dfcad5a107c1
feat: Add Codex restore shortfall parity (v2.3.36)
- time
- 2026-06-26 11:19 UTC · 4 months ago
- machine
- koh2
- parent
- 43dfb18543c3
↓ peach-android-dfcad5a1.tar.gzatlas at this save
files changed4 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 −0app/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 {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 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 }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+}