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