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.AdoptionFrame11import com.peachpasswords.sync.LocalDeviceIdentity12import com.peachpasswords.sync.PairedDevice13import com.peachpasswords.sync.PairedDeviceStore14import com.peachpasswords.sync.PeachSyncService15import com.peachpasswords.sync.RelayAdoptionReceiver16import com.peachpasswords.sync.RelaySettingsStore17import com.peachpasswords.sync.buildLocalIdentity18import com.peachpasswords.sync.computeVaultId19import com.peachpasswords.sync.getDeviceName20import com.peachpasswords.sync.hexToPubkey21import com.peachpasswords.sync.parsePairingInvitation22import com.peachpasswords.ui.security.scorePassword23import com.peachpasswords.vault.AdoptVaultResult24import com.peachpasswords.vault.VaultCrypto25import com.peachpasswords.vault.VaultRepository26import dagger.hilt.android.lifecycle.HiltViewModel27import dagger.hilt.android.qualifiers.ApplicationContext28import java.util.UUID29import javax.inject.Inject30import kotlinx.coroutines.CompletableDeferred31import kotlinx.coroutines.CancellationException32import kotlinx.coroutines.Job33import kotlinx.coroutines.flow.MutableStateFlow34import kotlinx.coroutines.flow.StateFlow35import kotlinx.coroutines.flow.asStateFlow36import kotlinx.coroutines.launch3738sealed interface AdoptVaultUiState {39 data object Password : AdoptVaultUiState40 4142434445 data object Disclosure : AdoptVaultUiState46 data object Pair : AdoptVaultUiState47 data object Connecting : AdoptVaultUiState48 data class ConfirmSas(val sas: String) : AdoptVaultUiState49 data object Adopting : AdoptVaultUiState50 data class Complete(val sourceName: String) : AdoptVaultUiState51 data class Failed(val reason: String) : AdoptVaultUiState52}5354@HiltViewModel55class AdoptVaultViewModel @Inject constructor(56 private val receiver: RelayAdoptionReceiver,57 private val vault: VaultRepository,58 private val devices: PairedDeviceStore,59 private val relaySettings: RelaySettingsStore,60 private val backgroundSync: BackgroundSyncPreference,61 private val license: LicenseRepository,62 @ApplicationContext private val appContext: Context,63) : ViewModel() {64 6566676869 val joining: Boolean = vault.hasVault()7071 private val _state = MutableStateFlow<AdoptVaultUiState>(72 if (joining) AdoptVaultUiState.Disclosure else AdoptVaultUiState.Password,73 )74 val state: StateFlow<AdoptVaultUiState> = _state.asStateFlow()7576 private var password: String? = null77 private var confirmation: CompletableDeferred<Boolean>? = null78 private var adoptionJob: Job? = null79 private var protocolError: String? = null8081 fun acceptPassword(value: String, repeated: String): Boolean {82 if (value.length < 12 || value != repeated || scorePassword(value) < 2) {83 return false84 }85 password = value86 _state.value = AdoptVaultUiState.Pair87 return true88 }8990 91 fun acceptDisclosure() {92 if (_state.value is AdoptVaultUiState.Disclosure) {93 _state.value = AdoptVaultUiState.Pair94 }95 }9697 fun adopt(invitation: String) {98 if (_state.value !is AdoptVaultUiState.Pair) return99 if (parsePairingInvitation(invitation) == null) {100 _state.value = AdoptVaultUiState.Failed("invalid_pairing_invitation")101 clearPassword()102 return103 }104 _state.value = AdoptVaultUiState.Connecting105 protocolError = null106 adoptionJob = viewModelScope.launch {107 try {108 val adopted = receiver.adopt(109 encodedInvitation = invitation,110 deps = receiverDeps(),111 confirm = { sas ->112 val pending = CompletableDeferred<Boolean>()113 confirmation = pending114 _state.value = AdoptVaultUiState.ConfirmSas(sas)115 pending.await()116 },117 )118 119 120 121 122 123 adopted.relayUrl?.let(relaySettings::setRelayUrl)124 _state.value = AdoptVaultUiState.Complete(125 adopted.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",126 )127 } catch (error: CancellationException) {128 throw error129 } catch (error: Throwable) {130 _state.value = AdoptVaultUiState.Failed(131 protocolError ?: error.message ?: "adoption_failed",132 )133 } finally {134 confirmation = null135 clearPassword()136 }137 }138 }139140 fun confirmSas(matches: Boolean) {141 val pending = confirmation ?: return142 confirmation = null143 if (matches) _state.value = AdoptVaultUiState.Adopting144 pending.complete(matches)145 }146147 fun restart() {148 adoptionJob?.cancel()149 adoptionJob = null150 confirmation?.complete(false)151 confirmation = null152 clearPassword()153 _state.value =154 if (joining) AdoptVaultUiState.Disclosure else AdoptVaultUiState.Password155 }156157 private fun receiverDeps(): AdoptionReceiverDeps =158 object : AdoptionReceiverDeps {159 override val deviceName = getDeviceName(appContext)160 override val deviceType = DeviceType.ANDROID161 override fun generateDeviceId(): String = UUID.randomUUID().toString()162163 164 165 override suspend fun confirmSas(): Boolean = false166167 override suspend fun validateSource(source: AdoptionFrame.Source) {168 169 170 171 172 173 174 if (!joining) return175 }176177 override suspend fun createVaultFromRootKey(rootKeyHex: String): String {178 179 180 181 val result = if (joining) {182 val incomingRoot = hexToPubkey(rootKeyHex)183 val incomingVaultId = try {184 computeVaultId(incomingRoot)185 } finally {186 VaultCrypto.zero(incomingRoot)187 }188 AdoptVaultResult.Success(189 requireSameVaultForPopulatedRePair(190 localVaultId = vault.syncVaultId(),191 incomingVaultId = incomingVaultId,192 ),193 )194 } else {195 val localPassword = password196 ?: throw IllegalStateException("adoption_password_unavailable")197 vault.createVaultFromRootKey(localPassword, rootKeyHex)198 }199 return when (result) {200 is AdoptVaultResult.Success -> result.vaultId201 is AdoptVaultResult.Error -> throw IllegalStateException(result.reason)202 }203 }204205 override suspend fun deriveLocalIdentity(): LocalDeviceIdentity {206 val master = vault.syncMasterKey()207 ?: throw IllegalStateException("adopted_vault_unavailable")208 return try {209 buildLocalIdentity(appContext, master)210 } finally {211 VaultCrypto.zero(master)212 }213 }214215 override suspend fun clearPairedDeviceRevocation(deviceId: String) {216 devices.clearRevocation(deviceId)217 }218219 override suspend fun upsertPairedDevice(device: PairedDevice) {220 devices.upsert(device)221 }222223 override suspend fun applyLicense(licenseKey: String) {224 if (license.validateLicense(licenseKey)) {225 license.storeLicense(licenseKey)226 }227 }228229 override fun onError(error: String) {230 protocolError = error231 }232 }233234 fun setBackgroundSync(enabled: Boolean) {235 backgroundSync.setEnabled(enabled)236 if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)237 }238239 private fun clearPassword() {240 password = null241 }242243 override fun onCleared() {244 adoptionJob?.cancel()245 confirmation?.complete(false)246 clearPassword()247 super.onCleared()248 }249}250251252253254255256internal fun requireSameVaultForPopulatedRePair(257 localVaultId: String?,258 incomingVaultId: String,259): String {260 if (localVaultId == null) throw IllegalStateException("vault_locked")261 if (localVaultId != incomingVaultId) {262 throw IllegalStateException("JOIN_POPULATED_VAULT_BLOCKED")263 }264 return localVaultId265}