feat(backup): lock-less phrase reveal, copy-to-clipboard, partner-restore empty-state, manual helper entry (R24-d)
This commit is contained in:
parent
b78dc24870
commit
aa09566636
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue