1package com.peachpasswords.ui.pairing23import android.content.Context4import androidx.lifecycle.ViewModel5import androidx.lifecycle.viewModelScope6import com.peachpasswords.license.LicenseRepository7import com.peachpasswords.sync.AdoptionReceiverDeps8import com.peachpasswords.sync.BackgroundSyncPreference9import com.peachpasswords.sync.DeviceType10import com.peachpasswords.sync.LocalDeviceIdentity11import com.peachpasswords.sync.PairedDevice12import com.peachpasswords.sync.PairedDeviceStore13import com.peachpasswords.sync.PeachSyncService14import com.peachpasswords.sync.RelayAdoptionReceiver15import com.peachpasswords.sync.RelaySettingsStore16import com.peachpasswords.sync.buildLocalIdentity17import com.peachpasswords.sync.getDeviceName18import com.peachpasswords.sync.parsePairingInvitation19import com.peachpasswords.ui.security.scorePassword20import com.peachpasswords.vault.AdoptVaultResult21import com.peachpasswords.vault.VaultCrypto22import com.peachpasswords.vault.VaultRepository23import dagger.hilt.android.lifecycle.HiltViewModel24import dagger.hilt.android.qualifiers.ApplicationContext25import java.util.UUID26import javax.inject.Inject27import kotlinx.coroutines.CompletableDeferred28import kotlinx.coroutines.CancellationException29import kotlinx.coroutines.Job30import kotlinx.coroutines.flow.MutableStateFlow31import kotlinx.coroutines.flow.StateFlow32import kotlinx.coroutines.flow.asStateFlow33import kotlinx.coroutines.launch3435sealed interface AdoptVaultUiState {36 data object Password : AdoptVaultUiState37 3839404142 data object Disclosure : AdoptVaultUiState43 data object Pair : AdoptVaultUiState44 data object Connecting : AdoptVaultUiState45 data class ConfirmSas(val sas: String) : AdoptVaultUiState46 474849505152 data class RetireWords(val incomingWords: List<String>) : AdoptVaultUiState53 data object Adopting : AdoptVaultUiState54 data class Complete(val sourceName: String) : AdoptVaultUiState55 data class Failed(val reason: String) : AdoptVaultUiState56}5758@HiltViewModel59class AdoptVaultViewModel @Inject constructor(60 private val receiver: RelayAdoptionReceiver,61 private val vault: VaultRepository,62 private val devices: PairedDeviceStore,63 private val relaySettings: RelaySettingsStore,64 private val backgroundSync: BackgroundSyncPreference,65 private val license: LicenseRepository,66 @ApplicationContext private val appContext: Context,67) : ViewModel() {68 6970717273 val joining: Boolean = vault.hasVault()7475 private val _state = MutableStateFlow<AdoptVaultUiState>(76 if (joining) AdoptVaultUiState.Disclosure else AdoptVaultUiState.Password,77 )78 val state: StateFlow<AdoptVaultUiState> = _state.asStateFlow()7980 private var password: String? = null81 private var confirmation: CompletableDeferred<Boolean>? = null82 private var wordRetirement: CompletableDeferred<Boolean>? = null83 private var adoptionJob: Job? = null84 private var protocolError: String? = null8586 fun acceptPassword(value: String, repeated: String): Boolean {87 if (value.length < 12 || value != repeated || scorePassword(value) < 2) {88 return false89 }90 password = value91 _state.value = AdoptVaultUiState.Pair92 return true93 }9495 96 fun acceptDisclosure() {97 if (_state.value is AdoptVaultUiState.Disclosure) {98 _state.value = AdoptVaultUiState.Pair99 }100 }101102 fun adopt(invitation: String) {103 if (_state.value !is AdoptVaultUiState.Pair) return104 if (parsePairingInvitation(invitation) == null) {105 _state.value = AdoptVaultUiState.Failed("invalid_pairing_invitation")106 clearPassword()107 return108 }109 _state.value = AdoptVaultUiState.Connecting110 protocolError = null111 adoptionJob = viewModelScope.launch {112 try {113 val adopted = receiver.adopt(114 encodedInvitation = invitation,115 deps = receiverDeps(),116 confirm = { sas ->117 val pending = CompletableDeferred<Boolean>()118 confirmation = pending119 _state.value = AdoptVaultUiState.ConfirmSas(sas)120 pending.await()121 },122 )123 124 125 126 127 128 adopted.relayUrl?.let(relaySettings::setRelayUrl)129 _state.value = AdoptVaultUiState.Complete(130 adopted.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",131 )132 } catch (error: CancellationException) {133 throw error134 } catch (error: Throwable) {135 _state.value = AdoptVaultUiState.Failed(136 protocolError ?: error.message ?: "adoption_failed",137 )138 } finally {139 confirmation = null140 wordRetirement = null141 clearPassword()142 }143 }144 }145146 fun confirmSas(matches: Boolean) {147 val pending = confirmation ?: return148 confirmation = null149 150 if (matches && !joining) _state.value = AdoptVaultUiState.Adopting151 pending.complete(matches)152 }153154 155 fun acknowledgeWordRetirement(proceed: Boolean) {156 val pending = wordRetirement ?: return157 wordRetirement = null158 pending.complete(proceed)159 }160161 fun restart() {162 adoptionJob?.cancel()163 adoptionJob = null164 confirmation?.complete(false)165 confirmation = null166 wordRetirement?.complete(false)167 wordRetirement = null168 clearPassword()169 _state.value =170 if (joining) AdoptVaultUiState.Disclosure else AdoptVaultUiState.Password171 }172173 private fun receiverDeps(): AdoptionReceiverDeps =174 object : AdoptionReceiverDeps {175 override val deviceName = getDeviceName(appContext)176 override val deviceType = DeviceType.ANDROID177 override fun generateDeviceId(): String = UUID.randomUUID().toString()178179 180 181 override suspend fun confirmSas(): Boolean = false182183 override suspend fun createVaultFromRootKey(rootKeyHex: String): String {184 185 186 187 val result = if (joining) {188 189 190 191 val incoming = vault.previewRecoveryWords(rootKeyHex)192 ?: throw IllegalStateException("JOIN_UNRECOVERABLE_ROOT_KEY")193 val acknowledged = CompletableDeferred<Boolean>()194 wordRetirement = acknowledged195 _state.value = AdoptVaultUiState.RetireWords(incoming)196 if (!acknowledged.await()) {197 throw IllegalStateException("join_declined_at_word_retirement")198 }199 _state.value = AdoptVaultUiState.Adopting200 vault.joinVault(rootKeyHex)201 } else {202 val localPassword = password203 ?: throw IllegalStateException("adoption_password_unavailable")204 vault.createVaultFromRootKey(localPassword, rootKeyHex)205 }206 return when (result) {207 is AdoptVaultResult.Success -> result.vaultId208 is AdoptVaultResult.Error -> throw IllegalStateException(result.reason)209 }210 }211212 override suspend fun deriveLocalIdentity(): LocalDeviceIdentity {213 val master = vault.syncMasterKey()214 ?: throw IllegalStateException("adopted_vault_unavailable")215 return try {216 buildLocalIdentity(appContext, master)217 } finally {218 VaultCrypto.zero(master)219 }220 }221222 override suspend fun clearPairedDeviceRevocation(deviceId: String) {223 devices.clearRevocation(deviceId)224 }225226 override suspend fun upsertPairedDevice(device: PairedDevice) {227 devices.upsert(device)228 }229230 override suspend fun applyLicense(licenseKey: String) {231 if (license.validateLicense(licenseKey)) {232 license.storeLicense(licenseKey)233 }234 }235236 override fun onError(error: String) {237 protocolError = error238 }239 }240241 fun setBackgroundSync(enabled: Boolean) {242 backgroundSync.setEnabled(enabled)243 if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)244 }245246 private fun clearPassword() {247 password = null248 }249250 override fun onCleared() {251 adoptionJob?.cancel()252 confirmation?.complete(false)253 wordRetirement?.complete(false)254 clearPassword()255 super.onCleared()256 }257}