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.CodexException11import com.peachpasswords.codex.CodexGenerator12import com.peachpasswords.codex.CodexManifest13import com.peachpasswords.codex.CodexPayload14import com.peachpasswords.codex.CodexRestore15import com.peachpasswords.codex.CodexStep16import com.peachpasswords.license.LicenseRepository17import com.peachpasswords.vault.UnlockResult18import com.peachpasswords.vault.VaultRepository19import dagger.hilt.android.lifecycle.HiltViewModel20import dagger.hilt.android.qualifiers.ApplicationContext21import kotlinx.coroutines.Dispatchers22import kotlinx.coroutines.flow.MutableStateFlow23import kotlinx.coroutines.flow.StateFlow24import kotlinx.coroutines.flow.asStateFlow25import kotlinx.coroutines.launch26import kotlinx.coroutines.withContext27import javax.inject.Inject2829sealed interface GenerateState {30 data object Idle : GenerateState31 data class Running(val step: CodexStep, val current: Int, val total: Int) : GenerateState32 data class Done(val manifest: CodexManifest, val pdf: ByteArray) : GenerateState33 data class Error(val message: String) : GenerateState34}3536sealed interface RestoreState {37 data object Idle : RestoreState38 data class Scanning(val page: Int, val pages: Int) : RestoreState39 data class Decoded(val payload: CodexPayload, val codesFound: Int) : RestoreState40 data object Restoring : RestoreState41 data object Restored : RestoreState42 data class Error(val message: String) : RestoreState43}44454647484950sealed interface CameraScanState {51 data object Choosing : CameraScanState52 data class Scanning(val codesFound: Int) : CameraScanState53 data class Timeout(val codesFound: Int) : CameraScanState54}5556@HiltViewModel57class CodexViewModel @Inject constructor(58 @ApplicationContext private val context: Context,59 private val vault: VaultRepository,60 private val license: LicenseRepository,61) : ViewModel() {6263 private val _generate = MutableStateFlow<GenerateState>(GenerateState.Idle)64 val generate: StateFlow<GenerateState> = _generate.asStateFlow()6566 67 val recoveryWordsSet: Boolean get() = vault.isRecoveryWordsSet()6869 70 val isPro: Boolean get() = license.isProActive()7172 private val recoveryWords: List<String> by lazy {73 vault.syncMasterKey()?.let { k ->74 try { com.peachpasswords.vault.rootKeyToWords(k) }75 finally { com.peachpasswords.vault.VaultCrypto.zero(k) }76 } ?: emptyList()77 }7879 80 fun gateMatches(w3: String, w7: String, w11: String): Boolean {81 val ws = recoveryWords82 if (ws.size < 11) return false83 return w3.trim().lowercase() == ws[2] &&84 w7.trim().lowercase() == ws[6] &&85 w11.trim().lowercase() == ws[10]86 }8788 private val _restore = MutableStateFlow<RestoreState>(RestoreState.Idle)89 val restore: StateFlow<RestoreState> = _restore.asStateFlow()9091 fun startGenerate(now: Long) {92 if (!isPro) {93 _generate.value = GenerateState.Error("Codex generation is a Pro feature.")94 return95 }96 _generate.value = GenerateState.Running(CodexStep.COMPRESS, 0, 1)97 viewModelScope.launch {98 try {99 val (pdf, manifest) = withContext(Dispatchers.Default) {100 CodexGenerator.generate(vault, now) { step, cur, total ->101 _generate.value = GenerateState.Running(step, cur, total)102 }103 }104 _generate.value = GenerateState.Done(manifest, pdf)105 } catch (e: CodexException) {106 _generate.value = GenerateState.Error(e.message ?: e.code.name)107 } catch (e: Exception) {108 _generate.value = GenerateState.Error(e.message ?: "generation_failed")109 }110 }111 }112113 fun writePdf(uri: Uri): Boolean = try {114 val pdf = (_generate.value as? GenerateState.Done)?.pdf ?: return false115 context.contentResolver.openOutputStream(uri)?.use { it.write(pdf) } != null116 } catch (_: Exception) {117 false118 }119120 fun scan(uri: Uri) {121 _restore.value = RestoreState.Scanning(0, 0)122 viewModelScope.launch {123 try {124 val payload = withContext(Dispatchers.Default) {125 val result = CodexRestore.decodeFromUri(context, uri) { page, pages ->126 _restore.value = RestoreState.Scanning(page, pages)127 }128 if (result.envelopes.isEmpty()) {129 throw CodexException(130 com.peachpasswords.codex.CodexErrorCode.INSUFFICIENT_CHUNKS,131 "No Aztec codes could be read from this file",132 )133 }134 CodexRestore.reconstructPayload(result.envelopes) to result.envelopes.size135 }136 _restore.value = RestoreState.Decoded(payload.first, payload.second)137 } catch (e: CodexException) {138 _restore.value = RestoreState.Error(e.message ?: e.code.name)139 } catch (e: Exception) {140 _restore.value = RestoreState.Error(e.message ?: "restore_failed")141 }142 }143 }144145 fun confirmRestore(password: String, onDone: () -> Unit) {146 val payload = (_restore.value as? RestoreState.Decoded)?.payload ?: return147 _restore.value = RestoreState.Restoring148 viewModelScope.launch {149 when (val r = vault.restoreFromCodex(payload.vaultBlob, password)) {150 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)153 }154 }155 }156157 fun resetRestore() { _restore.value = RestoreState.Idle }158159 160 private val _camera = MutableStateFlow<CameraScanState>(CameraScanState.Choosing)161 val camera: StateFlow<CameraScanState> = _camera.asStateFlow()162163 164 private val cameraEnvelopes = LinkedHashMap<String, ChunkEnvelope>()165166 167 fun chooseRestore() {168 cameraEnvelopes.clear()169 _camera.value = CameraScanState.Choosing170 _restore.value = RestoreState.Idle171 }172173 174 fun startCameraScan() {175 cameraEnvelopes.clear()176 _camera.value = CameraScanState.Scanning(0)177 }178179 180 fun cameraTimedOut() {181 val s = _camera.value182 if (s is CameraScanState.Scanning) _camera.value = CameraScanState.Timeout(s.codesFound)183 }184185 186187188189190191 fun onCameraFrame(image: ImageProxy): Boolean {192 if (_camera.value !is CameraScanState.Scanning) return _restore.value is RestoreState.Decoded193 val text = CodexAztecFrame.decode(image) ?: return false194 val env = CodexRestore.parseEnvelope(text) ?: return false195 cameraEnvelopes.putIfAbsent("${env.s}:${env.i}", env)196 _camera.value = CameraScanState.Scanning(cameraEnvelopes.size)197 return try {198 val payload = CodexRestore.reconstructPayload(cameraEnvelopes.values.toList())199 _restore.value = RestoreState.Decoded(payload, cameraEnvelopes.size)200 true201 } catch (_: Exception) {202 false203 }204 }205}