1package com.peachpasswords.ui.codex23import android.content.Context4import android.net.Uri5import androidx.core.content.FileProvider6import androidx.lifecycle.ViewModel7import androidx.lifecycle.viewModelScope8import androidx.camera.core.ImageProxy9import com.peachpasswords.codex.ChunkEnvelope10import com.peachpasswords.codex.CodexAztecFrame11import com.peachpasswords.codex.CodexCodec12import com.peachpasswords.codex.CodexErrorCode13import com.peachpasswords.codex.CodexException14import com.peachpasswords.codex.CodexGenerator15import com.peachpasswords.codex.CodexManifest16import com.peachpasswords.codex.CodexPayload17import com.peachpasswords.codex.CodexRestore18import com.peachpasswords.codex.CodexStep19import com.peachpasswords.license.LicenseRepository20import com.peachpasswords.vault.UnlockResult21import com.peachpasswords.vault.VaultRepository22import dagger.hilt.android.lifecycle.HiltViewModel23import dagger.hilt.android.qualifiers.ApplicationContext24import kotlinx.coroutines.Dispatchers25import kotlinx.coroutines.flow.MutableStateFlow26import kotlinx.coroutines.flow.StateFlow27import kotlinx.coroutines.flow.asStateFlow28import kotlinx.coroutines.launch29import kotlinx.coroutines.withContext30import java.io.File31import javax.inject.Inject3233sealed interface GenerateState {34 data object Idle : GenerateState35 data class Running(val step: CodexStep, val current: Int, val total: Int) : GenerateState36 data class Done(val manifest: CodexManifest, val pdf: ByteArray) : GenerateState37 data class Error(val message: String) : GenerateState38}3940sealed interface RestoreState {41 data object Idle : RestoreState42 data class Scanning(val page: Int, val pages: Int) : RestoreState43 data class Shortfall(val found: Int, val missing: Int, val ready: Int, val total: Int) : RestoreState44 data class Decoded(val payload: CodexPayload, val codesFound: Int) : RestoreState45 data object Restoring : RestoreState46 data object Restored : RestoreState47 data class Error(val message: String) : RestoreState48}49505152535455sealed interface CameraScanState {56 data object Choosing : CameraScanState57 data class Scanning(val codesFound: Int) : CameraScanState58 data class Timeout(val codesFound: Int) : CameraScanState59}6061@HiltViewModel62class CodexViewModel @Inject constructor(63 @ApplicationContext private val context: Context,64 private val vault: VaultRepository,65 private val license: LicenseRepository,66) : ViewModel() {6768 private val _generate = MutableStateFlow<GenerateState>(GenerateState.Idle)69 val generate: StateFlow<GenerateState> = _generate.asStateFlow()7071 72 val recoveryWordsSet: Boolean get() = vault.isRecoveryWordsSet()7374 75 val isPro: Boolean get() = license.isProActive()7677 78 fun expectTransientBackground() = vault.expectTransientBackground()7980 private val recoveryWords: List<String> by lazy {81 vault.syncMasterKey()?.let { k ->82 try { com.peachpasswords.vault.rootKeyToWords(k) }83 finally { com.peachpasswords.vault.VaultCrypto.zero(k) }84 } ?: emptyList()85 }8687 88 fun gateMatches(w3: String, w7: String, w11: String): Boolean {89 val ws = recoveryWords90 if (ws.size < 11) return false91 return w3.trim().lowercase() == ws[2] &&92 w7.trim().lowercase() == ws[6] &&93 w11.trim().lowercase() == ws[10]94 }9596 private val _restore = MutableStateFlow<RestoreState>(RestoreState.Idle)97 val restore: StateFlow<RestoreState> = _restore.asStateFlow()9899 fun startGenerate(now: Long) {100 if (!isPro) {101 _generate.value = GenerateState.Error("Codex generation is a Pro feature.")102 return103 }104 _generate.value = GenerateState.Running(CodexStep.COMPRESS, 0, 1)105 viewModelScope.launch {106 try {107 val (pdf, manifest) = withContext(Dispatchers.Default) {108 CodexGenerator.generate(vault, now) { step, cur, total ->109 _generate.value = GenerateState.Running(step, cur, total)110 }111 }112 _generate.value = GenerateState.Done(manifest, pdf)113 } catch (e: CodexException) {114 _generate.value = GenerateState.Error(e.message ?: e.code.name)115 } catch (e: Exception) {116 _generate.value = GenerateState.Error(e.message ?: "generation_failed")117 }118 }119 }120121 fun writePdf(uri: Uri): Boolean = try {122 val pdf = (_generate.value as? GenerateState.Done)?.pdf ?: return false123 context.contentResolver.openOutputStream(uri)?.use { it.write(pdf) } != null124 } catch (_: Exception) {125 false126 }127128 fun sharePdfUri(fileName: String = codexPdfFileName()): Uri? = try {129 val pdf = (_generate.value as? GenerateState.Done)?.pdf ?: return null130 val dir = File(context.cacheDir, "codex").apply { mkdirs() }131 val file = File(dir, fileName)132 file.outputStream().use { it.write(pdf) }133 FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)134 } catch (_: Exception) {135 null136 }137138 fun scan(uri: Uri) {139 _restore.value = RestoreState.Scanning(0, 0)140 viewModelScope.launch {141 try {142 val payload = withContext(Dispatchers.Default) {143 val result = CodexRestore.decodeFromUri(context, uri) { page, pages ->144 _restore.value = RestoreState.Scanning(page, pages)145 }146 if (result.envelopes.isEmpty()) {147 throw CodexException(148 CodexErrorCode.INSUFFICIENT_CHUNKS,149 "No Aztec codes could be read from this file",150 )151 }152 try {153 CodexRestore.reconstructPayload(result.envelopes) to result.envelopes.size154 } catch (e: CodexException) {155 val shortfall = codexRestoreShortfall(result.envelopes)156 if (e.code == CodexErrorCode.INSUFFICIENT_CHUNKS && shortfall != null) {157 _restore.value = RestoreState.Shortfall(158 found = shortfall.found,159 missing = shortfall.missing,160 ready = shortfall.ready,161 total = shortfall.total,162 )163 return@withContext null164 }165 throw e166 }167 }168 if (payload != null) {169 _restore.value = RestoreState.Decoded(payload.first, payload.second)170 }171 } catch (e: CodexException) {172 _restore.value = RestoreState.Error(codexRestoreErrorMessage(e.code, e.message))173 } catch (e: Exception) {174 _restore.value = RestoreState.Error(e.message ?: "restore_failed")175 }176 }177 }178179 fun confirmRestore(password: String, onDone: () -> Unit) {180 val payload = (_restore.value as? RestoreState.Decoded)?.payload ?: return181 _restore.value = RestoreState.Restoring182 viewModelScope.launch {183 when (val r = vault.restoreFromCodex(payload.vaultBlob, password)) {184 is UnlockResult.Success -> { _restore.value = RestoreState.Restored; onDone() }185 is UnlockResult.WrongPassword -> _restore.value = RestoreState.Error(186 codexRestoreErrorMessage(CodexErrorCode.RESTORE_FAILED, "WRONG_PASSPHRASE"),187 )188 is UnlockResult.Error -> _restore.value = RestoreState.Error(189 codexRestoreErrorMessage(CodexErrorCode.RESTORE_FAILED, r.reason),190 )191 }192 }193 }194195 fun resetRestore() { _restore.value = RestoreState.Idle }196197 198 private val _camera = MutableStateFlow<CameraScanState>(CameraScanState.Choosing)199 val camera: StateFlow<CameraScanState> = _camera.asStateFlow()200201 202 private val cameraEnvelopes = LinkedHashMap<String, ChunkEnvelope>()203204 205 fun chooseRestore() {206 cameraEnvelopes.clear()207 _camera.value = CameraScanState.Choosing208 _restore.value = RestoreState.Idle209 }210211 212 fun startCameraScan() {213 cameraEnvelopes.clear()214 _camera.value = CameraScanState.Scanning(0)215 _restore.value = RestoreState.Idle216 }217218 219 fun cameraTimedOut() {220 val s = _camera.value221 if (s is CameraScanState.Scanning) _camera.value = CameraScanState.Timeout(s.codesFound)222 }223224 225226227228229230 fun onCameraFrame(image: ImageProxy): Boolean {231 if (_camera.value !is CameraScanState.Scanning) return _restore.value is RestoreState.Decoded232 val text = CodexAztecFrame.decode(image) ?: return false233 val env = CodexRestore.parseEnvelope(text) ?: return false234 cameraEnvelopes.putIfAbsent("${env.s}:${env.i}", env)235 _camera.value = CameraScanState.Scanning(cameraEnvelopes.size)236 return try {237 val payload = CodexRestore.reconstructPayload(cameraEnvelopes.values.toList())238 _restore.value = RestoreState.Decoded(payload, cameraEnvelopes.size)239 true240 } catch (_: Exception) {241 false242 }243 }244}245246internal data class CodexShortfall(val found: Int, val missing: Int, val ready: Int, val total: Int)247248internal fun codexRestoreShortfall(envelopes: List<ChunkEnvelope>): CodexShortfall? {249 val good = envelopes250 .filter { CodexCodec.verifyChunkIntegrity(it) }251 .distinctBy { "${it.s}:${it.i}" }252 if (good.isEmpty()) return null253 val stripeCount = good.maxOf { it.capS.coerceAtLeast(1) }254 val byStripe = good.groupBy { it.s }255 var ready = 0256 var missing = 0257 for (stripe in 0 until stripeCount) {258 val stripeEnvelopes = byStripe[stripe].orEmpty()259 if (stripeEnvelopes.isEmpty()) continue260 val total = stripeEnvelopes.first().t261 val (dataShards, _) = com.peachpasswords.codex.deriveShardCounts(total)262 val found = stripeEnvelopes.count { it.i in 0 until total }263 if (found >= dataShards) ready++ else missing += dataShards - found264 }265 return CodexShortfall(found = good.size, missing = missing, ready = ready, total = stripeCount)266}267268internal fun codexRestoreErrorMessage(code: CodexErrorCode, raw: String?): String {269 val message = raw.orEmpty()270 return when {271 message.contains("WRONG_PASSPHRASE", ignoreCase = true) ||272 message.contains("wrong", ignoreCase = true) ->273 "wrong master password. try again."274 message.contains("INVALID_VAULT_BLOB", ignoreCase = true) ||275 code == CodexErrorCode.INVALID_VAULT_BLOB ->276 "the reconstructed vault is unreadable. please re-scan the Codex."277 message.contains("FINGERPRINT_MISMATCH", ignoreCase = true) ||278 code == CodexErrorCode.RECONSTRUCTION_FINGERPRINT_MISMATCH ->279 "vault integrity check failed. please re-scan the Codex."280 code == CodexErrorCode.INSUFFICIENT_CHUNKS && message.contains("No Aztec", ignoreCase = true) ->281 "no Codex codes found in this PDF. make sure you're selecting a Peach Codex PDF, not a different document."282 message.isNotBlank() -> message283 else -> "restoration failed. please try again."284 }285}