parent
893d061400
commit
93ec5b7f91
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -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
|
§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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue