feat(backup): lock-less phrase reveal, copy-to-clipboard, partner-restore empty-state, manual helper entry (R24-d)

This commit is contained in:
null 2026-06-30 21:24:52 -05:00
parent b78dc24870
commit aa09566636
1 changed files with 88 additions and 10 deletions

View File

@ -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<String?>(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<SecurityUiState> = 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)