Add verify key megaphone.

This commit is contained in:
Michelle Tang
2025-07-10 13:22:30 -04:00
committed by Jeffrey Starke
parent c6afa17330
commit 359f473b59
13 changed files with 605 additions and 148 deletions

View File

@@ -1137,6 +1137,11 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<activity android:name=".backup.v2.ui.verify.VerifyBackupKeyActivity"
android:exported="false"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Signal.DayNight.NoActionBar" />
<activity android:name=".groups.ui.incommon.GroupsInCommonActivity"
android:exported="false"
android:theme="@style/Signal.DayNight.NoActionBar" />

View File

@@ -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() {

View File

@@ -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)
)
}
}
}
}
}

View File

@@ -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()

View File

@@ -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()
}
)
}
}

View File

@@ -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) }
)
}
}

View File

@@ -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<BackupKeyDisplayUiState> = _uiState
override fun updateBackupKeySaveState(newState: BackupKeySaveState?) {
_uiState.update { it.copy(keySaveState = newState) }
}
}
data class BackupKeyDisplayUiState(
val keySaveState: BackupKeySaveState? = null
)

View File

@@ -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<String>
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()
)
}
}

View File

@@ -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.

View File

@@ -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)

View File

@@ -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;

View File

@@ -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
}
}

View File

@@ -7738,6 +7738,19 @@
<!-- Button of a megaphone shown at the bottom of the chat list to prompt the user to enable message backups that will simply dismiss the megaphone -->
<string name="TurnOnSignalBackups__not_now">Not now</string>
<!-- Title of a megaphone shown to prompt the user to verify their backup key -->
<string name="VerifyBackupKey__title">Verify your backup key</string>
<!-- Body of a megaphone shown to prompt the user to verify their backup key -->
<string name="VerifyBackupKey__body">To help you remember your key, we\'ll periodically ask you for it.</string>
<!-- Button of a megaphone that will take users to the verify backup key screen -->
<string name="VerifyBackupKey__verify">Verify</string>
<!-- Button of a megaphone that will snooze the reminder to verify their backup key -->
<string name="VerifyBackupKey__not_now">Not now</string>
<!-- Snackbar text shown when the backup key entered is correct. -->
<string name="VerifyBackupKey__backup_key_correct">Backup key correct. Keep your key safe.</string>
<!-- Snackbar text shown when the user chooses to snooze the backup key reminder. -->
<string name="VerifyBackupKey__we_will_ask_again">We will ask you again in a week.</string>
<!-- Title of a megaphone shown at the bottom of the chat list when a user has disable the system setting for showing full screen notifications used showing incoming calls -->
<string name="GrantFullScreenIntentPermission_megaphone_title">Turn on full screen notifications?</string>
<!-- Body of a megaphone shown at the bottom of the chat list when a user has disable the system setting for showing full screen notifications used showing incoming calls -->
@@ -8402,6 +8415,13 @@
<!-- Confirm your key button text to move onto next screen in flow -->
<string name="MessageBackupsKeyVerifyScreen__next">Next</string>
<!-- Verify your key title -->
<string name="VerifyBackupPinScreen__enter_your_backup_key">Enter your backup key</string>
<!-- Verify your key subtitle -->
<string name="VerifyBackupPinScreen__enter_the_backup_key_that_you_recorded">Enter the 64-digit code you recorded when you enabled backups.</string>
<!-- Button text when you forget your backup key -->
<string name="VerifyBackupPinScreen__forgot_key">Forgot key?</string>
<!-- MessagesBackupsTypeSelectionScreen -->
<!-- Screen headline -->
<string name="MessagesBackupsTypeSelectionScreen__choose_your_backup_plan">Choose your backup plan</string>