1package com.peachpasswords.ui.codex23import android.content.Context4import android.net.Uri5import androidx.lifecycle.ViewModel6import androidx.lifecycle.viewModelScope7import androidx.camera.core.ImageProxy8import com.peachpasswords.codex.ChunkEnvelope9import com.peachpasswords.codex.CodexAztecFrame10import com.peachpasswords.codex.CodexCodec11import com.peachpasswords.codex.CodexErrorCode12import com.peachpasswords.codex.CodexException13import com.peachpasswords.codex.CodexGenerator14import com.peachpasswords.codex.CodexManifest15import com.peachpasswords.codex.CodexPayload16import com.peachpasswords.codex.CodexRestore17import com.peachpasswords.codex.CodexStep18import com.peachpasswords.license.LicenseRepository19import com.peachpasswords.vault.UnlockResult20import com.peachpasswords.vault.VaultRepository21import dagger.hilt.android.lifecycle.HiltViewModel22import dagger.hilt.android.qualifiers.ApplicationContext23import kotlinx.coroutines.Dispatchers24import kotlinx.coroutines.flow.MutableStateFlow25import kotlinx.coroutines.flow.StateFlow26import kotlinx.coroutines.flow.asStateFlow27import kotlinx.coroutines.launch28import kotlinx.coroutines.withContext29import javax.inject.Inject3031sealed interface GenerateState {32 data object Idle : GenerateState33 data class Running(val step: CodexStep, val current: Int, val total: Int) : GenerateState34 data class Done(val manifest: CodexManifest, val pdf: ByteArray) : GenerateState35 data class Error(val message: String) : GenerateState36}3738sealed interface RestoreState {39 data object Idle : RestoreState40 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) : RestoreState42 data class Decoded(val payload: CodexPayload, val codesFound: Int) : RestoreState43 data object Restoring : RestoreState44 data object Restored : RestoreState45 data class Error(val message: String) : RestoreState46}47484950515253sealed interface CameraScanState {54 data object Choosing : CameraScanState55 data class Scanning(val codesFound: Int) : CameraScanState56 data class Timeout(val codesFound: Int) : CameraScanState57}5859@HiltViewModel60class CodexViewModel @Inject constructor(61 @ApplicationContext private val context: Context,62 private val vault: VaultRepository,63 private val license: LicenseRepository,64) : ViewModel() {6566 private val _generate = MutableStateFlow<GenerateState>(GenerateState.Idle)67 val generate: StateFlow<GenerateState> = _generate.asStateFlow()6869 70 val recoveryWordsSet: Boolean get() = vault.isRecoveryWordsSet()7172 73 val isPro: Boolean get() = license.isProActive()7475 private val recoveryWords: List<String> by lazy {76 vault.syncMasterKey()?.let { k ->77 try { com.peachpasswords.vault.rootKeyToWords(k) }78 finally { com.peachpasswords.vault.VaultCrypto.zero(k) }79 } ?: emptyList()80 }8182 83 fun gateMatches(w3: String, w7: String, w11: String): Boolean {84 val ws = recoveryWords85 if (ws.size < 11) return false86 return w3.trim().lowercase() == ws[2] &&87 w7.trim().lowercase() == ws[6] &&88 w11.trim().lowercase() == ws[10]89 }9091 private val _restore = MutableStateFlow<RestoreState>(RestoreState.Idle)92 val restore: StateFlow<RestoreState> = _restore.asStateFlow()9394 fun startGenerate(now: Long) {95 if (!isPro) {96 _generate.value = GenerateState.Error("Codex generation is a Pro feature.")97 return98 }99 _generate.value = GenerateState.Running(CodexStep.COMPRESS, 0, 1)100 viewModelScope.launch {101 try {102 val (pdf, manifest) = withContext(Dispatchers.Default) {103 CodexGenerator.generate(vault, now) { step, cur, total ->104 _generate.value = GenerateState.Running(step, cur, total)105 }106 }107 _generate.value = GenerateState.Done(manifest, pdf)108 } catch (e: CodexException) {109 _generate.value = GenerateState.Error(e.message ?: e.code.name)110 } catch (e: Exception) {111 _generate.value = GenerateState.Error(e.message ?: "generation_failed")112 }113 }114 }115116 fun writePdf(uri: Uri): Boolean = try {117 val pdf = (_generate.value as? GenerateState.Done)?.pdf ?: return false118 context.contentResolver.openOutputStream(uri)?.use { it.write(pdf) } != null119 } catch (_: Exception) {120 false121 }122123 fun scan(uri: Uri) {124 _restore.value = RestoreState.Scanning(0, 0)125 viewModelScope.launch {126 try {127 val payload = withContext(Dispatchers.Default) {128 val result = CodexRestore.decodeFromUri(context, uri) { page, pages ->129 _restore.value = RestoreState.Scanning(page, pages)130 }131 if (result.envelopes.isEmpty()) {132 throw CodexException(133 CodexErrorCode.INSUFFICIENT_CHUNKS,134 "No Aztec codes could be read from this file",135 )136 }137 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 }152 }153 if (payload != null) {154 _restore.value = RestoreState.Decoded(payload.first, payload.second)155 }156 } catch (e: CodexException) {157 _restore.value = RestoreState.Error(codexRestoreErrorMessage(e.code, e.message))158 } catch (e: Exception) {159 _restore.value = RestoreState.Error(e.message ?: "restore_failed")160 }161 }162 }163164 fun confirmRestore(password: String, onDone: () -> Unit) {165 val payload = (_restore.value as? RestoreState.Decoded)?.payload ?: return166 _restore.value = RestoreState.Restoring167 viewModelScope.launch {168 when (val r = vault.restoreFromCodex(payload.vaultBlob, password)) {169 is UnlockResult.Success -> { _restore.value = RestoreState.Restored; onDone() }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 )176 }177 }178 }179180 fun resetRestore() { _restore.value = RestoreState.Idle }181182 183 private val _camera = MutableStateFlow<CameraScanState>(CameraScanState.Choosing)184 val camera: StateFlow<CameraScanState> = _camera.asStateFlow()185186 187 private val cameraEnvelopes = LinkedHashMap<String, ChunkEnvelope>()188189 190 fun chooseRestore() {191 cameraEnvelopes.clear()192 _camera.value = CameraScanState.Choosing193 _restore.value = RestoreState.Idle194 }195196 197 fun startCameraScan() {198 cameraEnvelopes.clear()199 _camera.value = CameraScanState.Scanning(0)200 _restore.value = RestoreState.Idle201 }202203 204 fun cameraTimedOut() {205 val s = _camera.value206 if (s is CameraScanState.Scanning) _camera.value = CameraScanState.Timeout(s.codesFound)207 }208209 210211212213214215 fun onCameraFrame(image: ImageProxy): Boolean {216 if (_camera.value !is CameraScanState.Scanning) return _restore.value is RestoreState.Decoded217 val text = CodexAztecFrame.decode(image) ?: return false218 val env = CodexRestore.parseEnvelope(text) ?: return false219 cameraEnvelopes.putIfAbsent("${env.s}:${env.i}", env)220 _camera.value = CameraScanState.Scanning(cameraEnvelopes.size)221 return try {222 val payload = CodexRestore.reconstructPayload(cameraEnvelopes.values.toList())223 _restore.value = RestoreState.Decoded(payload, cameraEnvelopes.size)224 true225 } catch (_: Exception) {226 false227 }228 }229}230231internal data class CodexShortfall(val found: Int, val missing: Int, val ready: Int, val total: Int)232233internal 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}252253internal 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."269 }270}