dfcad5a107c1

feat: Add Codex restore shortfall parity (v2.3.36)

- Show browser-style PDF shortfall recovery with found/missing/stripe-ready counts - Map Codex restore errors to browser copy for wrong password, integrity, invalid vault, and no-code PDFs - Add restore state helper tests and update browser parity notes - Verified with focused CodexRestoreStateTest and full testDebugUnitTest/assembleDebug/lintDebug gate

time
2026-06-26 11:19 UTC · 4 months ago
machine
koh2
parent
43dfb18543c3
4 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/ui/codex/CodexRestoreScreen.kt+48 −0app/src/main/java/com/peachpasswords/ui/codex/CodexViewModel.kt+71 −6app/src/test/java/com/peachpasswords/ui/codex/CodexRestoreStateTest.kt+51 −0
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 23350020-        versionName "2.3.35"19+        versionCode 23360020+        versionName "2.3.36"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/ui/codex/CodexRestoreScreen.kt+48 −0
@@ -80,6 +80,13 @@8080                     color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 12.sp,8181                 )8282 83+                is RestoreState.Shortfall -> ShortfallBody(84+                    s = s,85+                    accent = accent,86+                    onRetryPdf = { vm.resetRestore(); pickLauncher.launch("application/pdf") },87+                    onUseCamera = { vm.startCameraScan() },88+                )89+8390                 is RestoreState.Decoded -> DecodedBody(s, accent, vm, onRestored)8491 8592                 is RestoreState.Restoring ->@@ -97,6 +104,47 @@97104                 }98105             }99106         }107+    }108+}109+110+@Composable111+private fun ShortfallBody(112+    s: RestoreState.Shortfall,113+    accent: Color,114+    onRetryPdf: () -> Unit,115+    onUseCamera: () -> Unit,116+) {117+    Column {118+        Text(119+            "PDF import needs another pass.",120+            color = peachColors.T1,121+            fontFamily = PeachType.Display,122+            fontStyle = FontStyle.Italic,123+            fontSize = 19.sp,124+        )125+        Spacer(Modifier.height(6.dp))126+        Text(127+            "The PDF decoded ${s.found} codes, but ${s.missing} remained unreadable. " +128+                (if (s.ready == s.total) {129+                    "All stripes are ready now."130+                } else {131+                    "${s.ready} of ${s.total} stripes are ready."132+                }),133+            color = peachColors.T2,134+            fontFamily = PeachType.Body,135+            fontSize = 13.sp,136+        )137+        Spacer(Modifier.height(8.dp))138+        Text(139+            "Try importing the PDF again to fill the gaps, or switch to camera scanning if it keeps failing.",140+            color = peachColors.T2,141+            fontFamily = PeachType.Body,142+            fontSize = 13.sp,143+        )144+        Spacer(Modifier.height(20.dp))145+        CodexButton("Try PDF again", accent, filled = true, onClick = onRetryPdf)146+        Spacer(Modifier.height(10.dp))147+        CodexButton("Use camera", peachColors.T2, filled = false, onClick = onUseCamera)100148     }101149 }102150 

view file

app/src/main/java/com/peachpasswords/ui/codex/CodexViewModel.kt+71 −6
@@ -7,6 +7,8 @@77 import androidx.camera.core.ImageProxy88 import com.peachpasswords.codex.ChunkEnvelope99 import com.peachpasswords.codex.CodexAztecFrame10+import com.peachpasswords.codex.CodexCodec11+import com.peachpasswords.codex.CodexErrorCode1012 import com.peachpasswords.codex.CodexException1113 import com.peachpasswords.codex.CodexGenerator1214 import com.peachpasswords.codex.CodexManifest@@ -36,6 +38,7 @@3638 sealed interface RestoreState {3739     data object Idle : RestoreState3840     data class Scanning(val page: Int, val pages: Int) : RestoreState41+    data class Shortfall(val found: Int, val missing: Int, val ready: Int, val total: Int) : RestoreState3942     data class Decoded(val payload: CodexPayload, val codesFound: Int) : RestoreState4043     data object Restoring : RestoreState4144     data object Restored : RestoreState@@ -127,15 +130,31 @@127130                     }128131                     if (result.envelopes.isEmpty()) {129132                         throw CodexException(130-                            com.peachpasswords.codex.CodexErrorCode.INSUFFICIENT_CHUNKS,133+                            CodexErrorCode.INSUFFICIENT_CHUNKS,131134                             "No Aztec codes could be read from this file",132135                         )133136                     }134-                    CodexRestore.reconstructPayload(result.envelopes) to result.envelopes.size137+                    try {138+                        CodexRestore.reconstructPayload(result.envelopes) to result.envelopes.size139+                    } catch (e: CodexException) {140+                        val shortfall = codexRestoreShortfall(result.envelopes)141+                        if (e.code == CodexErrorCode.INSUFFICIENT_CHUNKS && shortfall != null) {142+                            _restore.value = RestoreState.Shortfall(143+                                found = shortfall.found,144+                                missing = shortfall.missing,145+                                ready = shortfall.ready,146+                                total = shortfall.total,147+                            )148+                            return@withContext null149+                        }150+                        throw e151+                    }135152                 }136-                _restore.value = RestoreState.Decoded(payload.first, payload.second)153+                if (payload != null) {154+                    _restore.value = RestoreState.Decoded(payload.first, payload.second)155+                }137156             } catch (e: CodexException) {138-                _restore.value = RestoreState.Error(e.message ?: e.code.name)157+                _restore.value = RestoreState.Error(codexRestoreErrorMessage(e.code, e.message))139158             } catch (e: Exception) {140159                 _restore.value = RestoreState.Error(e.message ?: "restore_failed")141160             }@@ -148,8 +167,12 @@148167         viewModelScope.launch {149168             when (val r = vault.restoreFromCodex(payload.vaultBlob, password)) {150169                 is UnlockResult.Success -> { _restore.value = RestoreState.Restored; onDone() }151-                is UnlockResult.WrongPassword -> _restore.value = RestoreState.Error("Wrong master password")152-                is UnlockResult.Error -> _restore.value = RestoreState.Error(r.reason)170+                is UnlockResult.WrongPassword -> _restore.value = RestoreState.Error(171+                    codexRestoreErrorMessage(CodexErrorCode.RESTORE_FAILED, "WRONG_PASSPHRASE"),172+                )173+                is UnlockResult.Error -> _restore.value = RestoreState.Error(174+                    codexRestoreErrorMessage(CodexErrorCode.RESTORE_FAILED, r.reason),175+                )153176             }154177         }155178     }@@ -174,6 +197,7 @@174197     fun startCameraScan() {175198         cameraEnvelopes.clear()176199         _camera.value = CameraScanState.Scanning(0)200+        _restore.value = RestoreState.Idle177201     }178202 179203     /** UI reports 10s elapsed with no successful reconstruction. */@@ -201,5 +225,46 @@201225         } catch (_: Exception) {202226             false203227         }228+    }229+}230+231+internal data class CodexShortfall(val found: Int, val missing: Int, val ready: Int, val total: Int)232+233+internal fun codexRestoreShortfall(envelopes: List<ChunkEnvelope>): CodexShortfall? {234+    val good = envelopes235+        .filter { CodexCodec.verifyChunkIntegrity(it) }236+        .distinctBy { "${it.s}:${it.i}" }237+    if (good.isEmpty()) return null238+    val stripeCount = good.maxOf { it.capS.coerceAtLeast(1) }239+    val byStripe = good.groupBy { it.s }240+    var ready = 0241+    var missing = 0242+    for (stripe in 0 until stripeCount) {243+        val stripeEnvelopes = byStripe[stripe].orEmpty()244+        if (stripeEnvelopes.isEmpty()) continue245+        val total = stripeEnvelopes.first().t246+        val (dataShards, _) = com.peachpasswords.codex.deriveShardCounts(total)247+        val found = stripeEnvelopes.count { it.i in 0 until total }248+        if (found >= dataShards) ready++ else missing += dataShards - found249+    }250+    return CodexShortfall(found = good.size, missing = missing, ready = ready, total = stripeCount)251+}252+253+internal fun codexRestoreErrorMessage(code: CodexErrorCode, raw: String?): String {254+    val message = raw.orEmpty()255+    return when {256+        message.contains("WRONG_PASSPHRASE", ignoreCase = true) ||257+            message.contains("wrong", ignoreCase = true) ->258+            "wrong master password. try again."259+        message.contains("INVALID_VAULT_BLOB", ignoreCase = true) ||260+            code == CodexErrorCode.INVALID_VAULT_BLOB ->261+            "the reconstructed vault is unreadable. please re-scan the Codex."262+        message.contains("FINGERPRINT_MISMATCH", ignoreCase = true) ||263+            code == CodexErrorCode.RECONSTRUCTION_FINGERPRINT_MISMATCH ->264+            "vault integrity check failed. please re-scan the Codex."265+        code == CodexErrorCode.INSUFFICIENT_CHUNKS && message.contains("No Aztec", ignoreCase = true) ->266+            "no Codex codes found in this PDF. make sure you're selecting a Peach Codex PDF, not a different document."267+        message.isNotBlank() -> message268+        else -> "restoration failed. please try again."204269     }205270 }

view file

app/src/test/java/com/peachpasswords/ui/codex/CodexRestoreStateTest.kt+51 −0
@@ -0,0 +1,51 @@1+package com.peachpasswords.ui.codex2+3+import com.peachpasswords.codex.CodexCodec4+import com.peachpasswords.codex.CodexErrorCode5+import org.junit.Assert.assertEquals6+import org.junit.Test7+import kotlin.random.Random8+9+class CodexRestoreStateTest {10+11+    @Test12+    fun codexRestoreShortfall_reportsMissingCodeCounts() {13+        val rng = Random(36)14+        val compressed = CodexCodec.deflate(ByteArray(5000) { rng.nextInt().toByte() })15+        val (envelopes, layout) = CodexCodec.encodeToEnvelopes(compressed, "PX-TEST-0001")16+        val parityInFirstStripe = layout.parityChunks / layout.stripeCount17+        val survivors = envelopes18+            .filterNot { it.s == 0 && it.i <= parityInFirstStripe }19+20+        val shortfall = codexRestoreShortfall(survivors)21+22+        assertEquals(layout.totalChunks - parityInFirstStripe - 1, shortfall?.found)23+        assertEquals(1, shortfall?.missing)24+        assertEquals(0, shortfall?.ready)25+        assertEquals(layout.stripeCount, shortfall?.total)26+    }27+28+    @Test29+    fun codexRestoreErrorMessage_matchesBrowserRestoreCopy() {30+        assertEquals(31+            "wrong master password. try again.",32+            codexRestoreErrorMessage(CodexErrorCode.RESTORE_FAILED, "WRONG_PASSPHRASE"),33+        )34+        assertEquals(35+            "the reconstructed vault is unreadable. please re-scan the Codex.",36+            codexRestoreErrorMessage(CodexErrorCode.INVALID_VAULT_BLOB, null),37+        )38+        assertEquals(39+            "vault integrity check failed. please re-scan the Codex.",40+            codexRestoreErrorMessage(CodexErrorCode.RECONSTRUCTION_FINGERPRINT_MISMATCH, null),41+        )42+        assertEquals(43+            "no Codex codes found in this PDF. make sure you're selecting a Peach Codex PDF, not a different document.",44+            codexRestoreErrorMessage(CodexErrorCode.INSUFFICIENT_CHUNKS, "No Aztec codes could be read from this file"),45+        )46+        assertEquals(47+            "restoration failed. please try again.",48+            codexRestoreErrorMessage(CodexErrorCode.RESTORE_FAILED, null),49+        )50+    }51+}

view file