diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d7939ee794..6d53097848 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1137,6 +1137,11 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
+
+
diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt
index 8aba01322b..6a8269ffa4 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt
@@ -33,6 +33,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
@@ -67,6 +68,7 @@ import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.getSerializableCompat
import org.signal.core.util.logging.Log
import org.signal.donations.StripeApi
+import org.thoughtcrime.securesms.backup.v2.ui.verify.VerifyBackupKeyActivity
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.show
import org.thoughtcrime.securesms.calls.log.CallLogFilter
import org.thoughtcrime.securesms.calls.log.CallLogFragment
@@ -599,6 +601,16 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
)
)
}
+
+ if (resultCode == RESULT_OK && requestCode == VerifyBackupKeyActivity.REQUEST_CODE) {
+ mainNavigationViewModel.setSnackbar(
+ SnackbarState(
+ message = getString(R.string.VerifyBackupKey__backup_key_correct),
+ duration = SnackbarDuration.Short
+ )
+ )
+ mainNavigationViewModel.onMegaphoneSnoozed(Megaphones.Event.VERIFY_BACKUP_KEY)
+ }
}
override fun onFirstRender() {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/EnterKeyScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/EnterKeyScreen.kt
new file mode 100644
index 0000000000..d381d6437b
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/EnterKeyScreen.kt
@@ -0,0 +1,173 @@
+package org.thoughtcrime.securesms.backup.v2.ui.subscription
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.signal.core.ui.compose.Buttons
+import org.signal.core.ui.compose.horizontalGutters
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.fonts.MonoTypeface
+import org.thoughtcrime.securesms.registrationv3.ui.restore.BackupKeyVisualTransformation
+import org.thoughtcrime.securesms.registrationv3.ui.restore.attachBackupKeyAutoFillHelper
+import org.thoughtcrime.securesms.registrationv3.ui.restore.backupKeyAutoFillHelper
+import org.whispersystems.signalservice.api.AccountEntropyPool
+
+/**
+ * Screen to enter backup key with an option to view the backup key again
+ */
+@Composable
+fun EnterKeyScreen(
+ paddingValues: PaddingValues,
+ backupKey: String,
+ onNextClick: () -> Unit,
+ captionContent: @Composable () -> Unit,
+ seeKeyButton: @Composable () -> Unit
+) {
+ Column(
+ verticalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier
+ .padding(paddingValues)
+ .consumeWindowInsets(paddingValues)
+ .imePadding()
+ .fillMaxSize()
+ ) {
+ val scrollState = rememberScrollState()
+
+ val focusRequester = remember { FocusRequester() }
+ val visualTransform = remember { BackupKeyVisualTransformation(chunkSize = 4) }
+ val keyboardController = LocalSoftwareKeyboardController.current
+
+ var enteredBackupKey by remember { mutableStateOf("") }
+ var isBackupKeyValid by remember { mutableStateOf(false) }
+ var showError by remember { mutableStateOf(false) }
+
+ Column(
+ modifier = Modifier
+ .verticalScroll(scrollState)
+ .weight(weight = 1f, fill = false)
+ .horizontalGutters(),
+ horizontalAlignment = Alignment.Start
+ ) {
+ captionContent()
+
+ Spacer(modifier = Modifier.height(48.dp))
+
+ val updateEnteredBackupKey = { input: String ->
+ enteredBackupKey = AccountEntropyPool.removeIllegalCharacters(input).uppercase()
+ isBackupKeyValid = enteredBackupKey == backupKey
+ showError = !isBackupKeyValid && enteredBackupKey.length >= backupKey.length
+ }
+
+ var requestFocus: Boolean by remember { mutableStateOf(true) }
+ val autoFillHelper = backupKeyAutoFillHelper { updateEnteredBackupKey(it) }
+
+ TextField(
+ value = enteredBackupKey,
+ onValueChange = {
+ updateEnteredBackupKey(it)
+ autoFillHelper.onValueChanged(it)
+ },
+ label = {
+ Text(text = stringResource(id = R.string.MessageBackupsKeyVerifyScreen__backup_key))
+ },
+ textStyle = LocalTextStyle.current.copy(
+ fontFamily = MonoTypeface.fontFamily(),
+ lineHeight = 36.sp
+ ),
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Password,
+ capitalization = KeyboardCapitalization.None,
+ imeAction = ImeAction.Next,
+ autoCorrectEnabled = false
+ ),
+ keyboardActions = KeyboardActions(
+ onNext = {
+ if (isBackupKeyValid) {
+ keyboardController?.hide()
+ onNextClick()
+ }
+ }
+ ),
+ colors = TextFieldDefaults.colors(
+ focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
+ unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant
+ ),
+ supportingText = { if (showError) Text(text = stringResource(R.string.MessageBackupsKeyVerifyScreen__incorrect_backup_key)) },
+ isError = showError,
+ minLines = 4,
+ visualTransformation = visualTransform,
+ modifier = Modifier
+ .testTag("message-backups-key-verify-screen-backup-key-input-field")
+ .fillMaxWidth()
+ .focusRequester(focusRequester)
+ .attachBackupKeyAutoFillHelper(autoFillHelper)
+ .onGloballyPositioned {
+ if (requestFocus) {
+ focusRequester.requestFocus()
+ requestFocus = false
+ }
+ }
+ )
+ }
+
+ Surface(
+ shadowElevation = if (scrollState.canScrollForward) 8.dp else 0.dp,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier
+ .padding(top = 8.dp, bottom = 24.dp)
+ .horizontalGutters()
+ .fillMaxWidth()
+ ) {
+ seeKeyButton()
+
+ Buttons.LargeTonal(
+ enabled = isBackupKeyValid,
+ onClick = onNextClick
+ ) {
+ Text(
+ text = stringResource(id = R.string.RegistrationActivity_next)
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyVerifyScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyVerifyScreen.kt
index 51d2d7474f..c5a6415983 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyVerifyScreen.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyVerifyScreen.kt
@@ -6,71 +6,40 @@
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.compose.foundation.Image
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
-import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.text.KeyboardActions
-import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
-import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
-import androidx.compose.material3.TextField
-import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
-import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
-import androidx.compose.ui.focus.FocusRequester
-import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.layout.onGloballyPositioned
-import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
-import androidx.compose.ui.text.input.ImeAction
-import androidx.compose.ui.text.input.KeyboardCapitalization
-import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalPreview
-import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
-import org.thoughtcrime.securesms.fonts.MonoTypeface
-import org.thoughtcrime.securesms.registrationv3.ui.restore.BackupKeyVisualTransformation
-import org.thoughtcrime.securesms.registrationv3.ui.restore.attachBackupKeyAutoFillHelper
-import org.thoughtcrime.securesms.registrationv3.ui.restore.backupKeyAutoFillHelper
-import org.whispersystems.signalservice.api.AccountEntropyPool
+import org.thoughtcrime.securesms.keyvalue.SignalStore
import kotlin.random.Random
import kotlin.random.nextInt
import org.signal.core.ui.R as CoreUiR
@@ -78,7 +47,7 @@ import org.signal.core.ui.R as CoreUiR
/**
* Prompt user to re-enter backup key (AEP) to confirm they have it still.
*/
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MessageBackupsKeyVerifyScreen(
backupKey: String,
@@ -96,127 +65,28 @@ fun MessageBackupsKeyVerifyScreen(
onNavigationClick = onNavigationClick
) { paddingValues ->
- Column(
- verticalArrangement = Arrangement.SpaceBetween,
- modifier = Modifier
- .padding(paddingValues)
- .imePadding()
- .fillMaxSize()
- ) {
- val scrollState = rememberScrollState()
-
- val focusRequester = remember { FocusRequester() }
- val visualTransform = remember { BackupKeyVisualTransformation(chunkSize = 4) }
- val keyboardController = LocalSoftwareKeyboardController.current
-
- var enteredBackupKey by remember { mutableStateOf("") }
- var isBackupKeyValid by remember { mutableStateOf(false) }
- var showError by remember { mutableStateOf(false) }
-
- Column(
- modifier = Modifier
- .verticalScroll(scrollState)
- .weight(weight = 1f, fill = false)
- .horizontalGutters(),
- horizontalAlignment = Alignment.Start
- ) {
+ EnterKeyScreen(
+ paddingValues = paddingValues,
+ backupKey = backupKey,
+ onNextClick = {
+ coroutineScope.launch { sheetState.show() }
+ },
+ captionContent = {
Text(
text = stringResource(R.string.MessageBackupsKeyVerifyScreen__enter_the_backup_key_that_you_just_recorded),
style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurfaceVariant)
)
-
- Spacer(modifier = Modifier.height(48.dp))
-
- val updateEnteredBackupKey = { input: String ->
- enteredBackupKey = AccountEntropyPool.removeIllegalCharacters(input).uppercase()
- isBackupKeyValid = enteredBackupKey == backupKey
- showError = !isBackupKeyValid && enteredBackupKey.length >= backupKey.length
- }
-
- var requestFocus: Boolean by remember { mutableStateOf(true) }
- val autoFillHelper = backupKeyAutoFillHelper { updateEnteredBackupKey(it) }
-
- TextField(
- value = enteredBackupKey,
- onValueChange = {
- updateEnteredBackupKey(it)
- autoFillHelper.onValueChanged(it)
- },
- label = {
- Text(text = stringResource(id = R.string.MessageBackupsKeyVerifyScreen__backup_key))
- },
- textStyle = LocalTextStyle.current.copy(
- fontFamily = MonoTypeface.fontFamily(),
- lineHeight = 36.sp
- ),
- keyboardOptions = KeyboardOptions(
- keyboardType = KeyboardType.Password,
- capitalization = KeyboardCapitalization.None,
- imeAction = ImeAction.Next,
- autoCorrectEnabled = false
- ),
- keyboardActions = KeyboardActions(
- onNext = {
- if (isBackupKeyValid) {
- keyboardController?.hide()
- coroutineScope.launch { sheetState.show() }
- }
- }
- ),
- colors = TextFieldDefaults.colors(
- focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
- unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant
- ),
- supportingText = { if (showError) Text(text = stringResource(R.string.MessageBackupsKeyVerifyScreen__incorrect_backup_key)) },
- isError = showError,
- minLines = 4,
- visualTransformation = visualTransform,
- modifier = Modifier
- .testTag("message-backups-key-verify-screen-backup-key-input-field")
- .fillMaxWidth()
- .focusRequester(focusRequester)
- .attachBackupKeyAutoFillHelper(autoFillHelper)
- .onGloballyPositioned {
- if (requestFocus) {
- focusRequester.requestFocus()
- requestFocus = false
- }
- }
- )
- }
-
- Surface(
- shadowElevation = if (scrollState.canScrollForward) 8.dp else 0.dp,
- modifier = Modifier.fillMaxWidth()
- ) {
- Row(
- horizontalArrangement = Arrangement.SpaceBetween,
- modifier = Modifier
- .padding(top = 8.dp, bottom = 24.dp)
- .horizontalGutters()
- .fillMaxWidth()
+ },
+ seeKeyButton = {
+ TextButton(
+ onClick = onNavigationClick
) {
- TextButton(
- onClick = onNavigationClick
- ) {
- Text(
- text = stringResource(id = R.string.MessageBackupsKeyVerifyScreen__see_key_again)
- )
- }
-
- Buttons.LargeTonal(
- enabled = isBackupKeyValid,
- onClick = {
- coroutineScope.launch { sheetState.show() }
- }
- ) {
- Text(
- text = stringResource(id = R.string.RegistrationActivity_next)
- )
- }
+ Text(
+ text = stringResource(id = R.string.MessageBackupsKeyVerifyScreen__see_key_again)
+ )
}
}
- }
+ )
if (sheetState.isVisible) {
ModalBottomSheet(
@@ -225,6 +95,7 @@ fun MessageBackupsKeyVerifyScreen(
containerColor = SignalTheme.colors.colorSurface1,
onDismissRequest = {
coroutineScope.launch {
+ SignalStore.backup.lastVerifyKeyTime = System.currentTimeMillis()
sheetState.hide()
}
}
@@ -232,6 +103,7 @@ fun MessageBackupsKeyVerifyScreen(
BottomSheetContent(
onContinueClick = {
coroutineScope.launch {
+ SignalStore.backup.lastVerifyKeyTime = System.currentTimeMillis()
sheetState.hide()
}
onNextClick()
diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/verify/ConfirmBackupKeyDisplayFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/verify/ConfirmBackupKeyDisplayFragment.kt
new file mode 100644
index 0000000000..038b2a8c54
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/verify/ConfirmBackupKeyDisplayFragment.kt
@@ -0,0 +1,30 @@
+package org.thoughtcrime.securesms.backup.v2.ui.verify
+
+import android.app.Activity.RESULT_OK
+import androidx.compose.runtime.Composable
+import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyVerifyScreen
+import org.thoughtcrime.securesms.compose.ComposeFragment
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+
+/**
+ * Fragment to confirm the backup key just shown after users forget it.
+ */
+class ConfirmBackupKeyDisplayFragment : ComposeFragment() {
+
+ @Composable
+ override fun FragmentContent() {
+ MessageBackupsKeyVerifyScreen(
+ backupKey = SignalStore.account.accountEntropyPool.displayValue,
+ onNavigationClick = {
+ requireActivity().supportFragmentManager.popBackStack()
+ },
+ onNextClick = {
+ SignalStore.backup.lastVerifyKeyTime = System.currentTimeMillis()
+ SignalStore.backup.hasVerifiedBefore = true
+ SignalStore.backup.hasSnoozedVerified = false
+ requireActivity().setResult(RESULT_OK)
+ requireActivity().finish()
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/verify/ForgotBackupKeyFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/verify/ForgotBackupKeyFragment.kt
new file mode 100644
index 0000000000..dc5b9455a3
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/verify/ForgotBackupKeyFragment.kt
@@ -0,0 +1,50 @@
+package org.thoughtcrime.securesms.backup.v2.ui.verify
+
+import android.R
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyRecordScreen
+import org.thoughtcrime.securesms.compose.ComposeFragment
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+import org.thoughtcrime.securesms.util.Util
+import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository
+import org.thoughtcrime.securesms.util.viewModel
+
+/**
+ * Fragment which displays the backup key to the user after users forget it.
+ */
+class ForgotBackupKeyFragment : ComposeFragment() {
+
+ companion object {
+ const val CLIPBOARD_TIMEOUT_SECONDS = 60
+ }
+
+ private val viewModel: ForgotBackupKeyViewModel by viewModel { ForgotBackupKeyViewModel() }
+
+ @Composable
+ override fun FragmentContent() {
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+ val passwordManagerSettingsIntent = AndroidCredentialRepository.getCredentialManagerSettingsIntent(requireContext())
+
+ MessageBackupsKeyRecordScreen(
+ backupKey = SignalStore.account.accountEntropyPool.displayValue,
+ keySaveState = state.keySaveState,
+ canOpenPasswordManagerSettings = passwordManagerSettingsIntent != null,
+ onNavigationClick = { requireActivity().supportFragmentManager.popBackStack() },
+ onCopyToClipboardClick = { Util.copyToClipboard(requireContext(), it, CLIPBOARD_TIMEOUT_SECONDS) },
+ onRequestSaveToPasswordManager = viewModel::onBackupKeySaveRequested,
+ onConfirmSaveToPasswordManager = viewModel::onBackupKeySaveConfirmed,
+ onSaveToPasswordManagerComplete = viewModel::onBackupKeySaveCompleted,
+ onNextClick = {
+ requireActivity()
+ .supportFragmentManager
+ .beginTransaction()
+ .add(R.id.content, ConfirmBackupKeyDisplayFragment())
+ .addToBackStack(null)
+ .commit()
+ },
+ onGoToPasswordManagerSettingsClick = { requireContext().startActivity(passwordManagerSettingsIntent) }
+ )
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/verify/ForgotBackupKeyViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/verify/ForgotBackupKeyViewModel.kt
new file mode 100644
index 0000000000..7a1f7835a4
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/verify/ForgotBackupKeyViewModel.kt
@@ -0,0 +1,24 @@
+package org.thoughtcrime.securesms.backup.v2.ui.verify
+
+import androidx.lifecycle.ViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeyCredentialManagerHandler
+import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState
+
+/**
+ * View model for [ForgotBackupKeyFragment]
+ */
+class ForgotBackupKeyViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
+ private val _uiState = MutableStateFlow(BackupKeyDisplayUiState())
+ val uiState: StateFlow = _uiState
+
+ override fun updateBackupKeySaveState(newState: BackupKeySaveState?) {
+ _uiState.update { it.copy(keySaveState = newState) }
+ }
+}
+
+data class BackupKeyDisplayUiState(
+ val keySaveState: BackupKeySaveState? = null
+)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/verify/VerifyBackupKeyActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/verify/VerifyBackupKeyActivity.kt
new file mode 100644
index 0000000000..6cc321df4e
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/verify/VerifyBackupKeyActivity.kt
@@ -0,0 +1,191 @@
+package org.thoughtcrime.securesms.backup.v2.ui.verify
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.result.ActivityResultLauncher
+import androidx.biometric.BiometricManager
+import androidx.biometric.BiometricPrompt
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.LinkAnnotation
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.withLink
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.dp
+import org.signal.core.ui.compose.Previews
+import org.signal.core.ui.compose.SignalPreview
+import org.signal.core.ui.compose.theme.SignalTheme
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.BiometricDeviceAuthentication
+import org.thoughtcrime.securesms.BiometricDeviceLockContract
+import org.thoughtcrime.securesms.PassphraseRequiredActivity
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.backup.v2.ui.subscription.EnterKeyScreen
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+import org.thoughtcrime.securesms.util.CommunicationActions
+import kotlin.random.Random
+import kotlin.random.nextInt
+
+/**
+ * Screen to verify the backup key
+ */
+class VerifyBackupKeyActivity : PassphraseRequiredActivity() {
+
+ companion object {
+ private val TAG = Log.tag(VerifyBackupKeyActivity::class)
+
+ @JvmStatic
+ fun createIntent(context: Context): Intent {
+ return Intent(context, VerifyBackupKeyActivity::class.java)
+ }
+
+ const val REQUEST_CODE = 1204
+ }
+
+ private lateinit var biometricDeviceAuthentication: BiometricDeviceAuthentication
+ private lateinit var biometricDeviceLockLauncher: ActivityResultLauncher
+
+ override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
+ enableEdgeToEdge()
+
+ setContent {
+ SignalTheme {
+ VerifyBackupPinScreen(
+ backupKey = SignalStore.account.accountEntropyPool.displayValue,
+ onForgotKeyClick = {
+ if (!biometricDeviceAuthentication.authenticate(this, true) { biometricDeviceLockLauncher.launch(getString(R.string.RemoteBackupsSettingsFragment__unlock_to_view_backup_key)) }) {
+ displayBackupKey()
+ }
+ },
+ onNextClick = {
+ SignalStore.backup.lastVerifyKeyTime = System.currentTimeMillis()
+ SignalStore.backup.hasVerifiedBefore = true
+ SignalStore.backup.hasSnoozedVerified = false
+ setResult(RESULT_OK)
+ finish()
+ }
+ )
+ }
+ }
+
+ initializeBiometricAuth()
+ }
+
+ private fun initializeBiometricAuth() {
+ val biometricPrompt = BiometricPrompt(this, AuthListener())
+ val promptInfo: BiometricPrompt.PromptInfo = BiometricPrompt.PromptInfo.Builder()
+ .setAllowedAuthenticators(BiometricDeviceAuthentication.ALLOWED_AUTHENTICATORS)
+ .setTitle(getString(R.string.RemoteBackupsSettingsFragment__unlock_to_view_backup_key))
+ .build()
+
+ biometricDeviceAuthentication = BiometricDeviceAuthentication(BiometricManager.from(this), biometricPrompt, promptInfo)
+ biometricDeviceLockLauncher = registerForActivityResult(BiometricDeviceLockContract()) { result: Int ->
+ if (result == BiometricDeviceAuthentication.AUTHENTICATED) {
+ displayBackupKey()
+ }
+ }
+ }
+
+ private fun displayBackupKey() {
+ supportFragmentManager
+ .beginTransaction()
+ .add(android.R.id.content, ForgotBackupKeyFragment())
+ .addToBackStack(null)
+ .commit()
+ }
+
+ private inner class AuthListener : BiometricPrompt.AuthenticationCallback() {
+ override fun onAuthenticationFailed() {
+ Log.w(TAG, "onAuthenticationFailed")
+ Toast.makeText(this@VerifyBackupKeyActivity, R.string.RemoteBackupsSettingsFragment__authenticatino_required, Toast.LENGTH_SHORT).show()
+ }
+
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
+ Log.i(TAG, "onAuthenticationSucceeded")
+ displayBackupKey()
+ }
+
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
+ Log.w(TAG, "onAuthenticationError: $errorCode, $errString")
+ onAuthenticationFailed()
+ }
+ }
+}
+
+@Composable
+fun VerifyBackupPinScreen(
+ backupKey: String,
+ onForgotKeyClick: () -> Unit = {},
+ onNextClick: () -> Unit = {}
+) {
+ val context = LocalContext.current
+ val keyboardController = LocalSoftwareKeyboardController.current
+
+ val text = buildAnnotatedString {
+ append(stringResource(id = R.string.VerifyBackupPinScreen__enter_the_backup_key_that_you_recorded))
+ append(" ")
+
+ withLink(
+ LinkAnnotation.Clickable(tag = "learn-more") {
+ CommunicationActions.openBrowserLink(context, context.getString(R.string.backup_failed_support_url))
+ }
+ ) {
+ withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) {
+ append(stringResource(id = R.string.BackupAlertBottomSheet__learn_more))
+ }
+ }
+ }
+
+ Scaffold { paddingValues ->
+ EnterKeyScreen(
+ paddingValues = paddingValues,
+ backupKey = backupKey,
+ onNextClick = onNextClick,
+ captionContent = {
+ Text(
+ text = stringResource(R.string.VerifyBackupPinScreen__enter_your_backup_key),
+ style = MaterialTheme.typography.headlineMedium.copy(color = MaterialTheme.colorScheme.onSurface),
+ modifier = Modifier.padding(top = 40.dp, bottom = 16.dp)
+ )
+
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurfaceVariant)
+ )
+ },
+ seeKeyButton = {
+ TextButton(
+ onClick = {
+ keyboardController?.hide()
+ onForgotKeyClick()
+ }
+ ) {
+ Text(text = stringResource(id = R.string.VerifyBackupPinScreen__forgot_key))
+ }
+ }
+ )
+ }
+}
+
+@SignalPreview
+@Composable
+private fun VerifyBackupKeyScreen() {
+ Previews.Preview {
+ VerifyBackupPinScreen(
+ backupKey = (0 until 64).map { Random.nextInt(65..90).toChar() }.joinToString("").uppercase()
+ )
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt
index 46856a7d40..5259e2541d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt
@@ -82,6 +82,10 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
private const val KEY_MEDIA_ROOT_BACKUP_KEY = "backup.mediaRootBackupKey"
+ private const val KEY_LAST_VERIFY_KEY_TIME = "backup.last_verify_key_time"
+ private const val KEY_HAS_SNOOZED_VERIFY = "backup.has_snoozed_verify"
+ private const val KEY_HAS_VERIFIED_BEFORE = "backup.has_verified_before"
+
private val cachedCdnCredentialsExpiresIn: Duration = 12.hours
private val lock = ReentrantLock()
@@ -299,6 +303,9 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
.putLong(KEY_NEXT_BACKUP_TIME, -1)
.putBoolean(KEY_BACKUPS_INITIALIZED, false)
.putBoolean(KEY_BACKUP_UPLOADED, false)
+ .putLong(KEY_LAST_VERIFY_KEY_TIME, -1)
+ .putBoolean(KEY_HAS_VERIFIED_BEFORE, false)
+ .putBoolean(KEY_HAS_SNOOZED_VERIFY, false)
.apply()
backupTier = null
backupTierInternalOverride = null
@@ -324,6 +331,15 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
var isNoBackupForManualUploadNotified by booleanValue(KEY_MANUAL_NO_BACKUP_NOTIFIED, false)
+ /** Last time they successfully entered their backup key, including when they first initialized backups **/
+ var lastVerifyKeyTime by longValue(KEY_LAST_VERIFY_KEY_TIME, -1)
+
+ /** Checks if they have previously snoozed the megaphone to verify their backup key **/
+ var hasSnoozedVerified by booleanValue(KEY_HAS_SNOOZED_VERIFY, false)
+
+ /** Checks if they have ever verified their backup key before **/
+ var hasVerifiedBefore by booleanValue(KEY_HAS_VERIFIED_BEFORE, false)
+
/**
* If true, it means we have been told that remote storage is full, but we have not yet run any of our "garbage collection" tasks, like committing deletes
* or pruning orphaned media.
diff --git a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionRemoteBackups.kt b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionRemoteBackups.kt
index d2dca763b4..05ed9c07a4 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionRemoteBackups.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionRemoteBackups.kt
@@ -36,6 +36,7 @@ class LogSectionRemoteBackups : LogSection {
output.append("Backup frequency: ${SignalStore.backup.backupFrequency.name}\n")
output.append("Optimize storage: ${SignalStore.backup.optimizeStorage}\n")
output.append("Detected subscription state mismatch: ${SignalStore.backup.subscriptionStateMismatchDetected}\n")
+ output.append("Last verified key time: ${SignalStore.backup.lastVerifyKeyTime}\n")
output.append("\n -- Subscription State\n")
val backupSubscriptionId = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java
index 7da3079eb0..02fd79aea4 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java
@@ -19,6 +19,7 @@ import org.signal.core.util.SetUtil;
import org.signal.core.util.TranslationDetection;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.backup.v2.ui.verify.VerifyBackupKeyActivity;
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.MegaphoneRecord;
@@ -128,6 +129,7 @@ public final class Megaphones {
put(Event.ADD_A_PROFILE_PHOTO, shouldShowAddAProfilePhotoMegaphone(context) ? ALWAYS : NEVER);
put(Event.PNP_LAUNCH, shouldShowPnpLaunchMegaphone() ? ALWAYS : NEVER);
put(Event.TURN_ON_SIGNAL_BACKUPS, shouldShowTurnOnBackupsMegaphone(context) ? ALWAYS : NEVER);
+ put(Event.VERIFY_BACKUP_KEY, new VerifyBackupKeyReminderSchedule());
}};
}
@@ -177,6 +179,8 @@ public final class Megaphones {
return buildUpdatePinAfterAepRegistrationMegaphone();
case TURN_ON_SIGNAL_BACKUPS:
return buildTurnOnSignalBackupsMegaphone();
+ case VERIFY_BACKUP_KEY:
+ return buildVerifyBackupKeyMegaphone();
default:
throw new IllegalArgumentException("Event not handled!");
}
@@ -474,6 +478,28 @@ public final class Megaphones {
.build();
}
+ public static @NonNull Megaphone buildVerifyBackupKeyMegaphone() {
+ Megaphone.Builder builder = new Megaphone.Builder(Event.VERIFY_BACKUP_KEY, Megaphone.Style.BASIC)
+ .setImage(R.drawable.image_signal_backups_key)
+ .setTitle(R.string.VerifyBackupKey__title)
+ .setBody(R.string.VerifyBackupKey__body)
+ .setActionButton(R.string.VerifyBackupKey__verify, (megaphone, controller) -> {
+ Intent intent = VerifyBackupKeyActivity.createIntent(controller.getMegaphoneActivity());
+
+ controller.onMegaphoneNavigationRequested(intent, VerifyBackupKeyActivity.REQUEST_CODE);
+ });
+
+ if (!SignalStore.backup().getHasSnoozedVerified()) {
+ builder.setSecondaryButton(R.string.VerifyBackupKey__not_now, (megaphone, controller) -> {
+ SignalStore.backup().setHasSnoozedVerified(true);
+ controller.onMegaphoneToastRequested(controller.getMegaphoneActivity().getString(R.string.VerifyBackupKey__we_will_ask_again));
+ controller.onMegaphoneSnooze(Event.VERIFY_BACKUP_KEY);
+ });
+ }
+
+ return builder.build();
+ }
+
private static boolean shouldShowOnboardingMegaphone(@NonNull Context context) {
return SignalStore.onboarding().hasOnboarding(context);
}
@@ -599,7 +625,8 @@ public final class Megaphones {
GRANT_FULL_SCREEN_INTENT("grant_full_screen_intent"),
NEW_LINKED_DEVICE("new_linked_device"),
UPDATE_PIN_AFTER_AEP_REGISTRATION("update_pin_after_registration"),
- TURN_ON_SIGNAL_BACKUPS("turn_on_signal_backups");
+ TURN_ON_SIGNAL_BACKUPS("turn_on_signal_backups"),
+ VERIFY_BACKUP_KEY("verify_backup_key");
private final String key;
diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/VerifyBackupKeyReminderSchedule.kt b/app/src/main/java/org/thoughtcrime/securesms/megaphone/VerifyBackupKeyReminderSchedule.kt
new file mode 100644
index 0000000000..8a8a2015f4
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/VerifyBackupKeyReminderSchedule.kt
@@ -0,0 +1,36 @@
+package org.thoughtcrime.securesms.megaphone
+
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+import org.thoughtcrime.securesms.util.RemoteConfig
+import kotlin.time.Duration.Companion.days
+
+/**
+ * Calculates if the verify key megaphone should be shown based on the following rules
+ * - 1 reminder within 14 days of creation, every 6 months after that
+ * - Allow snooze only once, for a week
+ * - Do not show within 1 week of showing the PIN reminder
+ */
+class VerifyBackupKeyReminderSchedule : MegaphoneSchedule {
+
+ override fun shouldDisplay(seenCount: Int, lastSeen: Long, firstVisible: Long, currentTime: Long): Boolean {
+ if (!RemoteConfig.messageBackups) {
+ return false
+ }
+
+ if (!SignalStore.backup.areBackupsEnabled) {
+ return false
+ }
+
+ val lastVerifiedTime = SignalStore.backup.lastVerifyKeyTime
+ val previouslySnoozed = SignalStore.backup.hasSnoozedVerified
+ val isFirstReminder = !SignalStore.backup.hasVerifiedBefore
+
+ val intervalTime = if (isFirstReminder) 14.days.inWholeMilliseconds else 183.days.inWholeMilliseconds
+ val snoozedTime = if (previouslySnoozed) 7.days.inWholeMilliseconds else 0.days.inWholeMilliseconds
+
+ val shouldShowBackupKeyReminder = System.currentTimeMillis() > (lastVerifiedTime + intervalTime + snoozedTime)
+ val hasShownPinReminderRecently = System.currentTimeMillis() < SignalStore.pin.lastReminderTime + 7.days.inWholeMilliseconds
+
+ return shouldShowBackupKeyReminder && !hasShownPinReminderRecently
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 4f074e61bf..19674e5ca8 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -7738,6 +7738,19 @@
Not now
+
+ Verify your backup key
+
+ To help you remember your key, we\'ll periodically ask you for it.
+
+ Verify
+
+ Not now
+
+ Backup key correct. Keep your key safe.
+
+ We will ask you again in a week.
+
Turn on full screen notifications?
@@ -8402,6 +8415,13 @@
Next
+
+ Enter your backup key
+
+ Enter the 64-digit code you recorded when you enabled backups.
+
+ Forgot key?
+
Choose your backup plan