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
↓ peach-android-fbe98110.tar.gzatlas at this save
files changed12 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 −0app/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 theapp/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 stashapp/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+}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)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 }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 }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 }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 &&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 }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 {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('.')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+}