diff --git a/README.md b/README.md
index 425269c..d501f18 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
```text
Status: Draft
Owner: _null
-Last reviewed: 2026-08-18
+Last reviewed: 2026-08-20
Governs: README.md as the project-facing overview for Privacy: Period Tracker
Review trigger: The first buildable feature release; any change to the stack, the
privacy promise, or how the project is built and run
@@ -59,9 +59,9 @@ no article feed, no symptom encyclopedia, and not contraception — is in
## Status
-**Five of the eight batches are done.** Foundation, prediction engine, core UX,
-fertility and notifications have landed; privacy and security (Batch 06) is the
-next work. Every row below cites the file or test that proves it.
+Foundation through Batch 06 are closed, Batch 08 polish is complete, and
+monetization (Batch 07) remains unbuilt. Every row below cites the file or test
+that proves it.
| Surface | Status | Evidence |
| --- | --- | --- |
@@ -76,6 +76,7 @@ next work. Every row below cites the file or test that proves it.
| Fertility window and ovulation estimate | Built | `domain/prediction/src/main/kotlin/dev/privacyllc/period/domain/prediction/FertilityEstimate.kt`, 11 tests; shown on Today and Calendar |
| Discreet notifications | Built | `core/notifications` (24 JVM tests), instrumented `NotificationPrivacyTest` |
| App lock (PIN + fingerprint) | Built | `core/security` (Keystore-backed verifier, lockout policy), `app/src/main/kotlin/dev/privacyllc/period/lock` gate; 28 JVM tests plus `KeystoreVerifierTest` run on `PeriodMinSdk26` and `PeriodQA` |
+| Discreet launcher alias | Built | `app/src/main/AndroidManifest.xml` aliases; `LauncherAliasSwitcherTest`, `PrivacyViewModelTest`; device checked on `emulator-5554` |
| Export My Data | Built | `core/export` (format pinned byte-for-byte against `core/export/src/test/resources/golden-v1.json`), written through the Storage Access Framework by `app/src/main/kotlin/dev/privacyllc/period/feature/export` |
| Monetization | Not built | Batch 07 |
| QA | Round 3 run, partial | [docs/qa/ClaudeReport.md](docs/qa/ClaudeReport.md) — partial at `0451fbe`; TalkBack, text scaling, `minSdk` and a real locked screen still unreached |
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 8bd8e5d..d1fc8f7 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -48,13 +48,36 @@
+
+
+ android:icon="@mipmap/ic_launcher"
+ android:label="@string/app_name"
+ android:roundIcon="@mipmap/ic_launcher_round"
+ android:targetActivity=".MainActivity">
-
+
+
+
+
+
+
+
+
diff --git a/app/src/main/kotlin/dev/privacyllc/period/di/DataModule.kt b/app/src/main/kotlin/dev/privacyllc/period/di/DataModule.kt
index d8cef05..a09cb0b 100644
--- a/app/src/main/kotlin/dev/privacyllc/period/di/DataModule.kt
+++ b/app/src/main/kotlin/dev/privacyllc/period/di/DataModule.kt
@@ -17,6 +17,8 @@ import dev.privacyllc.period.core.notifications.ReminderScheduler
import dev.privacyllc.period.core.security.AppLockRepository
import dev.privacyllc.period.domain.prediction.PersonalPredictionEngine
import dev.privacyllc.period.domain.prediction.PredictionEngine
+import dev.privacyllc.period.launcher.AndroidLauncherAliasSwitcher
+import dev.privacyllc.period.launcher.LauncherAliasSwitcher
import java.time.Clock
import javax.inject.Qualifier
import javax.inject.Singleton
@@ -107,6 +109,10 @@ object DataModule {
@Singleton
fun reminderScheduler(@ApplicationContext context: Context): ReminderScheduler =
ReminderScheduler(context)
+
+ @Provides
+ @Singleton
+ fun launcherAliasSwitcher(switcher: AndroidLauncherAliasSwitcher): LauncherAliasSwitcher = switcher
}
/** Distinguishes the lock's store from the settings store; they are different files. */
diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/settings/PrivacyViewModel.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/settings/PrivacyViewModel.kt
index e81fc56..184484e 100644
--- a/app/src/main/kotlin/dev/privacyllc/period/feature/settings/PrivacyViewModel.kt
+++ b/app/src/main/kotlin/dev/privacyllc/period/feature/settings/PrivacyViewModel.kt
@@ -4,16 +4,25 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.privacyllc.period.core.data.CycleRepository
+import dev.privacyllc.period.core.datastore.UserPreferences
+import dev.privacyllc.period.core.datastore.UserPreferencesRepository
+import dev.privacyllc.period.launcher.LauncherAliasSwitcher
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
/** What the Privacy & Security section is doing right now. */
enum class DeletionState { IDLE, DELETING, DONE, FAILED }
+/** Whether changing the launcher aliases failed. */
+enum class LauncherChangeState { IDLE, FAILED }
+
/**
* §45's Delete My Data.
*
@@ -49,11 +58,25 @@ enum class DeletionState { IDLE, DELETING, DONE, FAILED }
@HiltViewModel
class PrivacyViewModel @Inject constructor(
private val repository: CycleRepository,
+ private val preferences: UserPreferencesRepository,
+ private val launcherAliasSwitcher: LauncherAliasSwitcher,
) : ViewModel() {
private val _deletion = MutableStateFlow(DeletionState.IDLE)
val deletion: StateFlow = _deletion.asStateFlow()
+ val incognitoLauncherEnabled: StateFlow =
+ preferences.preferences
+ .map { it.incognitoLauncherEnabled }
+ .stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(5_000),
+ UserPreferences.Defaults.incognitoLauncherEnabled,
+ )
+
+ private val _launcherChange = MutableStateFlow(LauncherChangeState.IDLE)
+ val launcherChange: StateFlow = _launcherChange.asStateFlow()
+
/**
* A failure surfaces instead of taking the process down.
*
@@ -62,19 +85,34 @@ class PrivacyViewModel @Inject constructor(
* also leave the user unable to tell whether their data is gone, which is
* the worst possible moment for an ambiguous outcome.
*/
- private val handler = CoroutineExceptionHandler { _, _ ->
+ private val deletionHandler = CoroutineExceptionHandler { _, _ ->
_deletion.value = DeletionState.FAILED
}
+ private val launcherHandler = CoroutineExceptionHandler { _, _ ->
+ _launcherChange.value = LauncherChangeState.FAILED
+ }
+
fun deleteEverything() {
if (_deletion.value == DeletionState.DELETING) return
_deletion.value = DeletionState.DELETING
- viewModelScope.launch(handler) {
+ viewModelScope.launch(deletionHandler) {
repository.deleteAllHealthData()
_deletion.value = DeletionState.DONE
}
}
+ fun setIncognitoLauncherEnabled(enabled: Boolean) {
+ viewModelScope.launch(launcherHandler) {
+ launcherAliasSwitcher.setIncognitoEnabled(enabled)
+ preferences.setIncognitoLauncherEnabled(enabled)
+ }
+ }
+
+ fun acknowledgeLauncherChange() {
+ _launcherChange.value = LauncherChangeState.IDLE
+ }
+
fun acknowledge() {
_deletion.value = DeletionState.IDLE
}
diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/settings/SettingsScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/settings/SettingsScreen.kt
index 48ba592..31ec030 100644
--- a/app/src/main/kotlin/dev/privacyllc/period/feature/settings/SettingsScreen.kt
+++ b/app/src/main/kotlin/dev/privacyllc/period/feature/settings/SettingsScreen.kt
@@ -15,6 +15,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -67,7 +68,10 @@ fun SettingsScreen(
viewModel: PrivacyViewModel? = hiltViewModel(),
) {
val deletion = viewModel?.deletion?.collectAsStateWithLifecycle()?.value ?: DeletionState.IDLE
+ val launcherChange = viewModel?.launcherChange?.collectAsStateWithLifecycle()?.value ?: LauncherChangeState.IDLE
+ val incognitoLauncher = viewModel?.incognitoLauncherEnabled?.collectAsStateWithLifecycle()?.value ?: false
var confirming by remember { mutableStateOf(false) }
+ var launcherTarget by remember { mutableStateOf(null) }
Column(
Modifier
@@ -90,6 +94,16 @@ fun SettingsScreen(
subtitle = "Ask for a PIN before the app opens",
onClick = onOpenAppLock,
)
+ SettingsSwitchRow(
+ title = "Discreet launcher",
+ subtitle = if (incognitoLauncher) {
+ "Home screen shows Daybook with a neutral icon"
+ } else {
+ "Use a neutral home-screen name and icon"
+ },
+ checked = incognitoLauncher,
+ onChange = { launcherTarget = it },
+ )
SettingsRow(
title = ExportCopy.ROW_TITLE,
subtitle = ExportCopy.ROW_SUBTITLE,
@@ -131,6 +145,17 @@ fun SettingsScreen(
)
}
+ launcherTarget?.let { target ->
+ LauncherChangeConfirmation(
+ enable = target,
+ onConfirm = {
+ launcherTarget = null
+ viewModel?.setIncognitoLauncherEnabled(target)
+ },
+ onDismiss = { launcherTarget = null },
+ )
+ }
+
when (deletion) {
DeletionState.DONE -> ResultDialog(
title = "Your data is deleted",
@@ -147,6 +172,14 @@ fun SettingsScreen(
)
else -> Unit
}
+
+ if (launcherChange == LauncherChangeState.FAILED) {
+ ResultDialog(
+ title = "Launcher was not changed",
+ body = "Something went wrong and your current home-screen icon and name are still in use.",
+ onDismiss = { viewModel?.acknowledgeLauncherChange() },
+ )
+ }
}
/**
@@ -181,6 +214,31 @@ private fun DeleteConfirmation(onConfirm: () -> Unit, onDismiss: () -> Unit) {
)
}
+@Composable
+private fun LauncherChangeConfirmation(
+ enable: Boolean,
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ val title = if (enable) "Use the discreet launcher?" else "Restore the app launcher?"
+ val action = if (enable) "Use Daybook" else "Restore"
+ val label = if (enable) "Daybook" else stringResource(R.string.app_name)
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(title) },
+ text = {
+ Text(
+ "Your home screen will show \"$label\" with its matching icon.\n\n" +
+ "Changing this removes and re-adds the launcher entry. If you " +
+ "placed the icon by hand, you may need to place it again, and " +
+ "some launchers keep the old icon until they restart.",
+ )
+ },
+ confirmButton = { TextButton(onClick = onConfirm) { Text(action) } },
+ dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
+ )
+}
+
@Composable
private fun ResultDialog(title: String, body: String, onDismiss: () -> Unit) {
AlertDialog(
@@ -270,6 +328,33 @@ private fun SettingsRow(title: String, subtitle: String, onClick: () -> Unit) {
HorizontalDivider()
}
+@Composable
+private fun SettingsSwitchRow(
+ title: String,
+ subtitle: String,
+ checked: Boolean,
+ onChange: (Boolean) -> Unit,
+) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f).padding(end = 16.dp)) {
+ Text(title, style = MaterialTheme.typography.bodyLarge)
+ Text(
+ subtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Switch(checked = checked, onCheckedChange = onChange)
+ }
+ HorizontalDivider()
+}
+
/**
* A row that opens its own explanation.
*
diff --git a/app/src/main/kotlin/dev/privacyllc/period/launcher/LauncherAliasSwitcher.kt b/app/src/main/kotlin/dev/privacyllc/period/launcher/LauncherAliasSwitcher.kt
new file mode 100644
index 0000000..0414610
--- /dev/null
+++ b/app/src/main/kotlin/dev/privacyllc/period/launcher/LauncherAliasSwitcher.kt
@@ -0,0 +1,48 @@
+package dev.privacyllc.period.launcher
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.pm.PackageManager
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dev.privacyllc.period.MainActivity
+import javax.inject.Inject
+
+interface LauncherAliasSwitcher {
+ fun setIncognitoEnabled(enabled: Boolean)
+}
+
+class AndroidLauncherAliasSwitcher @Inject constructor(
+ @ApplicationContext private val context: Context,
+) : LauncherAliasSwitcher {
+
+ override fun setIncognitoEnabled(enabled: Boolean) {
+ val packageName = context.packageName
+ val aliasPackage = MainActivity::class.java.name.substringBeforeLast('.')
+ val standard = ComponentName(packageName, "$aliasPackage.PeriodLauncherAlias")
+ val incognito = ComponentName(packageName, "$aliasPackage.IncognitoLauncherAlias")
+
+ if (enabled) {
+ enable(incognito)
+ disable(standard)
+ } else {
+ enable(standard)
+ disable(incognito)
+ }
+ }
+
+ private fun enable(component: ComponentName) {
+ set(component, PackageManager.COMPONENT_ENABLED_STATE_ENABLED)
+ }
+
+ private fun disable(component: ComponentName) {
+ set(component, PackageManager.COMPONENT_ENABLED_STATE_DISABLED)
+ }
+
+ private fun set(component: ComponentName, state: Int) {
+ context.packageManager.setComponentEnabledSetting(
+ component,
+ state,
+ PackageManager.DONT_KILL_APP,
+ )
+ }
+}
diff --git a/app/src/main/res/drawable/ic_launcher_incognito_foreground.xml b/app/src/main/res/drawable/ic_launcher_incognito_foreground.xml
new file mode 100644
index 0000000..883c20e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_incognito_foreground.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_incognito_monochrome.xml b/app/src/main/res/drawable/ic_launcher_incognito_monochrome.xml
new file mode 100644
index 0000000..e584485
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_incognito_monochrome.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_incognito.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_incognito.xml
new file mode 100644
index 0000000..d9edbf5
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_incognito.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_incognito_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_incognito_round.xml
new file mode 100644
index 0000000..d9edbf5
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_incognito_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/values/ic_launcher_incognito_background.xml b/app/src/main/res/values/ic_launcher_incognito_background.xml
new file mode 100644
index 0000000..af1fc7e
--- /dev/null
+++ b/app/src/main/res/values/ic_launcher_incognito_background.xml
@@ -0,0 +1,4 @@
+
+
+ #E9EDF2
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index c0440cd..5115e13 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -5,13 +5,14 @@
§10); the launcher gets the short form, because a long label is truncated
on most home screens anyway.
- Worth a decision rather than a default: §32 wants an optional INCOGNITO
- launcher name and icon for people who share a phone, and that is the
- feature that handles discretion. The default label is the brand.
+ Worth a decision rather than a default: §32's optional incognito launcher
+ uses app_incognito_name instead, because a neutral icon under this label
+ would still identify the app. The default label is the brand.
-->
Privacy: Period
Privacy: Period Tracker
Know your cycle. Protect your privacy.
+ Daybook
Today
Calendar
diff --git a/app/src/test/kotlin/dev/privacyllc/period/feature/settings/PrivacyViewModelTest.kt b/app/src/test/kotlin/dev/privacyllc/period/feature/settings/PrivacyViewModelTest.kt
index 135dba9..ba43b84 100644
--- a/app/src/test/kotlin/dev/privacyllc/period/feature/settings/PrivacyViewModelTest.kt
+++ b/app/src/test/kotlin/dev/privacyllc/period/feature/settings/PrivacyViewModelTest.kt
@@ -1,9 +1,13 @@
package dev.privacyllc.period.feature.settings
import androidx.test.core.app.ApplicationProvider
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import dev.privacyllc.period.core.data.CycleData
import dev.privacyllc.period.core.data.CycleRepository
+import dev.privacyllc.period.core.datastore.UserPreferencesRepository
import dev.privacyllc.period.domain.prediction.PersonalPredictionEngine
+import dev.privacyllc.period.launcher.LauncherAliasSwitcher
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
@@ -14,9 +18,12 @@ import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
+import org.junit.Rule
import org.junit.Test
+import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@@ -37,11 +44,15 @@ import java.time.ZoneOffset
@Config(sdk = [34])
class PrivacyViewModelTest {
+ @get:Rule val tmp = TemporaryFolder()
+
private val dispatcher = UnconfinedTestDispatcher()
private val today = LocalDate.of(2026, 8, 18)
private val clock = Clock.fixed(today.atStartOfDay(ZoneOffset.UTC).toInstant(), ZoneOffset.UTC)
private lateinit var repo: CycleRepository
+ private lateinit var prefs: UserPreferencesRepository
+ private lateinit var launcher: FakeLauncherAliasSwitcher
private lateinit var vm: PrivacyViewModel
@Before fun setUp() {
@@ -52,7 +63,14 @@ class PrivacyViewModelTest {
clock,
)
runBlocking { repo.deleteAllHealthData() }
- vm = PrivacyViewModel(repo)
+ prefs = UserPreferencesRepository(
+ PreferenceDataStoreFactory.create(
+ scope = CoroutineScope(dispatcher),
+ produceFile = { tmp.newFile("prefs.preferences_pb") },
+ ),
+ )
+ launcher = FakeLauncherAliasSwitcher()
+ vm = PrivacyViewModel(repo, prefs, launcher)
}
@After fun tearDown() = Dispatchers.resetMain()
@@ -137,4 +155,53 @@ class PrivacyViewModelTest {
await { vm.deletion.value == DeletionState.DONE }
assertTrue(runBlocking { repo.confirmedPeriods.first().isEmpty() })
}
+
+ @Test fun `the discreet launcher starts off`() {
+ assertFalse(runBlocking { prefs.preferences.first().incognitoLauncherEnabled })
+ assertFalse(vm.incognitoLauncherEnabled.value)
+ }
+
+ @Test fun `turning on the discreet launcher switches aliases and records the setting`() {
+ vm.setIncognitoLauncherEnabled(true)
+
+ assertEquals(listOf(true), launcher.calls)
+ assertTrue(runBlocking { prefs.preferences.first().incognitoLauncherEnabled })
+ }
+
+ @Test fun `restoring the normal launcher switches aliases and records the setting`() {
+ vm.setIncognitoLauncherEnabled(true)
+ vm.setIncognitoLauncherEnabled(false)
+
+ assertEquals(listOf(true, false), launcher.calls)
+ assertFalse(runBlocking { prefs.preferences.first().incognitoLauncherEnabled })
+ }
+
+ @Test fun `a failed alias switch does not record the setting`() {
+ launcher.fail = true
+
+ vm.setIncognitoLauncherEnabled(true)
+
+ assertEquals(LauncherChangeState.FAILED, vm.launcherChange.value)
+ assertFalse(runBlocking { prefs.preferences.first().incognitoLauncherEnabled })
+ }
+
+ @Test fun `delete my data does not reset the discreet launcher`() {
+ vm.setIncognitoLauncherEnabled(true)
+ seedThreeCycles()
+
+ vm.deleteEverything()
+ await { vm.deletion.value == DeletionState.DONE }
+
+ assertTrue(runBlocking { prefs.preferences.first().incognitoLauncherEnabled })
+ }
+
+ private class FakeLauncherAliasSwitcher : LauncherAliasSwitcher {
+ val calls = mutableListOf()
+ var fail = false
+
+ override fun setIncognitoEnabled(enabled: Boolean) {
+ if (fail) error("launcher unavailable")
+ calls += enabled
+ }
+ }
}
diff --git a/app/src/test/kotlin/dev/privacyllc/period/launcher/LauncherAliasSwitcherTest.kt b/app/src/test/kotlin/dev/privacyllc/period/launcher/LauncherAliasSwitcherTest.kt
new file mode 100644
index 0000000..3136a15
--- /dev/null
+++ b/app/src/test/kotlin/dev/privacyllc/period/launcher/LauncherAliasSwitcherTest.kt
@@ -0,0 +1,39 @@
+package dev.privacyllc.period.launcher
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.pm.PackageManager
+import androidx.test.core.app.ApplicationProvider
+import dev.privacyllc.period.MainActivity
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+class LauncherAliasSwitcherTest {
+
+ private val context: Context = ApplicationProvider.getApplicationContext()
+ private val switcher = AndroidLauncherAliasSwitcher(context)
+ private val pm = context.packageManager
+ private val packageName = context.packageName
+ private val aliasPackage = MainActivity::class.java.name.substringBeforeLast('.')
+ private val standard = ComponentName(packageName, "$aliasPackage.PeriodLauncherAlias")
+ private val incognito = ComponentName(packageName, "$aliasPackage.IncognitoLauncherAlias")
+
+ @Test fun `enabling incognito turns on only the neutral alias`() {
+ switcher.setIncognitoEnabled(true)
+
+ assertEquals(PackageManager.COMPONENT_ENABLED_STATE_DISABLED, pm.getComponentEnabledSetting(standard))
+ assertEquals(PackageManager.COMPONENT_ENABLED_STATE_ENABLED, pm.getComponentEnabledSetting(incognito))
+ }
+
+ @Test fun `disabling incognito restores only the normal alias`() {
+ switcher.setIncognitoEnabled(false)
+
+ assertEquals(PackageManager.COMPONENT_ENABLED_STATE_ENABLED, pm.getComponentEnabledSetting(standard))
+ assertEquals(PackageManager.COMPONENT_ENABLED_STATE_DISABLED, pm.getComponentEnabledSetting(incognito))
+ }
+}
diff --git a/build.gradle.kts b/build.gradle.kts
index b93b549..030531b 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -556,6 +556,12 @@ val themedDrawableExemptions: Map = mapOf(
"ic_launcher_monochrome" to
"themed monochrome vector — the launcher tints it from the system palette, " +
"so a night copy would be a second source of truth for one shape",
+ "ic_launcher_incognito_foreground" to
+ "neutral activity-alias launcher artwork — Android renders the adaptive icon " +
+ "outside the app theme, so duplicating it in drawable-night would be a second source of truth",
+ "ic_launcher_incognito_monochrome" to
+ "themed monochrome activity-alias vector — the launcher tints it from the " +
+ "system palette, so a night copy would be a second source of truth for one shape",
"ic_notification" to
"alpha-only status bar mark — Android masks a small icon to a silhouette " +
"and supplies the colour itself, so a dark variant would never be drawn",
diff --git a/core/datastore/src/main/kotlin/dev/privacyllc/period/core/datastore/UserPreferences.kt b/core/datastore/src/main/kotlin/dev/privacyllc/period/core/datastore/UserPreferences.kt
index 187612a..25f2dae 100644
--- a/core/datastore/src/main/kotlin/dev/privacyllc/period/core/datastore/UserPreferences.kt
+++ b/core/datastore/src/main/kotlin/dev/privacyllc/period/core/datastore/UserPreferences.kt
@@ -49,6 +49,7 @@ data class UserPreferences(
val theme: AppTheme = AppTheme.SYSTEM,
val adsRemoved: Boolean = false,
val onboardingCompleted: Boolean = false,
+ val incognitoLauncherEnabled: Boolean = false,
) {
companion object {
/**
diff --git a/core/datastore/src/main/kotlin/dev/privacyllc/period/core/datastore/UserPreferencesRepository.kt b/core/datastore/src/main/kotlin/dev/privacyllc/period/core/datastore/UserPreferencesRepository.kt
index d68e775..86e1d02 100644
--- a/core/datastore/src/main/kotlin/dev/privacyllc/period/core/datastore/UserPreferencesRepository.kt
+++ b/core/datastore/src/main/kotlin/dev/privacyllc/period/core/datastore/UserPreferencesRepository.kt
@@ -53,6 +53,7 @@ class UserPreferencesRepository(
suspend fun setBiometricLockEnabled(value: Boolean) = edit { it[Keys.BiometricLock] = value }
suspend fun setTheme(value: AppTheme) = edit { it[Keys.Theme] = value.name }
suspend fun setOnboardingCompleted(value: Boolean) = edit { it[Keys.OnboardingCompleted] = value }
+ suspend fun setIncognitoLauncherEnabled(value: Boolean) = edit { it[Keys.IncognitoLauncher] = value }
/**
* How many times the app has asked "did it start?" without being told yes.
@@ -112,6 +113,7 @@ class UserPreferencesRepository(
?: UserPreferences.Defaults.theme,
adsRemoved = p[Keys.AdsRemoved] ?: UserPreferences.Defaults.adsRemoved,
onboardingCompleted = p[Keys.OnboardingCompleted] ?: UserPreferences.Defaults.onboardingCompleted,
+ incognitoLauncherEnabled = p[Keys.IncognitoLauncher] ?: UserPreferences.Defaults.incognitoLauncherEnabled,
)
private object Keys {
@@ -128,6 +130,7 @@ class UserPreferencesRepository(
val Theme = stringPreferencesKey("theme")
val AdsRemoved = booleanPreferencesKey("ads_removed")
val OnboardingCompleted = booleanPreferencesKey("onboarding_completed")
+ val IncognitoLauncher = booleanPreferencesKey("incognito_launcher_enabled")
}
private companion object {
diff --git a/core/datastore/src/test/kotlin/dev/privacyllc/period/core/datastore/UserPreferencesRepositoryTest.kt b/core/datastore/src/test/kotlin/dev/privacyllc/period/core/datastore/UserPreferencesRepositoryTest.kt
index 35284a0..aaad9a7 100644
--- a/core/datastore/src/test/kotlin/dev/privacyllc/period/core/datastore/UserPreferencesRepositoryTest.kt
+++ b/core/datastore/src/test/kotlin/dev/privacyllc/period/core/datastore/UserPreferencesRepositoryTest.kt
@@ -75,6 +75,7 @@ class UserPreferencesRepositoryTest {
assertFalse(p.adsRemoved)
assertFalse(p.onboardingCompleted)
assertFalse(p.biometricLockEnabled)
+ assertFalse(p.incognitoLauncherEnabled)
assertEquals(AppTheme.SYSTEM, p.theme)
assertEquals(LocalTime.of(10, 0), p.reminderTime)
}
@@ -94,6 +95,7 @@ class UserPreferencesRepositoryTest {
repo.setTheme(AppTheme.DARK)
repo.setAdsRemoved(true)
repo.setOnboardingCompleted(true)
+ repo.setIncognitoLauncherEnabled(true)
assertEquals(
UserPreferences(
@@ -106,6 +108,7 @@ class UserPreferencesRepositoryTest {
theme = AppTheme.DARK,
adsRemoved = true,
onboardingCompleted = true,
+ incognitoLauncherEnabled = true,
),
repo.preferences.first(),
)
@@ -171,6 +174,7 @@ class UserPreferencesRepositoryTest {
repo.setNotificationPrivacy(NotificationPrivacy.DIRECT)
repo.setTheme(AppTheme.LIGHT)
repo.setBiometricLockEnabled(true)
+ repo.setIncognitoLauncherEnabled(true)
assertEquals(NotificationPrivacy.DIRECT, repo.preferences.first().notificationPrivacy)
@@ -180,6 +184,7 @@ class UserPreferencesRepositoryTest {
assertEquals(NotificationPrivacy.DIRECT, p.notificationPrivacy)
assertEquals(AppTheme.LIGHT, p.theme)
assertTrue(p.biometricLockEnabled)
+ assertTrue(p.incognitoLauncherEnabled)
assertTrue(p.adsRemoved)
}
}
diff --git a/docs/architecture/README.md b/docs/architecture/README.md
index c978da2..a16a33e 100644
--- a/docs/architecture/README.md
+++ b/docs/architecture/README.md
@@ -3,7 +3,7 @@
```
Status: Current
Owner: _null
-Last reviewed: 2026-08-18
+Last reviewed: 2026-08-20
Governs: docs/architecture/**, settings.gradle.kts, build.gradle.kts,
core/database/**, domain/** — the Gradle module graph and the data
shapes that outlive a function
@@ -437,7 +437,7 @@ menu is.
| `scripts/prove-guard.sh` | breaks what a guard protects and requires the guard to go red |
| `scripts/schema-guard.sh` | a Room entity may not change without the version changing with it — asks git, because Room overwrites the export during the build |
| `checkNoHealthLogging` (root `build.gradle.kts`) | no logging call may exist in a module that can see a cycle date — §45. Strips comments and matches a call rather than the class, so the `Log.WARN` constant and the KDoc explaining the rule both stay legal |
-| `checkThemedDrawables` (root `build.gradle.kts`) | every drawable has a `-night` twin of the same name, both directions. A missing night asset fails nothing at runtime — Android falls back to the light one and draws it on a dark screen — so the only other way to notice is to open that screen in that theme. Exemptions are a named map with reasons, not a narrowed scope |
+| `checkThemedDrawables` (root `build.gradle.kts`) | every drawable has a `-night` twin of the same name, both directions. A missing night asset fails nothing at runtime — Android falls back to the light one and draws it on a dark screen — so the only other way to notice is to open that screen in that theme. Exemptions are a named map with reasons, not a narrowed scope; launcher-only exceptions are limited to system-tinted monochrome resources and the neutral activity-alias icon whose adaptive icon is resolved outside the app theme |
| `checkNoSharedStorageWrites` (root `build.gradle.kts`) | no module that can see a cycle date may name `getExternalFilesDir`, `MediaStore`, `FileProvider` or `ACTION_SEND` — §45's shared-storage ban, which `checkPermissions` structurally cannot see because it matches `` and a `` merges green |
| `.githooks/` | pre-commit, commit-msg, post-commit — see [githooks/README.md](githooks/README.md) |
diff --git a/docs/design/BRAND_GUIDE.md b/docs/design/BRAND_GUIDE.md
index 50b1b38..0ff237f 100644
--- a/docs/design/BRAND_GUIDE.md
+++ b/docs/design/BRAND_GUIDE.md
@@ -638,6 +638,17 @@ Selected cycle dot:
Add a very faint pink-purple glow behind the emblem.
+### Discreet Launcher Alias
+
+The optional launcher alias is privacy utility, not brand expression. Its label
+is `Daybook`, and its icon should read as a neutral personal calendar: simple
+geometric page shape, muted grey/lavender dots, no rose accent, no cycle ring,
+no shield/lock, and no menstrual imagery.
+
+It uses the same adaptive-icon mechanics as the main launcher icon, but the art
+must stay generic enough that the label and icon do not identify a period
+tracker on a shared home screen.
+
---
# 19. Simplified Small Icon
diff --git a/docs/design/README.md b/docs/design/README.md
index 01ea8f4..f4441c1 100644
--- a/docs/design/README.md
+++ b/docs/design/README.md
@@ -242,6 +242,11 @@ emblem's overlapping calendar, shield, padlock, crescent and leaves would flatte
into an unreadable blob. A ring with one dot still reads at 48dp in one colour,
which is all that layer has to do.
+**The discreet launcher is not a second brand.** It is an activity alias named
+`Daybook`, with a neutral calendar-and-dot icon that avoids rose accents, cycle
+rings, shields, locks and menstrual imagery. The normal launcher keeps the real
+brand; the alias exists for shared-home-screen discretion.
+
### What the artwork has to do
Two things at once, stated by the owner and recorded here because it is the brief
diff --git a/docs/history/DEVELOPMENT_LOG.md b/docs/history/DEVELOPMENT_LOG.md
index b258a08..96af0a7 100644
--- a/docs/history/DEVELOPMENT_LOG.md
+++ b/docs/history/DEVELOPMENT_LOG.md
@@ -32,6 +32,42 @@ written and stay true. It is exempt from review for the same reason a receipt is
## Entries
+### 2026-08-20 (final) — Discreet launcher alias landed
+
+Issue #31 is ready. The normal launcher entry is now
+`PeriodLauncherAlias`, and the optional discreet entry is
+`IncognitoLauncherAlias`, both targeting the same `MainActivity` and shipping
+under the same application id. Settings exposes the choice as **Discreet
+launcher**, confirms that the home-screen entry is removed and re-added, and
+uses the neutral label `Daybook` with a generic calendar-dot adaptive icon.
+
+The preference lives in DataStore, survives Delete My Data, and is only recorded
+after `PackageManager` accepts the alias switch. The implementation enables the
+target alias before disabling the other one, so the package is never left
+without a launcher entry.
+
+**What this proved:** `LauncherAliasSwitcherTest` checks the real merged-manifest
+component names, including debug's `applicationIdSuffix`; `PrivacyViewModelTest`
+checks enable, restore, failure and delete-survival behavior;
+`UserPreferencesRepositoryTest` checks the DataStore default and round trip.
+`./gradlew :app:testDebugUnitTest` passed after clearing a generated KSP cache
+that was corrupted by concurrent Gradle runs, and
+`./gradlew :core:datastore:test checkThemedDrawables assembleDebug` passed.
+
+Device proof ran on `emulator-5554`, API 34: a fresh `adb install -r` produced
+one package, `dev.privacyllc.period.debug`; the default launcher resolver
+returned `dev.privacyllc.period.PeriodLauncherAlias`; turning on the setting
+through the UI switched the resolver to
+`dev.privacyllc.period.IncognitoLauncherAlias`; relaunch through that alias
+opened the app and Settings showed `Daybook` as active; restoring through the UI
+returned the resolver to `PeriodLauncherAlias`; a second `adb install -r`
+succeeded over the existing install and still listed only the one debug package.
+
+**Next action:** close Batch 08 once the tracker catches the `closes #31`
+commit, then move to Batch 07 monetization planning and issue filing.
+
+**Blockers:** none.
+
### 2026-08-20 (later) — Play listing assets created
Issue #32 is ready: the Play listing artwork now exists under
diff --git a/docs/qa/ClaudeQACoverage.md b/docs/qa/ClaudeQACoverage.md
index 1daa71a..5e13a57 100644
--- a/docs/qa/ClaudeQACoverage.md
+++ b/docs/qa/ClaudeQACoverage.md
@@ -3,7 +3,7 @@
```
Status: Current
Owner: _null
-Last reviewed: 2026-08-18
+Last reviewed: 2026-08-20
Governs: what each QA pass actually reached
Review trigger: Any QA round run
```
@@ -13,6 +13,25 @@ Review trigger: Any QA round run
> like a pass that succeeded, and that is how untested code ships believing it
> was tested.
+## Targeted Check — 2026-08-20, launcher alias
+
+Not a full QA round. This was the device proof for issue #31 on `emulator-5554`,
+API 34, with the debug package `dev.privacyllc.period.debug`.
+
+Fresh install resolved `MAIN`/`LAUNCHER` to
+`dev.privacyllc.period.PeriodLauncherAlias`. Onboarding was completed through
+the UI, Settings showed **Discreet launcher** off, and the confirmation dialog
+said the home screen would show `Daybook` while warning that the launcher entry
+is removed and re-added.
+
+After confirming, `cmd package query-activities` returned one launchable
+activity: `dev.privacyllc.period.IncognitoLauncherAlias`. Relaunching through
+that alias opened the app, and Settings showed the switch on with the copy
+"Home screen shows Daybook with a neutral icon." Restoring through the UI
+returned the resolver to `PeriodLauncherAlias`. A second `adb install -r`
+succeeded over the existing install, and `pm list packages dev.privacyllc.period`
+still returned only `dev.privacyllc.period.debug`.
+
## Round 3 — 2026-08-18 at `0451fbe`, partial
Batches 04 and 05 landed: fertility estimates and the reminder system. Pass F
diff --git a/docs/security/SECURITY.md b/docs/security/SECURITY.md
index d708aba..9e41ab6 100644
--- a/docs/security/SECURITY.md
+++ b/docs/security/SECURITY.md
@@ -3,7 +3,7 @@
```
Status: Current
Owner: _null
-Last reviewed: 2026-08-18
+Last reviewed: 2026-08-20
Governs: what this app protects, secret handling, data at rest, and what leaves
the device
Review trigger: Any new SDK or external service; any new secret; any change to
@@ -278,8 +278,8 @@ Written down so an unknown gap becomes a known one:
phone; it does not defend against a person the user has given access to. Note
the app's PIN is deliberately *not* the device's — the prompt never accepts the
device credential — so knowing how to unlock the phone is not knowing how to
- open this. The incognito launcher option ([§32](../planning/PRODUCT_PLAN.md),
- not built yet) is the answer to the adjacent problem —
- what the app *looks* like on a shared home screen.
+ open this. The incognito launcher option ([§32](../planning/PRODUCT_PLAN.md))
+ is the answer to the adjacent problem — what the app *looks* like on a shared
+ home screen.
- **Network-level observation of ad traffic.** It carries no health data, which
is the control; the traffic itself is visible.