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
7 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 −11
VERSION+1 −1
@@ -1 +1 @@1-2.4.141+2.4.15

view file

app/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 {

view file

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                         }

view file

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 }

view file

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             )

view file

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"

view file

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         }

view file