fbe981103717

L-01..05: intent router, OkHttp topPrivateDomain, sinceR haptic guard, strict SemVer on cache, user-initiated battery exemption

time
2026-07-29 15:44 UTC · 2 months ago
machine
koh2
agent
claude-opus-4
session
audit-remediation-2026-07-29
parent
d01087059810
12 files app/src/main/AndroidManifest.xml+3 −1app/src/main/java/com/peachpasswords/MainActivity.kt+17 −4app/src/main/java/com/peachpasswords/PeachIntentRouter.kt+50 −0app/src/main/java/com/peachpasswords/haptics/PeachHaptics.kt+13 −3app/src/main/java/com/peachpasswords/passkeys/PasskeyUnlockIntent.kt+2 −2app/src/main/java/com/peachpasswords/shared/DomainUtils.kt+13 −3app/src/main/java/com/peachpasswords/sync/BatteryExemption.kt+35 −1app/src/main/java/com/peachpasswords/ui/PeachAppNavHost.kt+24 −2app/src/main/java/com/peachpasswords/ui/pairing/CodePairScreen.kt+6 −3app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+9 −1app/src/main/java/com/peachpasswords/updates/UpdateManifest.kt+18 −2app/src/test/java/com/peachpasswords/PeachIntentRouterTest.kt+37 −0
app/src/main/AndroidManifest.xml+3 −1
@@ -14,7 +14,9 @@1414     <!-- Android 14+ requires the specific type alongside the generic permission. -->1515     <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />1616     <!-- Doze defers network work even for a foreground service, which would make17-         an already-paired browser's reconnect time out with no visible cause. -->17+         an already-paired browser's reconnect time out with no visible cause.18+         Play-policy-restricted: only ever requested from an explicit user tap, and19+         never required — see the distribution note in sync/BatteryExemption.kt. -->1820     <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />1921     <uses-permission android:name="android.permission.CAMERA" />2022     <!-- Peach ships outside the Play Store, so it installs its own updates via the

view file

app/src/main/java/com/peachpasswords/MainActivity.kt+17 −4
@@ -58,8 +58,7 @@5858     override fun onNewIntent(intent: Intent) {5959         super.onNewIntent(intent)6060         setIntent(intent)61-        if (intent.getBooleanExtra(EXTRA_OPEN_UPDATE, false)) updates.requestOpen()62-        forwardNewEntryRequest(intent)61+        routeIntent(intent)6362         if (BuildConfig.DEBUG) {6463             QaFlags.disableSecureCaptureForQa = intent.getBooleanExtra("qa_disable_secure_flag", false)6564         }@@ -70,8 +69,7 @@7069         super.onCreate(savedInstanceState)7170 7271         // Cold launch from the update notification (the warm case is onNewIntent).73-        if (intent.getBooleanExtra(EXTRA_OPEN_UPDATE, false)) updates.requestOpen()74-        forwardNewEntryRequest(intent)72+        routeIntent(intent)7573 7674         // Match the window fill to --bg so launch never flashes a lighter colour7775         // before Compose draws, whatever the device's own day/night mode is.@@ -124,6 +122,21 @@124122 125123 /** Layout width a phone gives us; anything past this is "extra" to spend. */126124 private val BASE_WIDTH = 411.dp125+126+/**127+ * The single place a launch intent is interpreted, shared by cold launch128+ * (`onCreate`) and warm relaunch (`onNewIntent`) — launchMode="singleTask" means129+ * either can be the one that carries a shortcut tap, and the two paths silently130+ * drifting apart is how the shortcut actions went unhandled in the first place.131+ *132+ * Everything here only records a request; nothing navigates. The nav host acts on133+ * it once the vault is unlocked.134+ */135+private fun MainActivity.routeIntent(intent: Intent) {136+    if (intent.getBooleanExtra(EXTRA_OPEN_UPDATE, false)) updates.requestOpen()137+    forwardNewEntryRequest(intent)138+    routeAction(intent.action)?.let(PendingActionQueue::request)139+}127140 128141 /**129142  * Pick up the "+ Create new entry" launcher from the autofill picker and stash

view file

app/src/main/java/com/peachpasswords/PeachIntentRouter.kt+50 −0
@@ -0,0 +1,50 @@1+package com.peachpasswords2+3+import kotlinx.coroutines.flow.MutableStateFlow4+import kotlinx.coroutines.flow.StateFlow5+import kotlinx.coroutines.flow.asStateFlow6+7+/**8+ * Launch actions the app declares to the system: the three launcher shortcuts in9+ * `res/xml/shortcuts.xml` and the credential-provider unlock entry point in10+ * [com.peachpasswords.passkeys.PasskeyUnlockIntent]. These strings are a public11+ * contract with the launcher and with system UI — they must stay byte-identical12+ * to the manifest/XML, so they live in one place rather than being retyped.13+ */14+object PeachActions {15+    const val SEARCH = "com.peachpasswords.action.SEARCH"16+    const val ADD = "com.peachpasswords.action.ADD"17+    const val PAIR = "com.peachpasswords.action.PAIR"18+    const val PASSKEY_UNLOCK = "com.peachpasswords.passkeys.ACTION_PASSKEY_UNLOCK"19+}20+21+/** What a launch action asks the app to do once the vault is actually open. */22+enum class PendingAction { Search, AddEntry, Pair, PasskeyUnlock }23+24+/** Unknown/absent actions (the plain launcher icon) route nowhere — start destination wins. */25+fun routeAction(action: String?): PendingAction? = when (action) {26+    PeachActions.SEARCH -> PendingAction.Search27+    PeachActions.ADD -> PendingAction.AddEntry28+    PeachActions.PAIR -> PendingAction.Pair29+    PeachActions.PASSKEY_UNLOCK -> PendingAction.PasskeyUnlock30+    else -> null31+}32+33+/**34+ * Holds the action from the launch intent until the vault is unlocked. Every one35+ * of these destinations needs an open vault, and a shortcut can be tapped while36+ * the app is locked or not running at all, so the request has to outlive the37+ * lock screen instead of being spent on it.38+ *39+ * ponytail: a process-scoped object rather than a Hilt singleton + ViewModel like40+ * [com.peachpasswords.autofill.AutofillNavRepository]. Single activity, single41+ * process, one consumer — the DI ceremony buys nothing here. Promote it if a42+ * second consumer or a test that needs isolation ever appears.43+ */44+object PendingActionQueue {45+    private val _pending = MutableStateFlow<PendingAction?>(null)46+    val pending: StateFlow<PendingAction?> = _pending.asStateFlow()47+48+    fun request(action: PendingAction) { _pending.value = action }49+    fun consume() { _pending.value = null }50+}

view file

app/src/main/java/com/peachpasswords/haptics/PeachHaptics.kt+13 −3
@@ -9,14 +9,24 @@99 import android.view.View1010 1111 object PeachHaptics {12-    fun onUnlock(view: View) = view.performHapticFeedback(HapticFeedbackConstants.CONFIRM)12+    fun onUnlock(view: View) = view.performHapticFeedback(sinceR(HapticFeedbackConstants.CONFIRM, HapticFeedbackConstants.VIRTUAL_KEY))1313     fun onEntryTap(view: View) = view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)1414     fun onCopy(view: View) = view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)1515     fun onSheetOpen(view: View) = view.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK)16-    fun onSheetClose(view: View) = view.performHapticFeedback(HapticFeedbackConstants.GESTURE_END)17-    fun onError(view: View) = view.performHapticFeedback(HapticFeedbackConstants.REJECT)16+    fun onSheetClose(view: View) = view.performHapticFeedback(sinceR(HapticFeedbackConstants.GESTURE_END, HapticFeedbackConstants.CONTEXT_CLICK))17+    fun onError(view: View) = view.performHapticFeedback(sinceR(HapticFeedbackConstants.REJECT, HapticFeedbackConstants.LONG_PRESS))1818     fun onButton(view: View) = view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)1919     fun onCardFocus(view: View) = view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)20+21+    /**22+     * CONFIRM / REJECT / GESTURE_END only exist from API 30. The constants are23+     * `int`s inlined at compile time, so on API 26-29 they don't fail loudly —24+     * `performHapticFeedback` just returns false and the device stays silent.25+     * Each falls back to the nearest pre-30 effect with the same intent: a short26+     * tick for confirm, a heavier buzz for reject, a soft click to close.27+     */28+    private fun sinceR(api30: Int, fallback: Int): Int =29+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) api30 else fallback2030 2131     fun onLongPress(context: Context) = vibrate(context, 20L, 180)2232     fun onSwipeCopy(context: Context) = vibrate(context, 15L, 150)

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyUnlockIntent.kt+2 −2
@@ -4,11 +4,12 @@44 import android.content.Context55 import android.content.Intent66 import com.peachpasswords.MainActivity7+import com.peachpasswords.PeachActions78 89 object PasskeyUnlockIntent {910     fun pendingIntent(context: Context): PendingIntent {1011         val intent = Intent(context, MainActivity::class.java)11-            .setAction(ACTION_PASSKEY_UNLOCK)12+            .setAction(PeachActions.PASSKEY_UNLOCK)1213             .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)1314         return PendingIntent.getActivity(1415             context,@@ -18,6 +19,5 @@1819         )1920     }2021 21-    private const val ACTION_PASSKEY_UNLOCK = "com.peachpasswords.passkeys.ACTION_PASSKEY_UNLOCK"2222     private const val REQUEST_CODE = 0x504b2323 }

view file

app/src/main/java/com/peachpasswords/shared/DomainUtils.kt+13 −3
@@ -1,12 +1,22 @@11 package com.peachpasswords.shared22 3-import okhttp3.internal.publicsuffix.PublicSuffixDatabase3+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull44 5+/**6+ * eTLD+1 ("bank.co.uk", not "co.uk"), the key everything from autofill matching to7+ * vault health compares on.8+ *9+ * [okhttp3.HttpUrl.topPrivateDomain] is OkHttp's *public* API over the same public10+ * suffix list its internal `PublicSuffixDatabase` exposes, so this keeps real PSL11+ * accuracy without depending on an `okhttp3.internal` class. Accuracy is not12+ * optional here: a "last two labels" heuristic would collapse `evil.co.uk` and13+ * `bank.co.uk` to the same key and hand credentials to the wrong site.14+ * Returns null for IP literals and bare public suffixes, as before.15+ */516 fun extractDomain(url: String): String? {617     return try {718         val normalized = if (url.startsWith("http")) url else "https://$url"8-        val host = java.net.URL(normalized).host.lowercase()9-        PublicSuffixDatabase.get().getEffectiveTldPlusOne(host)19+        normalized.toHttpUrlOrNull()?.topPrivateDomain()1020     } catch (_: Exception) {1121         null1222     }

view file

app/src/main/java/com/peachpasswords/sync/BatteryExemption.kt+35 −1
@@ -20,6 +20,23 @@2020  * says no should get a phone that syncs when they open the app rather than one2121  * that keeps asking. [isExempt] lets settings show the real state instead of2222  * assuming the request worked.23+ *24+ * ## Distribution decision (L-05)25+ *26+ * `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` is a Play-policy-restricted permission:27+ * Google requires the app's core function to genuinely break under Doze, and28+ * rejects apps that prompt for it unprompted or treat it as a prerequisite. The29+ * position taken here, deliberately:30+ *31+ *  - Peach is distributed outside Play (Kepr-published APK + in-app updater), so32+ *    the permission is not currently a listing risk. If Peach is ever submitted33+ *    to Play, this is the declaration form to fill in, not a permission to strip.34+ *  - The request is only ever reachable from a user tap in the pairing flow's35+ *    background-sync dialog — [requestIntent] refuses to build an intent without36+ *    that explicit confirmation, so no code path can prompt on its own.37+ *  - The exemption is an optimisation, never a requirement: [syncOnce] is the38+ *    supported degraded mode for a user who declines, and everything keeps39+ *    working with it off.2340  */2441 object BatteryExemption {2542 @@ -38,8 +55,14 @@3855     /**3956      * Returns the intent to launch, or null when there is nothing to ask for.4057      * The caller launches it so the result lands on a real Activity.58+     *59+     * [userConfirmed] is not a formality: it is the code-level guarantee behind60+     * the distribution decision above. Pass true only from a handler for an61+     * affirmative tap the user just made — never from a lifecycle callback, a62+     * coordinator, or "we noticed sync is unreliable" logic.4163      */42-    fun requestIntent(context: Context): Intent? {64+    fun requestIntent(context: Context, userConfirmed: Boolean): Intent? {65+        if (!userConfirmed) return null4366         if (!shouldAsk(context)) return null4467         context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)4568             .edit().putBoolean(ASKED_KEY, true).apply()@@ -48,4 +71,15 @@4871             Uri.parse("package:${context.packageName}"),4972         )5073     }74+75+    /**76+     * Degraded sync for a phone without the exemption: bring the sync service up77+     * now, while the app is in the foreground and Doze is not deferring anything.78+     * The user gets a vault that syncs whenever they open Peach — later than an79+     * exempt device, but never stale by the time they look at it.80+     *81+     * Same entry point the pairing and settings screens already use; the only82+     * thing the exemption buys is that path firing while the screen is off.83+     */84+    fun syncOnce(context: Context) = PeachSyncService.start(context)5185 }

view file

app/src/main/java/com/peachpasswords/ui/PeachAppNavHost.kt+24 −2
@@ -23,6 +23,8 @@2323 import androidx.navigation.compose.currentBackStackEntryAsState2424 import androidx.navigation.compose.rememberNavController2525 import androidx.navigation.navArgument26+import com.peachpasswords.PendingAction27+import com.peachpasswords.PendingActionQueue2628 import com.peachpasswords.autofill.AutofillNavRepository2729 import com.peachpasswords.autofill.AutofillNavViewModel2830 import com.peachpasswords.ui.codex.CodexGenerateScreen@@ -98,8 +100,28 @@98100             ),99101         ) { launchSingleTop = true }100102     }101-    val whatsNewNotes by updateVm.whatsNew.collectAsStateWithLifecycle()102-    // Held until the vault is open: the changelog is harmless, but shoving it in103+    // Launcher shortcut / passkey-unlock action from the launch intent, held the104+    // same way: every one of these destinations needs an open vault, and the105+    // shortcut is usually tapped while the app is locked or not running.106+    val pendingAction by PendingActionQueue.pending.collectAsStateWithLifecycle()107+    LaunchedEffect(pendingAction, unlocked) {108+        val action = pendingAction ?: return@LaunchedEffect109+        if (!unlocked) return@LaunchedEffect110+        PendingActionQueue.consume()111+        val route = when (action) {112+            PendingAction.Search -> PeachNav.VaultList.route113+            PendingAction.AddEntry -> PeachNav.EntryEdit.of(id = null)114+            PendingAction.Pair -> PeachNav.CodePair.route115+            // Unlocking *is* the whole action: the credential provider re-queries116+            // once the vault is open. Resuming the in-flight request itself needs117+            // the provider request stashed across the unlock — out of scope here,118+            // but the action is now recognised rather than silently dropped.119+            PendingAction.PasskeyUnlock -> null120+        } ?: return@LaunchedEffect121+        navController.navigate(route) { launchSingleTop = true }122+    }123+124+    val whatsNewNotes by updateVm.whatsNew.collectAsStateWithLifecycle()    // Held until the vault is open: the changelog is harmless, but shoving it in103125     // front of the lock screen reads as the app having lost its place.104126     LaunchedEffect(whatsNewNotes, unlocked) {105127         if (whatsNewNotes != null && unlocked &&

view file

app/src/main/java/com/peachpasswords/ui/pairing/CodePairScreen.kt+6 −3
@@ -76,9 +76,12 @@7676             showBackgroundSyncPrompt = false7777             vm.setBackgroundSync(enabled)7878             if (enabled) {79-                vm.batteryExemptionIntent()?.let { intent ->80-                    runCatching { context.startActivity(intent) }81-                }79+                // The dialog tap is the confirmation; when there's no exemption to80+                // ask for (already granted, or already declined once) fall through81+                // to the foreground-only sync path rather than doing nothing.82+                val intent = vm.batteryExemptionIntent(userConfirmed = true)83+                if (intent != null) runCatching { context.startActivity(intent) }84+                else vm.syncOnce()8285             }8386         }8487     }

view file

app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+9 −1
@@ -82,8 +82,16 @@8282      * The battery-exemption dialog to show, or null if it's already granted or8383      * already been declined once. Consumed by the screen, which owns an8484      * Activity to launch it from.85+     *86+     * [userConfirmed] must come from the tap itself — see [BatteryExemption]'s87+     * distribution note. When it yields nothing, sync still works; it just runs88+     * from the foreground ([BatteryExemption.syncOnce]).8589      */86-    fun batteryExemptionIntent(): android.content.Intent? = BatteryExemption.requestIntent(appContext)90+    fun batteryExemptionIntent(userConfirmed: Boolean): android.content.Intent? =91+        BatteryExemption.requestIntent(appContext, userConfirmed)92+93+    /** Degraded sync for a device with no Doze exemption — see [BatteryExemption.syncOnce]. */94+    fun syncOnce() = BatteryExemption.syncOnce(appContext)8795 8896     fun start() {8997         val master = vault.syncMasterKey() ?: run {

view file

app/src/main/java/com/peachpasswords/updates/UpdateManifest.kt+18 −2
@@ -32,10 +32,18 @@3232     }.toString()3333 3434     companion object {35+        /**36+         * The cached manifest is re-read straight into the download path, so it37+         * has to clear the same strict-SemVer bar [parseReleaseManifest] applies38+         * to a fresh fetch — otherwise a rewritten cache entry reaches39+         * `downloadAndVerify`, whose filename safety argument rests on that40+         * validation having happened. A malformed cache entry parses to null,41+         * which callers already treat as "no update known".42+         */3543         fun fromCachedJson(json: String): ReleaseManifest? = runCatching {3644             val o = JSONObject(json)3745             ReleaseManifest(38-                version = o.getString("version"),46+                version = o.getString("version").also(::validateSemver),3947                 url = o.getString("url"),4048                 checksum = o.getString("checksum"),4149                 notes = o.optTextOrNull("notes"),@@ -169,7 +177,15 @@169177 fun normalizeVersion(version: String): String =170178     version.trim().removePrefix("v").substringBefore('-').substringBefore('+')171179 172-/** Numeric-segment comparison — "2.3.67" < "2.3.100", which a string compare gets wrong. */180+/**181+ * Numeric-segment comparison — "2.3.67" < "2.3.100", which a string compare gets wrong.182+ *183+ * Tolerant on purpose, and only safely so: both untrusted entry points184+ * ([parseReleaseManifest] and [ReleaseManifest.fromCachedJson]) run185+ * [validateSemver] first, so the `?: 0` fallback is only ever reachable for the186+ * locally built `BuildConfig.VERSION_NAME`. Throwing here instead would turn a187+ * malformed local version string into a crash on every update check.188+ */173189 fun compareVersions(a: String, b: String): Int {174190     val left = normalizeVersion(a).split('.')175191     val right = normalizeVersion(b).split('.')

view file

app/src/test/java/com/peachpasswords/PeachIntentRouterTest.kt+37 −0
@@ -0,0 +1,37 @@1+package com.peachpasswords2+3+import org.junit.Assert.assertEquals4+import org.junit.Assert.assertNull5+import org.junit.Test6+7+class PeachIntentRouterTest {8+9+    @Test fun maps_every_declared_shortcut_action() {10+        assertEquals(PendingAction.Search, routeAction(PeachActions.SEARCH))11+        assertEquals(PendingAction.AddEntry, routeAction(PeachActions.ADD))12+        assertEquals(PendingAction.Pair, routeAction(PeachActions.PAIR))13+        assertEquals(PendingAction.PasskeyUnlock, routeAction(PeachActions.PASSKEY_UNLOCK))14+    }15+16+    /** The action strings are a contract with shortcuts.xml — a rename there must fail here. */17+    @Test fun action_strings_match_the_shortcuts_manifest() {18+        assertEquals("com.peachpasswords.action.SEARCH", PeachActions.SEARCH)19+        assertEquals("com.peachpasswords.action.ADD", PeachActions.ADD)20+        assertEquals("com.peachpasswords.action.PAIR", PeachActions.PAIR)21+        assertEquals("com.peachpasswords.passkeys.ACTION_PASSKEY_UNLOCK", PeachActions.PASSKEY_UNLOCK)22+    }23+24+    @Test fun plain_launch_and_unknown_actions_route_nowhere() {25+        assertNull(routeAction(null))26+        assertNull(routeAction("android.intent.action.MAIN"))27+        assertNull(routeAction(""))28+    }29+30+    @Test fun queue_holds_one_request_until_consumed() {31+        PendingActionQueue.request(PendingAction.Search)32+        PendingActionQueue.request(PendingAction.Pair)33+        assertEquals(PendingAction.Pair, PendingActionQueue.pending.value)34+        PendingActionQueue.consume()35+        assertNull(PendingActionQueue.pending.value)36+    }37+}

view file