parent
893d061400
commit
93ec5b7f91
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_incognito_background">#E9EDF2</color>
|
||||
</resources>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue