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 ```text
Status: Draft Status: Draft
Owner: _null Owner: _null
Last reviewed: 2026-08-18 Last reviewed: 2026-08-20
Governs: README.md as the project-facing overview for Privacy: Period Tracker 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 Review trigger: The first buildable feature release; any change to the stack, the
privacy promise, or how the project is built and run 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 ## Status
**Five of the eight batches are done.** Foundation, prediction engine, core UX, Foundation through Batch 06 are closed, Batch 08 polish is complete, and
fertility and notifications have landed; privacy and security (Batch 06) is the monetization (Batch 07) remains unbuilt. Every row below cites the file or test
next work. Every row below cites the file or test that proves it. that proves it.
| Surface | Status | Evidence | | 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 | | 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` | | 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` | | 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` | | 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 | | 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 | | 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 <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="false"
android:theme="@style/Theme.Period" />
<activity-alias
android:name=".PeriodLauncherAlias"
android:enabled="true"
android:exported="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> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </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> </application>
</manifest> </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.core.security.AppLockRepository
import dev.privacyllc.period.domain.prediction.PersonalPredictionEngine import dev.privacyllc.period.domain.prediction.PersonalPredictionEngine
import dev.privacyllc.period.domain.prediction.PredictionEngine import dev.privacyllc.period.domain.prediction.PredictionEngine
import dev.privacyllc.period.launcher.AndroidLauncherAliasSwitcher
import dev.privacyllc.period.launcher.LauncherAliasSwitcher
import java.time.Clock import java.time.Clock
import javax.inject.Qualifier import javax.inject.Qualifier
import javax.inject.Singleton import javax.inject.Singleton
@ -107,6 +109,10 @@ object DataModule {
@Singleton @Singleton
fun reminderScheduler(@ApplicationContext context: Context): ReminderScheduler = fun reminderScheduler(@ApplicationContext context: Context): ReminderScheduler =
ReminderScheduler(context) ReminderScheduler(context)
@Provides
@Singleton
fun launcherAliasSwitcher(switcher: AndroidLauncherAliasSwitcher): LauncherAliasSwitcher = switcher
} }
/** Distinguishes the lock's store from the settings store; they are different files. */ /** 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 androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dev.privacyllc.period.core.data.CycleRepository 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.CoroutineExceptionHandler
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
/** What the Privacy & Security section is doing right now. */ /** What the Privacy & Security section is doing right now. */
enum class DeletionState { IDLE, DELETING, DONE, FAILED } enum class DeletionState { IDLE, DELETING, DONE, FAILED }
/** Whether changing the launcher aliases failed. */
enum class LauncherChangeState { IDLE, FAILED }
/** /**
* §45's Delete My Data. * §45's Delete My Data.
* *
@ -49,11 +58,25 @@ enum class DeletionState { IDLE, DELETING, DONE, FAILED }
@HiltViewModel @HiltViewModel
class PrivacyViewModel @Inject constructor( class PrivacyViewModel @Inject constructor(
private val repository: CycleRepository, private val repository: CycleRepository,
private val preferences: UserPreferencesRepository,
private val launcherAliasSwitcher: LauncherAliasSwitcher,
) : ViewModel() { ) : ViewModel() {
private val _deletion = MutableStateFlow(DeletionState.IDLE) private val _deletion = MutableStateFlow(DeletionState.IDLE)
val deletion: StateFlow<DeletionState> = _deletion.asStateFlow() 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. * 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 * also leave the user unable to tell whether their data is gone, which is
* the worst possible moment for an ambiguous outcome. * the worst possible moment for an ambiguous outcome.
*/ */
private val handler = CoroutineExceptionHandler { _, _ -> private val deletionHandler = CoroutineExceptionHandler { _, _ ->
_deletion.value = DeletionState.FAILED _deletion.value = DeletionState.FAILED
} }
private val launcherHandler = CoroutineExceptionHandler { _, _ ->
_launcherChange.value = LauncherChangeState.FAILED
}
fun deleteEverything() { fun deleteEverything() {
if (_deletion.value == DeletionState.DELETING) return if (_deletion.value == DeletionState.DELETING) return
_deletion.value = DeletionState.DELETING _deletion.value = DeletionState.DELETING
viewModelScope.launch(handler) { viewModelScope.launch(deletionHandler) {
repository.deleteAllHealthData() repository.deleteAllHealthData()
_deletion.value = DeletionState.DONE _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() { fun acknowledge() {
_deletion.value = DeletionState.IDLE _deletion.value = DeletionState.IDLE
} }

View File

@ -15,6 +15,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -67,7 +68,10 @@ fun SettingsScreen(
viewModel: PrivacyViewModel? = hiltViewModel(), viewModel: PrivacyViewModel? = hiltViewModel(),
) { ) {
val deletion = viewModel?.deletion?.collectAsStateWithLifecycle()?.value ?: DeletionState.IDLE 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 confirming by remember { mutableStateOf(false) }
var launcherTarget by remember { mutableStateOf<Boolean?>(null) }
Column( Column(
Modifier Modifier
@ -90,6 +94,16 @@ fun SettingsScreen(
subtitle = "Ask for a PIN before the app opens", subtitle = "Ask for a PIN before the app opens",
onClick = onOpenAppLock, 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( SettingsRow(
title = ExportCopy.ROW_TITLE, title = ExportCopy.ROW_TITLE,
subtitle = ExportCopy.ROW_SUBTITLE, 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) { when (deletion) {
DeletionState.DONE -> ResultDialog( DeletionState.DONE -> ResultDialog(
title = "Your data is deleted", title = "Your data is deleted",
@ -147,6 +172,14 @@ fun SettingsScreen(
) )
else -> Unit 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 @Composable
private fun ResultDialog(title: String, body: String, onDismiss: () -> Unit) { private fun ResultDialog(title: String, body: String, onDismiss: () -> Unit) {
AlertDialog( AlertDialog(
@ -270,6 +328,33 @@ private fun SettingsRow(title: String, subtitle: String, onClick: () -> Unit) {
HorizontalDivider() 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. * 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 §10); the launcher gets the short form, because a long label is truncated
on most home screens anyway. on most home screens anyway.
Worth a decision rather than a default: §32 wants an optional INCOGNITO Worth a decision rather than a default: §32's optional incognito launcher
launcher name and icon for people who share a phone, and that is the uses app_incognito_name instead, because a neutral icon under this label
feature that handles discretion. The default label is the brand. would still identify the app. The default label is the brand.
--> -->
<string name="app_name">Privacy: Period</string> <string name="app_name">Privacy: Period</string>
<string name="app_full_name">Privacy: Period Tracker</string> <string name="app_full_name">Privacy: Period Tracker</string>
<string name="app_tagline">Know your cycle. Protect your privacy.</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_today">Today</string>
<string name="tab_calendar">Calendar</string> <string name="tab_calendar">Calendar</string>

View File

@ -1,9 +1,13 @@
package dev.privacyllc.period.feature.settings package dev.privacyllc.period.feature.settings
import androidx.test.core.app.ApplicationProvider 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.CycleData
import dev.privacyllc.period.core.data.CycleRepository 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.domain.prediction.PersonalPredictionEngine
import dev.privacyllc.period.launcher.LauncherAliasSwitcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -14,9 +18,12 @@ import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@ -37,11 +44,15 @@ import java.time.ZoneOffset
@Config(sdk = [34]) @Config(sdk = [34])
class PrivacyViewModelTest { class PrivacyViewModelTest {
@get:Rule val tmp = TemporaryFolder()
private val dispatcher = UnconfinedTestDispatcher() private val dispatcher = UnconfinedTestDispatcher()
private val today = LocalDate.of(2026, 8, 18) private val today = LocalDate.of(2026, 8, 18)
private val clock = Clock.fixed(today.atStartOfDay(ZoneOffset.UTC).toInstant(), ZoneOffset.UTC) private val clock = Clock.fixed(today.atStartOfDay(ZoneOffset.UTC).toInstant(), ZoneOffset.UTC)
private lateinit var repo: CycleRepository private lateinit var repo: CycleRepository
private lateinit var prefs: UserPreferencesRepository
private lateinit var launcher: FakeLauncherAliasSwitcher
private lateinit var vm: PrivacyViewModel private lateinit var vm: PrivacyViewModel
@Before fun setUp() { @Before fun setUp() {
@ -52,7 +63,14 @@ class PrivacyViewModelTest {
clock, clock,
) )
runBlocking { repo.deleteAllHealthData() } 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() @After fun tearDown() = Dispatchers.resetMain()
@ -137,4 +155,53 @@ class PrivacyViewModelTest {
await { vm.deletion.value == DeletionState.DONE } await { vm.deletion.value == DeletionState.DONE }
assertTrue(runBlocking { repo.confirmedPeriods.first().isEmpty() }) 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 "ic_launcher_monochrome" to
"themed monochrome vector — the launcher tints it from the system palette, " + "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", "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 "ic_notification" to
"alpha-only status bar mark — Android masks a small icon to a silhouette " + "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", "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 theme: AppTheme = AppTheme.SYSTEM,
val adsRemoved: Boolean = false, val adsRemoved: Boolean = false,
val onboardingCompleted: Boolean = false, val onboardingCompleted: Boolean = false,
val incognitoLauncherEnabled: Boolean = false,
) { ) {
companion object { companion object {
/** /**

View File

@ -53,6 +53,7 @@ class UserPreferencesRepository(
suspend fun setBiometricLockEnabled(value: Boolean) = edit { it[Keys.BiometricLock] = value } suspend fun setBiometricLockEnabled(value: Boolean) = edit { it[Keys.BiometricLock] = value }
suspend fun setTheme(value: AppTheme) = edit { it[Keys.Theme] = value.name } suspend fun setTheme(value: AppTheme) = edit { it[Keys.Theme] = value.name }
suspend fun setOnboardingCompleted(value: Boolean) = edit { it[Keys.OnboardingCompleted] = value } 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. * How many times the app has asked "did it start?" without being told yes.
@ -112,6 +113,7 @@ class UserPreferencesRepository(
?: UserPreferences.Defaults.theme, ?: UserPreferences.Defaults.theme,
adsRemoved = p[Keys.AdsRemoved] ?: UserPreferences.Defaults.adsRemoved, adsRemoved = p[Keys.AdsRemoved] ?: UserPreferences.Defaults.adsRemoved,
onboardingCompleted = p[Keys.OnboardingCompleted] ?: UserPreferences.Defaults.onboardingCompleted, onboardingCompleted = p[Keys.OnboardingCompleted] ?: UserPreferences.Defaults.onboardingCompleted,
incognitoLauncherEnabled = p[Keys.IncognitoLauncher] ?: UserPreferences.Defaults.incognitoLauncherEnabled,
) )
private object Keys { private object Keys {
@ -128,6 +130,7 @@ class UserPreferencesRepository(
val Theme = stringPreferencesKey("theme") val Theme = stringPreferencesKey("theme")
val AdsRemoved = booleanPreferencesKey("ads_removed") val AdsRemoved = booleanPreferencesKey("ads_removed")
val OnboardingCompleted = booleanPreferencesKey("onboarding_completed") val OnboardingCompleted = booleanPreferencesKey("onboarding_completed")
val IncognitoLauncher = booleanPreferencesKey("incognito_launcher_enabled")
} }
private companion object { private companion object {

View File

@ -75,6 +75,7 @@ class UserPreferencesRepositoryTest {
assertFalse(p.adsRemoved) assertFalse(p.adsRemoved)
assertFalse(p.onboardingCompleted) assertFalse(p.onboardingCompleted)
assertFalse(p.biometricLockEnabled) assertFalse(p.biometricLockEnabled)
assertFalse(p.incognitoLauncherEnabled)
assertEquals(AppTheme.SYSTEM, p.theme) assertEquals(AppTheme.SYSTEM, p.theme)
assertEquals(LocalTime.of(10, 0), p.reminderTime) assertEquals(LocalTime.of(10, 0), p.reminderTime)
} }
@ -94,6 +95,7 @@ class UserPreferencesRepositoryTest {
repo.setTheme(AppTheme.DARK) repo.setTheme(AppTheme.DARK)
repo.setAdsRemoved(true) repo.setAdsRemoved(true)
repo.setOnboardingCompleted(true) repo.setOnboardingCompleted(true)
repo.setIncognitoLauncherEnabled(true)
assertEquals( assertEquals(
UserPreferences( UserPreferences(
@ -106,6 +108,7 @@ class UserPreferencesRepositoryTest {
theme = AppTheme.DARK, theme = AppTheme.DARK,
adsRemoved = true, adsRemoved = true,
onboardingCompleted = true, onboardingCompleted = true,
incognitoLauncherEnabled = true,
), ),
repo.preferences.first(), repo.preferences.first(),
) )
@ -171,6 +174,7 @@ class UserPreferencesRepositoryTest {
repo.setNotificationPrivacy(NotificationPrivacy.DIRECT) repo.setNotificationPrivacy(NotificationPrivacy.DIRECT)
repo.setTheme(AppTheme.LIGHT) repo.setTheme(AppTheme.LIGHT)
repo.setBiometricLockEnabled(true) repo.setBiometricLockEnabled(true)
repo.setIncognitoLauncherEnabled(true)
assertEquals(NotificationPrivacy.DIRECT, repo.preferences.first().notificationPrivacy) assertEquals(NotificationPrivacy.DIRECT, repo.preferences.first().notificationPrivacy)
@ -180,6 +184,7 @@ class UserPreferencesRepositoryTest {
assertEquals(NotificationPrivacy.DIRECT, p.notificationPrivacy) assertEquals(NotificationPrivacy.DIRECT, p.notificationPrivacy)
assertEquals(AppTheme.LIGHT, p.theme) assertEquals(AppTheme.LIGHT, p.theme)
assertTrue(p.biometricLockEnabled) assertTrue(p.biometricLockEnabled)
assertTrue(p.incognitoLauncherEnabled)
assertTrue(p.adsRemoved) assertTrue(p.adsRemoved)
} }
} }

View File

@ -3,7 +3,7 @@
``` ```
Status: Current Status: Current
Owner: _null Owner: _null
Last reviewed: 2026-08-18 Last reviewed: 2026-08-20
Governs: docs/architecture/**, settings.gradle.kts, build.gradle.kts, Governs: docs/architecture/**, settings.gradle.kts, build.gradle.kts,
core/database/**, domain/** — the Gradle module graph and the data core/database/**, domain/** — the Gradle module graph and the data
shapes that outlive a function 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/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 | | `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 | | `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 | | `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) | | `.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. 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 # 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, into an unreadable blob. A ring with one dot still reads at 48dp in one colour,
which is all that layer has to do. 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 ### What the artwork has to do
Two things at once, stated by the owner and recorded here because it is the brief 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 ## 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 ### 2026-08-20 (later) — Play listing assets created
Issue #32 is ready: the Play listing artwork now exists under Issue #32 is ready: the Play listing artwork now exists under

View File

@ -3,7 +3,7 @@
``` ```
Status: Current Status: Current
Owner: _null Owner: _null
Last reviewed: 2026-08-18 Last reviewed: 2026-08-20
Governs: what each QA pass actually reached Governs: what each QA pass actually reached
Review trigger: Any QA round run 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 > like a pass that succeeded, and that is how untested code ships believing it
> was tested. > 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 ## Round 3 — 2026-08-18 at `0451fbe`, partial
Batches 04 and 05 landed: fertility estimates and the reminder system. Pass F Batches 04 and 05 landed: fertility estimates and the reminder system. Pass F

View File

@ -3,7 +3,7 @@
``` ```
Status: Current Status: Current
Owner: _null 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 Governs: what this app protects, secret handling, data at rest, and what leaves
the device the device
Review trigger: Any new SDK or external service; any new secret; any change to 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 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 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 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), open this. The incognito launcher option ([§32](../planning/PRODUCT_PLAN.md))
not built yet) is the answer to the adjacent problem — is the answer to the adjacent problem — what the app *looks* like on a shared
what the app *looks* like on a shared home screen. home screen.
- **Network-level observation of ad traffic.** It carries no health data, which - **Network-level observation of ad traffic.** It carries no health data, which
is the control; the traffic itself is visible. is the control; the traffic itself is visible.