ui: add discreet launcher alias

closes #31
This commit is contained in:
null 2026-08-20 02:37:29 -05:00
parent 893d061400
commit 93ec5b7f91
24 changed files with 510 additions and 19 deletions

View File

@ -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 |

View File

@ -48,13 +48,36 @@
<activity
android:name=".MainActivity"
android:exported="false"
android:theme="@style/Theme.Period" />
<activity-alias
android:name=".PeriodLauncherAlias"
android:enabled="true"
android:exported="true"
android:theme="@style/Theme.Period">
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</activity-alias>
<activity-alias
android:name=".IncognitoLauncherAlias"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_incognito"
android:label="@string/app_incognito_name"
android:roundIcon="@mipmap/ic_launcher_incognito_round"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
</application>
</manifest>

View File

@ -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. */

View File

@ -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<DeletionState> = _deletion.asStateFlow()
val incognitoLauncherEnabled: StateFlow<Boolean> =
preferences.preferences
.map { it.incognitoLauncherEnabled }
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
UserPreferences.Defaults.incognitoLauncherEnabled,
)
private val _launcherChange = MutableStateFlow(LauncherChangeState.IDLE)
val launcherChange: StateFlow<LauncherChangeState> = _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
}

View File

@ -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<Boolean?>(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.
*

View File

@ -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,
)
}
}

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#F7F8FA"
android:pathData="M30,34 Q30,28 36,28 H72 Q78,28 78,34 V76 Q78,82 72,82 H36 Q30,82 30,76 Z" />
<path
android:fillColor="#596577"
android:pathData="M30,34 Q30,28 36,28 H72 Q78,28 78,34 V43 H30 Z" />
<path
android:fillColor="#F7F8FA"
android:pathData="M40,22 Q43,22 43,25 V34 Q43,37 40,37 Q37,37 37,34 V25 Q37,22 40,22 Z" />
<path
android:fillColor="#F7F8FA"
android:pathData="M68,22 Q71,22 71,25 V34 Q71,37 68,37 Q65,37 65,34 V25 Q65,22 68,22 Z" />
<path
android:fillColor="#A6AFBE"
android:pathData="M42,52 m-4,0 a4,4 0,1 1,8 0 a4,4 0,1 1,-8 0" />
<path
android:fillColor="#A6AFBE"
android:pathData="M54,52 m-4,0 a4,4 0,1 1,8 0 a4,4 0,1 1,-8 0" />
<path
android:fillColor="#A6AFBE"
android:pathData="M66,52 m-4,0 a4,4 0,1 1,8 0 a4,4 0,1 1,-8 0" />
<path
android:fillColor="#C6CCD6"
android:pathData="M42,66 m-4,0 a4,4 0,1 1,8 0 a4,4 0,1 1,-8 0" />
<path
android:fillColor="#C6CCD6"
android:pathData="M54,66 m-4,0 a4,4 0,1 1,8 0 a4,4 0,1 1,-8 0" />
<path
android:fillColor="#C6CCD6"
android:pathData="M66,66 m-4,0 a4,4 0,1 1,8 0 a4,4 0,1 1,-8 0" />
</vector>

View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#00000000"
android:pathData="M30,34 Q30,28 36,28 H72 Q78,28 78,34 V76 Q78,82 72,82 H36 Q30,82 30,76 Z"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="5"
android:strokeLineJoin="round" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M30,36 H78 V45 H30 Z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M40,22 Q43,22 43,25 V34 Q43,37 40,37 Q37,37 37,34 V25 Q37,22 40,22 Z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M68,22 Q71,22 71,25 V34 Q71,37 68,37 Q65,37 65,34 V25 Q65,22 68,22 Z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M42,56 m-4,0 a4,4 0,1 1,8 0 a4,4 0,1 1,-8 0" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M54,56 m-4,0 a4,4 0,1 1,8 0 a4,4 0,1 1,-8 0" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M66,56 m-4,0 a4,4 0,1 1,8 0 a4,4 0,1 1,-8 0" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M42,70 m-4,0 a4,4 0,1 1,8 0 a4,4 0,1 1,-8 0" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M54,70 m-4,0 a4,4 0,1 1,8 0 a4,4 0,1 1,-8 0" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M66,70 m-4,0 a4,4 0,1 1,8 0 a4,4 0,1 1,-8 0" />
</vector>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_incognito_background" />
<foreground android:drawable="@drawable/ic_launcher_incognito_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_incognito_monochrome" />
</adaptive-icon>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_incognito_background" />
<foreground android:drawable="@drawable/ic_launcher_incognito_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_incognito_monochrome" />
</adaptive-icon>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_incognito_background">#E9EDF2</color>
</resources>

View File

@ -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.
-->
<string name="app_name">Privacy: Period</string>
<string name="app_full_name">Privacy: Period Tracker</string>
<string name="app_tagline">Know your cycle. Protect your privacy.</string>
<string name="app_incognito_name">Daybook</string>
<string name="tab_today">Today</string>
<string name="tab_calendar">Calendar</string>

View File

@ -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<Boolean>()
var fail = false
override fun setIncognitoEnabled(enabled: Boolean) {
if (fail) error("launcher unavailable")
calls += enabled
}
}
}

View File

@ -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))
}
}

View File

@ -556,6 +556,12 @@ val themedDrawableExemptions: Map<String, String> = 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",

View File

@ -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 {
/**

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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 `<uses-permission>` and a `<provider>` merges green |
| `.githooks/` | pre-commit, commit-msg, post-commit — see [githooks/README.md](githooks/README.md) |

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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.