1package com.peachpasswords.ui.codex23import android.content.Context4import android.net.Uri5import androidx.core.content.FileProvider6import android.util.Log7import com.peachpasswords.BuildConfig8import androidx.lifecycle.ViewModel9import androidx.lifecycle.viewModelScope10import androidx.camera.core.ImageProxy11import com.peachpasswords.codex.ChunkEnvelope12import com.peachpasswords.codex.CodexAztecFrame13import com.peachpasswords.codex.CodexCodec14import com.peachpasswords.codex.CodexErrorCode15import com.peachpasswords.codex.CodexException16import com.peachpasswords.codex.CodexGenerator17import com.peachpasswords.codex.CodexManifest18import com.peachpasswords.codex.CodexPayload19import com.peachpasswords.codex.CodexRestore20import com.peachpasswords.codex.CodexStep21import com.peachpasswords.license.LicenseRepository22import com.peachpasswords.vault.UnlockResult23import com.peachpasswords.vault.VaultRepository24import dagger.hilt.android.lifecycle.HiltViewModel25import dagger.hilt.android.qualifiers.ApplicationContext26import kotlinx.coroutines.Dispatchers27import kotlinx.coroutines.flow.MutableStateFlow28import kotlinx.coroutines.flow.StateFlow29import kotlinx.coroutines.flow.asStateFlow30import kotlinx.coroutines.launch31import kotlinx.coroutines.withContext32import java.io.File33import javax.inject.Inject3435sealed interface GenerateState {36 data object Idle : GenerateState37 data class Running(val step: CodexStep, val current: Int, val total: Int) : GenerateState38 data class Done(val manifest: CodexManifest, val pdf: ByteArray) : GenerateState39 data class Error(val message: String) : GenerateState40}4142sealed interface RestoreState {43 data object Idle : RestoreState44 data class Scanning(val page: Int, val pages: Int) : RestoreState45 data class Shortfall(val found: Int, val missing: Int, val ready: Int, val total: Int) : RestoreState46 data class Decoded(val payload: CodexPayload, val codesFound: Int) : RestoreState47 data object Restoring : RestoreState48 data object Restored : RestoreState49 data class Error(val message: String) : RestoreState50}51525354555657sealed interface CameraScanState {58 data object Choosing : CameraScanState59 data class Scanning(val codesFound: Int) : CameraScanState60 data class Timeout(val codesFound: Int) : CameraScanState61}6263@HiltViewModel64class CodexViewModel @Inject constructor(65 @ApplicationContext private val context: Context,66 private val vault: VaultRepository,67 private val license: LicenseRepository,68) : ViewModel() {6970 private val _generate = MutableStateFlow<GenerateState>(GenerateState.Idle)71 val generate: StateFlow<GenerateState> = _generate.asStateFlow()7273 74 val recoveryWordsSet: Boolean get() = vault.isRecoveryWordsSet()7576 77 val isPro: Boolean get() = license.isProActive()7879 80 fun expectTransientBackground() = vault.expectTransientBackground()8182 private val recoveryWords: List<String> by lazy {83 vault.sessionRootKey()?.let { k ->84 try { com.peachpasswords.vault.rootKeyToWords(k) }85 finally { com.peachpasswords.vault.VaultCrypto.zero(k) }86 } ?: emptyList()87 }8889 90 fun gateMatches(w3: String, w7: String, w11: String): Boolean {91 val ws = recoveryWords92 if (ws.size < 11) return false93 return w3.trim().lowercase() == ws[2] &&94 w7.trim().lowercase() == ws[6] &&95 w11.trim().lowercase() == ws[10]96 }9798 private val _restore = MutableStateFlow<RestoreState>(RestoreState.Idle)99 val restore: StateFlow<RestoreState> = _restore.asStateFlow()100101 fun startGenerate(now: Long) {102 if (!isPro) {103 _generate.value = GenerateState.Error("Codex generation is a Pro feature.")104 return105 }106 _generate.value = GenerateState.Running(CodexStep.COMPRESS, 0, 1)107 viewModelScope.launch {108 try {109 val (pdf, manifest) = withContext(Dispatchers.Default) {110 CodexGenerator.generate(vault, now) { step, cur, total ->111 _generate.value = GenerateState.Running(step, cur, total)112 }113 }114 _generate.value = GenerateState.Done(manifest, pdf)115 } catch (e: CodexException) {116 _generate.value = GenerateState.Error(e.message ?: e.code.name)117 } catch (e: Exception) {118 _generate.value = GenerateState.Error(e.message ?: "generation_failed")119 }120 }121 }122123 fun writePdf(uri: Uri): Boolean = try {124 val pdf = (_generate.value as? GenerateState.Done)?.pdf ?: return false125 context.contentResolver.openOutputStream(uri)?.use { it.write(pdf) } != null126 } catch (_: Exception) {127 false128 }129130 fun sharePdfUri(fileName: String = codexPdfFileName()): Uri? = try {131 val pdf = (_generate.value as? GenerateState.Done)?.pdf ?: return null132 val dir = File(context.cacheDir, "codex").apply { mkdirs() }133 val file = File(dir, fileName)134 file.outputStream().use { it.write(pdf) }135 FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)136 } catch (_: Exception) {137 null138 }139140 141142143144145 fun discardSharedPdf(fileName: String = codexPdfFileName()) {146 runCatching { File(File(context.cacheDir, "codex"), fileName).delete() }147 }148149 fun scan(uri: Uri) {150 _restore.value = RestoreState.Scanning(0, 0)151 viewModelScope.launch {152 try {153 val payload = withContext(Dispatchers.Default) {154 val result = CodexRestore.decodeFromUri(context, uri) { page, pages ->155 _restore.value = RestoreState.Scanning(page, pages)156 }157 if (result.envelopes.isEmpty()) {158 throw CodexException(159 CodexErrorCode.INSUFFICIENT_CHUNKS,160 "No Aztec codes could be read from this file",161 )162 }163 try {164 CodexRestore.reconstructPayload(result.envelopes) to result.envelopes.size165 } catch (e: CodexException) {166 val shortfall = codexRestoreShortfall(result.envelopes)167 if (e.code == CodexErrorCode.INSUFFICIENT_CHUNKS && shortfall != null) {168 _restore.value = RestoreState.Shortfall(169 found = shortfall.found,170 missing = shortfall.missing,171 ready = shortfall.ready,172 total = shortfall.total,173 )174 return@withContext null175 }176 throw e177 }178 }179 if (payload != null) {180 _restore.value = RestoreState.Decoded(payload.first, payload.second)181 }182 } catch (e: CodexException) {183 _restore.value = RestoreState.Error(codexRestoreErrorMessage(e.code, e.message))184 } catch (e: Exception) {185 _restore.value = RestoreState.Error(e.message ?: "restore_failed")186 }187 }188 }189190 fun confirmRestore(password: String, onDone: () -> Unit) {191 val payload = (_restore.value as? RestoreState.Decoded)?.payload ?: return192 _restore.value = RestoreState.Restoring193 viewModelScope.launch {194 when (val r = vault.restoreFromCodex(payload.vaultBlob, password)) {195 is UnlockResult.Success -> { _restore.value = RestoreState.Restored; onDone() }196 is UnlockResult.WrongPassword -> {197 198 199 200 201 202 if (BuildConfig.DEBUG) Log.w("PeachCodex", "restore rejected: WRONG_PASSPHRASE")203 _restore.value = RestoreState.Error(204 codexRestoreErrorMessage(CodexErrorCode.RESTORE_FAILED, "WRONG_PASSPHRASE"),205 )206 }207 is UnlockResult.Error -> {208 if (BuildConfig.DEBUG) Log.w("PeachCodex", "restore failed: ${r.reason}")209 _restore.value = RestoreState.Error(210 codexRestoreErrorMessage(CodexErrorCode.RESTORE_FAILED, r.reason),211 )212 }213 }214 }215 }216217 fun resetRestore() { _restore.value = RestoreState.Idle }218219 220 private val _camera = MutableStateFlow<CameraScanState>(CameraScanState.Choosing)221 val camera: StateFlow<CameraScanState> = _camera.asStateFlow()222223 224 private val cameraEnvelopes = LinkedHashMap<String, ChunkEnvelope>()225226 227 fun chooseRestore() {228 cameraEnvelopes.clear()229 _camera.value = CameraScanState.Choosing230 _restore.value = RestoreState.Idle231 }232233 234 fun startCameraScan() {235 cameraEnvelopes.clear()236 _camera.value = CameraScanState.Scanning(0)237 _restore.value = RestoreState.Idle238 }239240 241 fun cameraTimedOut() {242 val s = _camera.value243 if (s is CameraScanState.Scanning) _camera.value = CameraScanState.Timeout(s.codesFound)244 }245246 247248249250251252 fun onCameraFrame(image: ImageProxy): Boolean {253 if (_camera.value !is CameraScanState.Scanning) return _restore.value is RestoreState.Decoded254 val text = CodexAztecFrame.decode(image) ?: return false255 val env = CodexRestore.parseEnvelope(text) ?: return false256 cameraEnvelopes.putIfAbsent("${env.s}:${env.i}", env)257 _camera.value = CameraScanState.Scanning(cameraEnvelopes.size)258 return try {259 val payload = CodexRestore.reconstructPayload(cameraEnvelopes.values.toList())260 _restore.value = RestoreState.Decoded(payload, cameraEnvelopes.size)261 true262 } catch (_: Exception) {263 false264 }265 }266}267268internal data class CodexShortfall(val found: Int, val missing: Int, val ready: Int, val total: Int)269270internal fun codexRestoreShortfall(envelopes: List<ChunkEnvelope>): CodexShortfall? {271 val good = envelopes272 .filter { CodexCodec.verifyChunkIntegrity(it) }273 .distinctBy { "${it.s}:${it.i}" }274 if (good.isEmpty()) return null275 val stripeCount = good.maxOf { it.capS.coerceAtLeast(1) }276 val byStripe = good.groupBy { it.s }277 var ready = 0278 var missing = 0279 for (stripe in 0 until stripeCount) {280 val stripeEnvelopes = byStripe[stripe].orEmpty()281 if (stripeEnvelopes.isEmpty()) continue282 val total = stripeEnvelopes.first().t283 val (dataShards, _) = com.peachpasswords.codex.deriveShardCounts(total)284 val found = stripeEnvelopes.count { it.i in 0 until total }285 if (found >= dataShards) ready++ else missing += dataShards - found286 }287 return CodexShortfall(found = good.size, missing = missing, ready = ready, total = stripeCount)288}289290internal fun codexRestoreErrorMessage(code: CodexErrorCode, raw: String?): String {291 val message = raw.orEmpty()292 return when {293 message.contains("WRONG_PASSPHRASE", ignoreCase = true) ||294 message.contains("wrong", ignoreCase = true) ->295 "wrong master password. try again."296 message.contains("INVALID_VAULT_BLOB", ignoreCase = true) ||297 code == CodexErrorCode.INVALID_VAULT_BLOB ->298 "the reconstructed vault is unreadable. please re-scan the Codex."299 message.contains("FINGERPRINT_MISMATCH", ignoreCase = true) ||300 code == CodexErrorCode.RECONSTRUCTION_FINGERPRINT_MISMATCH ->301 "vault integrity check failed. please re-scan the Codex."302 code == CodexErrorCode.INSUFFICIENT_CHUNKS && message.contains("No Aztec", ignoreCase = true) ->303 "no Codex codes found in this PDF. make sure you're selecting a Peach Codex PDF, not a different document."304 message.isNotBlank() -> message305 else -> "restoration failed. please try again."306 }307}