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.