a28c55655805
stop the QR scanner killing the app by building decode strategies lazily and rotating the luma plane ourselves, retract the pull-to-refresh spinner unconditionally, and cut the sync card down to one pairing block (v2.4.15)
- time
- 2026-07-26 15:06 UTC · 3 months ago
- machine
- koh2
- agent
- claude-opus-5
- session
- sync UX and scanner crash
- parent
- 10d155fee98b
↓ peach-android-a28c5565.tar.gzatlas at this save
files changed7 files
VERSION+1 −1app/build.gradle+2 −2app/src/main/java/com/peachpasswords/ui/pairing/PairingQrCamera.kt+8 −1app/src/main/java/com/peachpasswords/ui/pairing/PairingQrFrame.kt+38 −16app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+10 −15app/src/main/java/com/peachpasswords/ui/settings/SettingsViewModel.kt+1 −1app/src/main/java/com/peachpasswords/ui/vault/VaultListScreen.kt+9 −11app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616 applicationId "com.peachpasswords"1717 minSdk 261818 targetSdk 3519- versionCode 24001420- versionName "2.4.14"19+ versionCode 24001520+ versionName "2.4.15"2121 2222 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323 vectorDrawables {app/src/main/java/com/peachpasswords/ui/pairing/PairingQrCamera.kt+8 −1
@@ -99,7 +99,14 @@9999 .build()100100 analysis.setAnalyzer(executor) { proxy ->101101 try {102- PairingQrFrame.decode(proxy)?.let(onInvitation)102+ // This runs on a bare executor thread, so anything103+ // thrown here is an uncaught exception that kills104+ // the process. A frame that will not decode is105+ // normal; dropping it and waiting for the next one106+ // is always the right answer.107+ runCatching { PairingQrFrame.decode(proxy) }108+ .getOrNull()109+ ?.let(onInvitation)103110 } finally {104111 proxy.close()105112 }app/src/main/java/com/peachpasswords/ui/pairing/PairingQrFrame.kt+38 −16
@@ -3,6 +3,7 @@33 import androidx.camera.core.ImageProxy44 import com.google.zxing.BinaryBitmap55 import com.google.zxing.DecodeHintType6+import com.google.zxing.LuminanceSource67 import com.google.zxing.PlanarYUVLuminanceSource78 import com.google.zxing.common.GlobalHistogramBinarizer89 import com.google.zxing.common.HybridBinarizer@@ -32,32 +33,53 @@3233 }3334 }3435 }35- val source = PlanarYUVLuminanceSource(36- luma,37- width,38- height,39- 0,40- 0,41- width,42- height,43- false,44- )36+ val source = planar(luma, width, height)37+4538 val hints = mapOf(4639 DecodeHintType.TRY_HARDER to true,4740 DecodeHintType.POSSIBLE_FORMATS to listOf(com.google.zxing.BarcodeFormat.QR_CODE),4841 )49- val attempts = listOf(50- BinaryBitmap(HybridBinarizer(source)),51- BinaryBitmap(GlobalHistogramBinarizer(source)),52- BinaryBitmap(HybridBinarizer(source.rotateCounterClockwise())),42+ // Lazily, one strategy at a time. Building these eagerly meant a strategy43+ // that threw during CONSTRUCTION escaped the per-attempt catch below and44+ // killed the analyzer thread — which is how the rotated attempt used to45+ // take the whole app down on the first camera frame.46+ val attempts = sequenceOf<() -> BinaryBitmap>(47+ { BinaryBitmap(HybridBinarizer(source)) },48+ { BinaryBitmap(GlobalHistogramBinarizer(source)) },49+ { BinaryBitmap(HybridBinarizer(rotatedCounterClockwise(luma, width, height))) },5350 )54- for (candidate in attempts) {51+ for (attempt in attempts) {5552 try {56- return QRCodeReader().decode(candidate, hints).text53+ return QRCodeReader().decode(attempt(), hints).text5754 } catch (_: Exception) {5855 // Try the next lighting/orientation strategy.5956 }6057 }6158 return null59+ }60+61+ private fun planar(luma: ByteArray, width: Int, height: Int): LuminanceSource =62+ PlanarYUVLuminanceSource(luma, width, height, 0, 0, width, height, false)63+64+ /**65+ * `PlanarYUVLuminanceSource` does not implement rotation — the base66+ * `LuminanceSource.rotateCounterClockwise()` throws67+ * `UnsupportedOperationException` — so rotate the luma plane directly.68+ * Counter-clockwise sends source (x, y) to (y, width - 1 - x) in an image69+ * that is height wide and width tall.70+ */71+ private fun rotatedCounterClockwise(72+ luma: ByteArray,73+ width: Int,74+ height: Int,75+ ): LuminanceSource {76+ val rotated = ByteArray(width * height)77+ for (y in 0 until height) {78+ val row = y * width79+ for (x in 0 until width) {80+ rotated[(width - 1 - x) * height + y] = luma[row + x]81+ }82+ }83+ return planar(rotated, height, width)6284 }6385 }app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+10 −15
@@ -671,46 +671,41 @@671671 var relayUrlDraft by remember(configuredRelayUrl) { mutableStateOf(configuredRelayUrl) }672672 var relayUrlStatus by remember { mutableStateOf<String?>(null) }673673 674- SubLabel("PAIRING", fg, top = 0.dp)674+ // One pairing block, two buttons. The old "Sync hub" row went to the same675+ // place as "show", and the description described a LAN-code-only design676+ // that no longer exists.677+ SubLabel("PAIR A DEVICE", fg, top = 0.dp)675678 RowLine(fg) {676679 Column(Modifier.weight(1f)) {677680 Row(verticalAlignment = Alignment.CenterVertically) {678681 Text("Peach Sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp); ProBadge(fg)679682 }680683 Text(681- "Pair devices with a code over your local network and keep encrypted vault blobs in step without a central account.",684+ "Your devices keep one vault in step, end-to-end encrypted, through a relay " +685+ "that can read none of it — so they don't need to be on the same network. " +686+ "One device shows a code, the other scans it.",682687 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,683688 modifier = Modifier.padding(top = 2.dp),684689 )685690 }686691 }687- // The whole row is the tap target — no separate "open" button (browser688- // sync-hub parity: every settings row is tappable, the chevron signals it).689- RowLine(fg, Modifier.clickable { onNavigate(PeachNav.CodePair) }) {690- Name("Sync hub", fg)691- Text("›", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 15.sp)692- }693692 RowLine(fg) {694693 Column(Modifier.weight(1f)) {695694 Text("Show a code", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)696695 Text(697- "This phone shows a QR or code for the other device to scan or type.",696+ "For the other device to scan. Always use this one to pair a browser.",698697 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,699698 modifier = Modifier.padding(top = 2.dp),700699 )701700 }702701 SubtleButton("show", fg) { onNavigate(PeachNav.CodePair) }703702 }704- // A browser extension can only ever display a code, so something has to be705- // able to scan one — and both devices showing a QR was the deadlock. This706- // phone can take either role; phone-to-phone works for the same reason.707703 RowLine(fg) {708704 Column(Modifier.weight(1f)) {709705 Text("Scan a code", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)710706 Text(711- "Point this phone at the QR on your other device. Your entries are kept — " +712- "the two vaults merge. This phone's recovery words are replaced by that " +713- "vault's, and Peach shows them before anything changes.",707+ "Joins that vault. Your entries are kept and the two merge; this phone's " +708+ "recovery words are replaced, and shown to you first.",714709 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,715710 modifier = Modifier.padding(top = 2.dp),716711 )app/src/main/java/com/peachpasswords/ui/settings/SettingsViewModel.kt+1 −1
@@ -332,7 +332,7 @@332332 "${if (s.breachMonitoring) "breaches watched" else "breach watch off"} · manual lock"333333 CardId.Vault ->334334 "$entriesCount ${if (entriesCount == 1) "entry" else "entries"} · backups, history, cleanup"335- CardId.Sync -> if (_isPro.value) "pair by chirp, keep in step" else "Pro · encrypted device pairing"335+ CardId.Sync -> if (_isPro.value) "your devices, one vault" else "Pro · encrypted device sync"336336 CardId.Appearance -> "${s.theme} · ${s.accentColor} · ${s.textSize}"337337 CardId.Shortcuts -> "bindings · what your fingers know"338338 CardId.Feedback -> "tell us what would make it better"app/src/main/java/com/peachpasswords/ui/vault/VaultListScreen.kt+9 −11
@@ -735,11 +735,6 @@735735 val maxPx = with(density) { 96.dp.toPx() }736736 var pull by remember { mutableStateOf(0f) }737737 738- // When a refresh round finishes, ease the spinner back to rest.739- LaunchedEffect(refreshing) {740- if (!refreshing && pull > 0f) animate(pull, 0f) { v, _ -> pull = v }741- }742-743738 val connection = remember(refreshing) {744739 object : NestedScrollConnection {745740 override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {@@ -762,12 +757,15 @@762757 }763758 764759 override suspend fun onPreFling(available: Velocity): Velocity {765- if (!refreshing && pull >= triggerPx) {766- onRefresh()767- animate(pull, triggerPx) { v, _ -> pull = v }768- } else if (!refreshing) {769- animate(pull, 0f) { v, _ -> pull = v }770- }760+ if (!refreshing && pull >= triggerPx) onRefresh()761+ // Always retract. Parking the spinner at the trigger point and762+ // waiting for `refreshing` to go true-then-false left it stuck763+ // on screen forever whenever that transition never happened —764+ // which is every sync that resolves synchronously, including765+ // the no-paired-devices case that goes straight to Offline.766+ // The spinner's visibility is driven by `refreshing` on its767+ // own, so it does not need the pull offset to stay raised.768+ animate(pull, 0f) { v, _ -> pull = v }771769 return Velocity.Zero772770 }773771 }