Add verification code requested alert handling.

This commit is contained in:
Cody Henthorne
2026-05-20 12:58:29 -04:00
committed by jeffrey-signal
parent 6722a28f98
commit 3b93edcdaf
13 changed files with 596 additions and 2 deletions
@@ -116,6 +116,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePay
import org.thoughtcrime.securesms.components.snackbars.LocalSnackbarStateConsumerRegistry
import org.thoughtcrime.securesms.components.snackbars.SnackbarHostKey
import org.thoughtcrime.securesms.components.snackbars.SnackbarState
import org.thoughtcrime.securesms.components.verificationrequested.VerificationCodeRequestedBottomSheet
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
import org.thoughtcrime.securesms.conversation.ConversationIntents
@@ -195,6 +196,7 @@ import org.thoughtcrime.securesms.window.AppScaffoldNavigator
import org.thoughtcrime.securesms.window.NavigationType
import org.thoughtcrime.securesms.window.rememberThreePaneScaffoldNavigatorDelegate
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
import kotlin.time.Duration.Companion.minutes
import org.signal.core.ui.R as CoreUiR
class MainActivity :
@@ -357,6 +359,25 @@ class MainActivity :
}
}
}
launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
SignalStore
.account
.verificationCodeRequestedAtMsFlow
.filter { it > 0L }
.collect { requestedAt ->
val notificationThreshold = requestedAt + 10.minutes.inWholeMilliseconds
if (System.currentTimeMillis() < notificationThreshold) {
VerificationCodeRequestedBottomSheet.show(supportFragmentManager, requestedAt)
} else {
Log.i(TAG, "Verification code requested but is older than 10 minutes, not showing sheet")
}
SignalStore.account.verificationCodeRequestedAtMs = 0L
}
}
}
}
supportFragmentManager.setFragmentResultListener(
@@ -86,6 +86,7 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
is AppSettingsRoute.BackupsRoute.Backups -> AppSettingsFragmentDirections.actionDirectToBackupsSettingsFragment().setLaunchCheckoutFlow(appSettingsRoute.launchCheckoutFlow)
AppSettingsRoute.Invite -> AppSettingsFragmentDirections.actionDirectToInviteFragment()
AppSettingsRoute.DataAndStorageRoute.DataAndStorage -> AppSettingsFragmentDirections.actionDirectToStoragePreferenceFragment()
AppSettingsRoute.AccountRoute.Account -> AppSettingsFragmentDirections.actionDirectToAccountSettingsFragment()
else -> error("Unsupported start location: ${appSettingsRoute?.javaClass?.name}")
}
}
@@ -177,6 +178,9 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
@JvmStatic
fun changeNumber(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.ChangeNumberRoute.Start)
@JvmStatic
fun account(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.AccountRoute.Account)
@JvmStatic
fun subscriptions(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.DonationsRoute.Donations(directToCheckoutType = InAppPaymentType.RECURRING_DONATION))
@@ -0,0 +1,195 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.verificationrequested
import android.os.Bundle
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentManager
import org.signal.core.ui.BottomSheetUtil
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.DateUtils
import java.util.Locale
/**
* Sheet shown when the server has pushed a notification telling us a verification code was
* requested for the user's account.
*/
class VerificationCodeRequestedBottomSheet : ComposeBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 1f
companion object {
private const val ARG_REQUESTED_AT = "requested_at"
@JvmStatic
fun show(fragmentManager: FragmentManager, requestedAtMs: Long) {
VerificationCodeRequestedBottomSheet().apply {
arguments = Bundle().apply { putLong(ARG_REQUESTED_AT, requestedAtMs) }
}.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
@Composable
override fun SheetContent() {
val context = LocalContext.current
val resources = LocalResources.current
val requestedAt = requireArguments().getLong(ARG_REQUESTED_AT)
val formattedTime = remember(requestedAt) {
val time = DateUtils.getOnlyTimeString(context, requestedAt)
val day = DateUtils.getDayPrecisionTimeString(context, Locale.getDefault(), requestedAt)
resources.getString(R.string.VerificationCodeRequestedBottomSheet__time_with_day, time, day)
}
val nestedScrollInterop = rememberNestedScrollInteropConnection()
val scrollModifier = Modifier.nestedScroll(nestedScrollInterop)
VerificationCodeRequestedContent(
formattedTime = formattedTime,
onSafetyTipsClicked = {
val fragmentManager = parentFragmentManager
dismissAllowingStateLoss()
VerificationCodeRequestedSafetyTipsBottomSheet.show(fragmentManager)
},
onOkClicked = { dismissAllowingStateLoss() },
modifier = scrollModifier
)
}
}
@Composable
private fun VerificationCodeRequestedContent(
formattedTime: String,
onSafetyTipsClicked: () -> Unit,
onOkClicked: () -> Unit,
modifier: Modifier = Modifier
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier.fillMaxWidth()
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth()
) {
BottomSheets.Handle()
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.weight(weight = 1f, fill = false)
.verticalScroll(state = scrollState)
.padding(horizontal = 36.dp)
.padding(bottom = 36.dp)
) {
Spacer(modifier = Modifier.height(26.dp))
Image(
painter = painterResource(id = R.drawable.verificationcode_alert_96),
contentDescription = null,
modifier = Modifier.size(96.dp)
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(id = R.string.VerificationCodeRequestedBottomSheet__title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = formattedTime,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(20.dp))
Text(
text = stringResource(id = R.string.VerificationCodeRequestedBottomSheet__body_1),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(id = R.string.VerificationCodeRequestedBottomSheet__body_2),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(32.dp))
Buttons.LargeTonal(
onClick = onSafetyTipsClicked,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
) {
Text(text = stringResource(id = R.string.VerificationCodeRequestedBottomSheet__safety_tips))
}
Spacer(modifier = Modifier.height(8.dp))
Buttons.LargeTonal(
onClick = onOkClicked,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
) {
Text(text = stringResource(id = R.string.VerificationCodeRequestedBottomSheet__ok))
}
}
}
}
@DayNightPreviews
@Composable
private fun VerificationCodeRequestedContentPreview() {
Previews.BottomSheetContentPreview {
VerificationCodeRequestedContent(
formattedTime = "3:25 PM Today",
onSafetyTipsClicked = {},
onOkClicked = {}
)
}
}
@@ -0,0 +1,182 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.verificationrequested
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentManager
import org.signal.core.ui.BottomSheetUtil
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
/**
* Sheet showing safety tips related to a verification code alert.
*/
class VerificationCodeRequestedSafetyTipsBottomSheet : ComposeBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 1f
companion object {
@JvmStatic
fun show(fragmentManager: FragmentManager) {
VerificationCodeRequestedSafetyTipsBottomSheet().show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
@Composable
override fun SheetContent() {
val nestedScrollInterop = rememberNestedScrollInteropConnection()
val scrollModifier = Modifier.nestedScroll(nestedScrollInterop)
SafetyTipsContent(
onOpenAccountSettings = {
startActivity(AppSettingsActivity.account(requireContext()))
dismissAllowingStateLoss()
},
modifier = scrollModifier
)
}
}
@Composable
private fun SafetyTipsContent(
onOpenAccountSettings: () -> Unit,
modifier: Modifier = Modifier
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier.fillMaxWidth()
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth()
) {
BottomSheets.Handle()
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.weight(weight = 1f, fill = false)
.verticalScroll(state = scrollState)
.padding(horizontal = 36.dp)
.padding(bottom = 36.dp)
) {
Spacer(modifier = Modifier.height(26.dp))
Text(
text = stringResource(id = R.string.SafetyTipsBottomSheet__title),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(36.dp))
SafetyTipRow(
iconRes = R.drawable.safetytip_48_message,
titleRes = R.string.SafetyTipsBottomSheet__tip_1_title,
bodyRes = R.string.SafetyTipsBottomSheet__tip_1_body
)
Spacer(modifier = Modifier.height(40.dp))
SafetyTipRow(
iconRes = R.drawable.safetytip_48_pin,
titleRes = R.string.SafetyTipsBottomSheet__tip_2_title,
bodyRes = R.string.SafetyTipsBottomSheet__tip_2_body
)
Spacer(modifier = Modifier.height(40.dp))
SafetyTipRow(
iconRes = R.drawable.safetytip_48_lock,
titleRes = R.string.SafetyTipsBottomSheet__tip_3_title,
bodyRes = R.string.SafetyTipsBottomSheet__tip_3_body
)
Spacer(modifier = Modifier.height(40.dp))
Buttons.LargeTonal(
onClick = onOpenAccountSettings,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
) {
Text(text = stringResource(id = R.string.SafetyTipsBottomSheet__open_account_settings))
}
}
}
}
@Composable
private fun SafetyTipRow(
iconRes: Int,
titleRes: Int,
bodyRes: Int
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(24.dp)
) {
Image(
painter = painterResource(id = iconRes),
contentDescription = null,
modifier = Modifier.size(48.dp)
)
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(id = titleRes),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(id = bodyRes),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@DayNightPreviews
@Composable
private fun SafetyTipsContentPreview() {
Previews.BottomSheetContentPreview {
SafetyTipsContent(onOpenAccountSettings = {})
}
}
@@ -35,13 +35,16 @@ public class FcmReceiveService extends FirebaseMessagingService {
remoteMessage.getOriginalPriority(),
NetworkUtil.getNetworkStatus(this)));
String registrationChallenge = remoteMessage.getData().get("challenge");
String rateLimitChallenge = remoteMessage.getData().get("rateLimitChallenge");
String registrationChallenge = remoteMessage.getData().get("challenge");
String rateLimitChallenge = remoteMessage.getData().get("rateLimitChallenge");
String verificationCodeRequest = remoteMessage.getData().get("verificationCodeRequested");
if (registrationChallenge != null) {
handleRegistrationPushChallenge(registrationChallenge);
} else if (rateLimitChallenge != null) {
handleRateLimitPushChallenge(rateLimitChallenge);
} else if (verificationCodeRequest != null && SignalStore.account().isPrimaryDevice()) {
handleVerificationCodeRequested(verificationCodeRequest, remoteMessage.getSentTime());
} else {
handleReceivedNotification(AppDependencies.getApplication(), remoteMessage);
}
@@ -102,4 +105,20 @@ public class FcmReceiveService extends FirebaseMessagingService {
Log.d(TAG, "Got a rate limit push challenge.");
AppDependencies.getJobManager().add(new SubmitRateLimitPushChallengeJob(challenge));
}
private static void handleVerificationCodeRequested(String verificationCodeRequestJson, long sentTime) {
Log.i(TAG, "Got a verification code requested push.");
VerificationCodeRequestedPush verificationRequestedPush = VerificationCodeRequestedPush.fromJson(verificationCodeRequestJson);
long requestedAt;
if (verificationRequestedPush != null && verificationRequestedPush.getTimestamp() != null) {
requestedAt = verificationRequestedPush.getTimestamp();
} else {
Log.w(TAG, "Unable to parse requested at timestamp from server, using sent time instead");
requestedAt = sentTime;
}
SignalStore.account().setVerificationCodeRequestedAtMs(requestedAt);
}
}
@@ -0,0 +1,30 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.gcm
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.signal.core.util.logging.Log
@Serializable
data class VerificationCodeRequestedPush(val timestamp: Long?) {
companion object {
private val TAG = Log.tag(VerificationCodeRequestedPush::class)
private val json = Json { ignoreUnknownKeys = true }
@JvmStatic
fun fromJson(jsonString: String): VerificationCodeRequestedPush? {
return try {
json.decodeFromString(jsonString)
} catch (e: Throwable) {
Log.w(TAG, "Unable to parse verification code request", e)
null
}
}
}
}
@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import android.preference.PreferenceManager
import kotlinx.coroutines.flow.Flow
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.ServiceId.PNI
@@ -86,6 +87,8 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context)
private const val KEY_HAS_LINKED_DEVICES = "account.has_linked_devices"
private const val KEY_HAS_INACTIVE_PRIMARY_DEVICE_ALERT = "account.has_inactive_primary_device_alert"
private const val KEY_VERIFICATION_CODE_REQUESTED_AT = "account.verification_code_requested_at"
private const val KEY_ACCOUNT_ENTROPY_POOL = "account.account_entropy_pool"
private const val KEY_RESTORED_ACCOUNT_ENTROPY_KEY = "account.restored_account_entropy_pool"
private const val KEY_RESTORED_ACCOUNT_ENTROPY_KEY_FROM_PRIMARY = "account.restore_account_entropy_pool_primary"
@@ -562,6 +565,11 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context)
@get:JvmName("isMultiDevice")
var isMultiDevice by booleanValue(KEY_HAS_LINKED_DEVICES, false)
/** Server has indicated a verification code was requested for the account at this timestamp (ms since epoch) */
private val verificationCodeRequestedAtMsValue = longValue(KEY_VERIFICATION_CODE_REQUESTED_AT, 0)
var verificationCodeRequestedAtMs: Long by verificationCodeRequestedAtMsValue
val verificationCodeRequestedAtMsFlow: Flow<Long> by lazy { verificationCodeRequestedAtMsValue.toFlow() }
/** Do not alter. If you need to migrate more stuff, create a new method. */
private fun migrateFromSharedPrefsV1(context: Context) {
Log.i(TAG, "[V1] Migrating account values from shared prefs.")