diff --git a/app/src/main/java/app/closer/ui/settings/SecurityScreen.kt b/app/src/main/java/app/closer/ui/settings/SecurityScreen.kt index 16f33489..174f2cdd 100644 --- a/app/src/main/java/app/closer/ui/settings/SecurityScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/SecurityScreen.kt @@ -1,5 +1,9 @@ package app.closer.ui.settings +import android.content.ClipData +import android.content.ClipboardManager +import android.os.PersistableBundle +import android.widget.Toast import androidx.biometric.BiometricPrompt import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -57,12 +61,17 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +import app.closer.core.navigation.AppRoute import app.closer.ui.components.CloserGlyphs data class SecurityUiState( val biometricLoginEnabled: Boolean = false, val hasRecoveryPhrase: Boolean = false, - val recoveryPhrase: String? = null + val recoveryPhrase: String? = null, + // The couple key IS on this device (encrypt/decrypt works) even when no phrase is stored — this is the + // partner-restored case (storeTransferredKeyset transfers the key, not the phrase). Lets the empty state + // tell those users to ask their partner for the phrase instead of implying setup is incomplete. + val coupleKeyPresent: Boolean = false ) @HiltViewModel @@ -80,24 +89,29 @@ class SecurityViewModel @Inject constructor( // ACCEPTER sees their phrase too (the old global RecoveryPhraseStore was only ever // written by the inviter's create-invite flow, C-SEC-001). private val _storedPhrase = MutableStateFlow(null) + // Whether the couple key itself is present on this device (set up), independent of the phrase. + private val _coupleKeyPresent = MutableStateFlow(false) init { viewModelScope.launch { val uid = authRepository.currentUserId ?: return@launch val coupleId = runCatching { coupleRepository.getCoupleForUser(uid) }.getOrNull()?.id _storedPhrase.value = coupleId?.let { encryptionManager.recoveryPhrase(it) } + _coupleKeyPresent.value = coupleId?.let { encryptionManager.aeadFor(it) != null } ?: false } } val uiState: StateFlow = combine( settingsRepository.settings, _recoveryPhrase, - _storedPhrase - ) { settings, phrase, stored -> + _storedPhrase, + _coupleKeyPresent + ) { settings, phrase, stored, keyPresent -> SecurityUiState( biometricLoginEnabled = settings.biometricLoginEnabled, hasRecoveryPhrase = stored != null, - recoveryPhrase = phrase + recoveryPhrase = phrase, + coupleKeyPresent = keyPresent ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SecurityUiState()) @@ -124,7 +138,20 @@ fun SecurityScreen( val context = LocalContext.current fun launchBiometricForPhrase() { - val activity = context as? FragmentActivity ?: return + val authenticators = + androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG or + androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL + val activity = context as? FragmentActivity + // If there's no screen lock / biometric to gate behind (or no FragmentActivity host), don't leave the + // tap doing nothing — reveal directly. An unlocked, signed-in device already exposes everything, so a + // missing lock isn't a new exposure; the prompt is a best-effort second factor when one exists. + if (activity == null || + androidx.biometric.BiometricManager.from(context).canAuthenticate(authenticators) != + androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS + ) { + viewModel.revealRecoveryPhrase() + return + } BiometricPrompt( activity, ContextCompat.getMainExecutor(context), @@ -137,14 +164,22 @@ fun SecurityScreen( BiometricPrompt.PromptInfo.Builder() .setTitle("Recovery phrase") .setDescription("Authenticate to view your recovery phrase") - .setAllowedAuthenticators( - androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG or - androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL - ) + .setAllowedAuthenticators(authenticators) .build() ) } + fun copyRecoveryPhrase(phrase: String) { + val clip = ClipData.newPlainText("Recovery phrase", phrase) + // Flag as sensitive so the phrase is kept out of the clipboard-preview toast and clipboard + // history on Android 13+ (harmless on older APIs — the string key is simply ignored). + clip.description.extras = PersistableBundle().apply { + putBoolean("android.content.extra.IS_SENSITIVE", true) + } + (context.getSystemService(ClipboardManager::class.java))?.setPrimaryClip(clip) + Toast.makeText(context, "Recovery phrase copied", Toast.LENGTH_SHORT).show() + } + state.recoveryPhrase?.let { phrase -> AlertDialog( onDismissRequest = viewModel::hideRecoveryPhrase, @@ -167,6 +202,21 @@ fun SecurityScreen( confirmButton = { TextButton(onClick = viewModel::hideRecoveryPhrase) { Text("Done") } }, + dismissButton = { + TextButton(onClick = { copyRecoveryPhrase(phrase) }) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + CloserGlyphs.Copy, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Text("Copy") + } + } + }, containerColor = SettingsSoft ) } @@ -241,12 +291,40 @@ fun SecurityScreen( modifier = Modifier.weight(1f) ) } + + // Help-my-partner-restore entry — a manual way to reach the consent screen if the + // "Help your partner restore" notification never arrived. Only offered when we hold the + // couple key (you can't hand over a key you don't have). The screen shows "no request + // waiting" when there's nothing pending. + if (state.coupleKeyPresent) { + Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onNavigate(AppRoute.RESTORE_CONSENT) } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon(CloserGlyphs.Couple, contentDescription = null, tint = SettingsMuted) + Text( + text = "Help my partner restore", + style = MaterialTheme.typography.bodyLarge, + color = SettingsInk, + modifier = Modifier.weight(1f) + ) + } + } } } if (!state.hasRecoveryPhrase) { Text( - text = "Your recovery phrase will appear here once your couple is set up on this device.", + text = if (state.coupleKeyPresent) + // Partner-restored device: fully set up, but the phrase wasn't transferred with the key. + "You set this device up with your partner's help, so the recovery phrase isn't saved here. Ask your partner to open Settings → Security and read you theirs — either partner's phrase can restore your history. Write it down somewhere safe." + else + "Your recovery phrase will appear here once your couple is set up on this device.", style = MaterialTheme.typography.bodySmall, color = SettingsMuted, modifier = Modifier.padding(horizontal = 4.dp)