Add a megaphone for confirming two-factor methods.

This commit is contained in:
Greyson Parrelli 2026-10-20 16:01:47 -04:00 • committed by Alex Hart
commit 7abc060dc1
35 changed files with 2120 additions and 4 deletions

View file

@ -929,6 +929,12 @@
android:theme="@style/Signal.DayNight.NoActionBar"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".components.settings.app.account.twofactor.verify.TwoFactorVerifyActivity"
android:exported="false"
android:theme="@style/Signal.DayNight.NoActionBar"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".groups.ui.incommon.GroupsInCommonActivity"
android:exported="false"

View file

@ -75,6 +75,7 @@ import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.appsettings.twofactorverify.TwoFactorVerifyMethod
import org.signal.core.ui.BottomSheetUtil
import org.signal.core.ui.NavigationType
import org.signal.core.ui.compose.Snackbars
@ -115,6 +116,7 @@ import org.thoughtcrime.securesms.components.compose.ConnectivityWarningBottomSh
import org.thoughtcrime.securesms.components.compose.DeviceSpecificNotificationBottomSheet
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity.Companion.manageSubscriptions
import org.thoughtcrime.securesms.components.settings.app.account.twofactor.verify.TwoFactorVerifyActivity
import org.thoughtcrime.securesms.components.settings.app.notifications.manual.NotificationProfileSelectionFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayRepository
@ -775,6 +777,26 @@ class MainActivity :
if (requestCode == AppSettingsActivity.REQUEST_CODE_UPGRADE_LOCAL_BACKUPS) {
mainNavigationViewModel.onEvent(MainNavigationEvents.MegaphoneSnoozed(Megaphones.Event.USE_NEW_ON_DEVICE_BACKUPS))
}
if (resultCode == RESULT_OK && requestCode == TwoFactorVerifyActivity.REQUEST_CODE) {
val message = when (TwoFactorVerifyActivity.getVerifiedMethod(data)) {
TwoFactorVerifyMethod.AUTHENTICATOR_APP -> getString(R.string.TwoFactorReminderMegaphone__thanks_for_confirming_your_authenticator_app)
TwoFactorVerifyMethod.PASSKEY -> getString(R.string.TwoFactorReminderMegaphone__thanks_for_confirming_your_passkey)
null -> null
}
if (message != null) {
mainNavigationViewModel.snackbarRegistry.emit(
SnackbarState(
message = message,
duration = Snackbars.Duration.SHORT,
hostKey = MainSnackbarHostKey.MainChrome
)
)
}
mainNavigationViewModel.onEvent(MainNavigationEvents.MegaphoneSnoozed(Megaphones.Event.TWO_FACTOR_REMINDER))
}
}
override fun onFirstRender() {

View file

@ -11,6 +11,7 @@ import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
import org.thoughtcrime.securesms.megaphone.TwoFactorReminderSchedule
import org.thoughtcrime.securesms.pin.SvrRepository
import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.kbs.PinHashUtil
@ -85,4 +86,9 @@ class AccountSettingsRepository(
fun masterKey(): MasterKey {
return masterKeyProvider()
}
/** Lets the two-factor reminder know whether there's anything to remind the user about. */
fun setHasTwoFactorMethods(hasMethods: Boolean) {
TwoFactorReminderSchedule.onMethodsObserved(hasMethods)
}
}

View file

@ -267,7 +267,10 @@ class AccountSettingsViewModel(
private suspend fun refreshTwoFactorMethods() {
val (methods, loadState) = when (val result = twoFactorMethodService.getMethods(accountRepository.masterKey())) {
is RequestResult.Success -> result.result.map { it.toTwoFactorMethod() }.sortedBy { it.kind.sortRank() } to LoadState.LOADED
is RequestResult.Success -> {
accountRepository.setHasTwoFactorMethods(result.result.isNotEmpty())
result.result.map { it.toTwoFactorMethod() }.sortedBy { it.kind.sortRank() } to LoadState.LOADED
}
is RequestResult.RetryableNetworkError, is RequestResult.ApplicationError -> {
Log.w(TAG, "Couldn't reach the service to list the account's second factors.")
emptyList<TwoFactorMethod>() to LoadState.NETWORK_FAILURE

View file

@ -0,0 +1,113 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.account.twofactor.verify
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import org.signal.appsettings.twofactorverify.PasskeyAssertionParameters
import org.signal.appsettings.twofactorverify.TwoFactorVerifyAction
import org.signal.appsettings.twofactorverify.TwoFactorVerifyEvent
import org.signal.appsettings.twofactorverify.TwoFactorVerifyMethod
import org.signal.appsettings.twofactorverify.TwoFactorVerifyScreen
import org.signal.core.ui.compose.CollectActions
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.ui.util.ViewUtil
import org.signal.core.ui.viewModel
import org.signal.core.util.logging.Log
import org.signal.passwordmanager.PasskeyAssertionResult
import org.signal.passwordmanager.SignalPasskeyManager
import org.thoughtcrime.securesms.PassphraseRequiredActivity
/**
* Hosts [TwoFactorVerifyScreen] for the two-factor reminder megaphone. Finishes with [RESULT_OK] and the verified
* method in its result once the user proves they still hold one, and with [RESULT_CANCELED] otherwise.
*/
class TwoFactorVerifyActivity : PassphraseRequiredActivity() {
companion object {
private val TAG = Log.tag(TwoFactorVerifyActivity::class)
const val REQUEST_CODE = 1206
private const val EXTRA_VERIFIED_METHOD = "verified_method"
@JvmStatic
fun createIntent(context: Context): Intent {
return Intent(context, TwoFactorVerifyActivity::class.java)
}
/** The method the user verified, read off a [RESULT_OK] result, or null when the result doesn't name one. */
fun getVerifiedMethod(data: Intent?): TwoFactorVerifyMethod? {
val name = data?.getStringExtra(EXTRA_VERIFIED_METHOD) ?: return null
return TwoFactorVerifyMethod.entries.firstOrNull { it.name == name }
}
}
private val viewModel by viewModel {
TwoFactorVerifyViewModel(arePasskeysSupported = SignalPasskeyManager.isSupported(this))
}
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
super.onCreate(savedInstanceState, ready)
setContent {
val state by viewModel.state.collectAsStateWithLifecycle()
CollectActions(viewModel.actions) { action -> handleAction(action) }
SignalTheme {
TwoFactorVerifyScreen(
state = state,
onEvent = viewModel::onEvent
)
}
}
}
private fun handleAction(action: TwoFactorVerifyAction) {
when (action) {
is TwoFactorVerifyAction.AuthenticateWithPasskey -> {
assertPasskey(action.parameters)
}
is TwoFactorVerifyAction.Verified -> {
setResult(RESULT_OK, Intent().putExtra(EXTRA_VERIFIED_METHOD, action.method.name))
finish()
}
TwoFactorVerifyAction.Close -> {
ViewUtil.hideKeyboard(this, window.decorView)
finish()
}
}
}
/** Runs the middle leg of the passkey ceremony, which requires an activity context. */
private fun assertPasskey(parameters: PasskeyAssertionParameters) {
lifecycleScope.launch {
val result = try {
SignalPasskeyManager.getPasskeyAssertion(
activityContext = this@TwoFactorVerifyActivity,
rpId = parameters.relyingPartyId,
challenge = parameters.challenge,
timeoutSeconds = parameters.timeoutSeconds,
allowedCredentialIds = parameters.allowedCredentialIds
)
} catch (e: CancellationException) {
Log.w(TAG, "The passkey ceremony was canceled before it could finish.", e)
viewModel.onEvent(TwoFactorVerifyEvent.PasskeyCeremonyCompleted(PasskeyAssertionResult.UserCanceled))
throw e
}
viewModel.onEvent(TwoFactorVerifyEvent.PasskeyCeremonyCompleted(result))
}
}
}

View file

@ -0,0 +1,126 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.account.twofactor.verify
import org.signal.appsettings.twofactorverify.PasskeyAssertionParameters
import org.signal.core.util.logging.Log
import org.signal.libsignal.net.MfaNotVerifiedException
import org.signal.libsignal.net.MfaVerificationCredential
import org.signal.libsignal.net.RequestResult
import org.signal.network.api.AccountApiV2
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.megaphone.TwoFactorReminderSchedule
import org.thoughtcrime.securesms.net.SignalNetwork
/**
* The network and storage behind [TwoFactorVerifyViewModel]: the service's two-phase verification check, plus the
* bookkeeping that decides when the reminder comes back.
*/
class TwoFactorVerifyRepository(
private val api: AccountApiV2 = SignalNetwork.accountApiV2,
private val relyingPartyId: String = BuildConfig.WEBAUTHN_RP_ID
) {
companion object {
private val TAG = Log.tag(TwoFactorVerifyRepository::class)
}
/**
* Asks the service which kinds of method the account has. The passkey challenge this returns is only good for a
* little while, so call again right before running a ceremony rather than holding onto an old one.
*/
suspend fun startVerification(): StartResult {
return when (val result = api.startMfaVerification()) {
is RequestResult.Success -> {
val response = result.result
StartResult.Success(
hasAuthenticatorApp = response.hasTotp,
passkey = response.webauthnParams?.let { params ->
PasskeyAssertionParameters(
relyingPartyId = relyingPartyId,
challenge = params.challenge,
timeoutSeconds = params.timeoutSeconds.toLong(),
allowedCredentialIds = params.allowedCredentialIds
)
}
)
}
is RequestResult.NonSuccess -> {
error("Code branch is unreachable")
}
is RequestResult.RetryableNetworkError -> {
Log.w(TAG, "Couldn't start the verification check.", result.networkError)
StartResult.NetworkFailure
}
is RequestResult.ApplicationError -> {
Log.w(TAG, "Couldn't start the verification check.", result.cause)
StartResult.NetworkFailure
}
}
}
/** Checks a code from the user's authenticator app against the account's keys. */
suspend fun verifyCode(code: String): VerifyResult {
val oneTimePassword = code.toIntOrNull() ?: return VerifyResult.NotVerified
return finishVerification(MfaVerificationCredential.Totp(oneTimePassword))
}
/** Checks a completed passkey assertion against the account's keys. */
suspend fun verifyPasskey(responseJson: String): VerifyResult {
return finishVerification(MfaVerificationCredential.WebAuthn(responseJson))
}
/** Records that the user proved they still hold a method, which pushes the next reminder out. */
fun markVerified() {
TwoFactorReminderSchedule.onVerified()
}
/** Records that the account turned out to have no methods after all, so the reminder stops showing. */
fun markNoMethods() {
TwoFactorReminderSchedule.onMethodsObserved(false)
}
private suspend fun finishVerification(credential: MfaVerificationCredential): VerifyResult {
return when (val result = api.finishMfaVerification(credential)) {
is RequestResult.Success -> {
VerifyResult.Success
}
is RequestResult.NonSuccess -> {
when (result.error) {
is MfaNotVerifiedException -> {
VerifyResult.NotVerified
}
}
}
is RequestResult.RetryableNetworkError -> {
Log.w(TAG, "Couldn't finish the verification check.", result.networkError)
VerifyResult.NetworkFailure
}
is RequestResult.ApplicationError -> {
Log.w(TAG, "Couldn't finish the verification check.", result.cause)
VerifyResult.NetworkFailure
}
}
}
sealed interface StartResult {
/** Which methods the account has. Both can be absent when it has none this build understands. */
data class Success(val hasAuthenticatorApp: Boolean, val passkey: PasskeyAssertionParameters?) : StartResult
data object NetworkFailure : StartResult
}
sealed interface VerifyResult {
data object Success : VerifyResult
/** The service couldn't verify the credential, whether a wrong code, an expired challenge, or a bad assertion. */
data object NotVerified : VerifyResult
data object NetworkFailure : VerifyResult
}
}

View file

@ -0,0 +1,246 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.account.twofactor.verify
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.signal.appsettings.twofactorverify.TwoFactorVerifyAction
import org.signal.appsettings.twofactorverify.TwoFactorVerifyEvent
import org.signal.appsettings.twofactorverify.TwoFactorVerifyMethod
import org.signal.appsettings.twofactorverify.TwoFactorVerifyState
import org.signal.appsettings.twofactorverify.TwoFactorVerifyState.CodeError
import org.signal.appsettings.twofactorverify.TwoFactorVerifyState.Dialog
import org.signal.appsettings.twofactorverify.TwoFactorVerifyState.Step
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.logging.Log
import org.signal.passwordmanager.PasskeyAssertionResult
import org.signal.uicomponents.codeentryfield.CodeEntryFieldAction
import org.signal.uicomponents.codeentryfield.CodeEntryFieldEvents
import org.signal.uicomponents.codeentryfield.CodeEntryFieldPresenter
import org.thoughtcrime.securesms.components.settings.app.account.twofactor.verify.TwoFactorVerifyRepository.StartResult
import org.thoughtcrime.securesms.components.settings.app.account.twofactor.verify.TwoFactorVerifyRepository.VerifyResult
/**
* Drives the flow that verifies one of the user's two-factor methods in answer to the reminder megaphone.
*
* @param arePasskeysSupported Whether this device can run a passkey ceremony at all. When it can't, an account's
* passkeys are left out of what's offered, the same as during registration.
*/
class TwoFactorVerifyViewModel(
private val arePasskeysSupported: Boolean,
private val repository: TwoFactorVerifyRepository = TwoFactorVerifyRepository()
) : EventDrivenViewModel<TwoFactorVerifyEvent>(TAG, shouldLogEvents = true) {
companion object {
private val TAG = Log.tag(TwoFactorVerifyViewModel::class)
}
private val _state = MutableStateFlow(TwoFactorVerifyState())
private val _actions = Channel<TwoFactorVerifyAction>(Channel.BUFFERED)
val state: StateFlow<TwoFactorVerifyState> = _state.asStateFlow()
val actions: Flow<TwoFactorVerifyAction> = _actions.receiveAsFlow()
private val codeEntryPresenter = CodeEntryFieldPresenter(viewModelScope)
init {
codeEntryPresenter
.state
.onEach { onEvent(TwoFactorVerifyEvent.CodeEntryStateChanged(it)) }
.launchIn(viewModelScope)
codeEntryPresenter
.actions
.onEach { onEvent(TwoFactorVerifyEvent.CodeEntryAction(it)) }
.launchIn(viewModelScope)
viewModelScope.launch { loadMethods() }
}
override suspend fun processEvent(event: TwoFactorVerifyEvent) {
when (event) {
TwoFactorVerifyEvent.PasskeySelected -> {
applyPasskeySelected()
}
TwoFactorVerifyEvent.AuthenticatorAppSelected -> {
_state.update { it.copy(step = Step.EnterCode(canReturnToMethodChoice = true)) }
}
is TwoFactorVerifyEvent.CodeEntryEvent -> {
val codeEntryEvent = event.event
if (codeEntryEvent is CodeEntryFieldEvents.CodeChanged && codeEntryEvent.code.isNotEmpty()) {
_state.update { it.copy(codeError = CodeError.None) }
}
codeEntryPresenter.onEvent(codeEntryEvent)
}
is TwoFactorVerifyEvent.CodeEntryStateChanged -> {
_state.update { it.copy(codeEntry = event.codeEntryState) }
}
is TwoFactorVerifyEvent.CodeEntryAction -> {
when (val action = event.action) {
is CodeEntryFieldAction.CodeEntered -> {
verifyCode(action.code)
}
}
}
is TwoFactorVerifyEvent.PasskeyCeremonyCompleted -> {
applyPasskeyCeremonyCompleted(event.result)
}
TwoFactorVerifyEvent.CancelClicked -> {
_actions.send(TwoFactorVerifyAction.Close)
}
TwoFactorVerifyEvent.BackClicked -> {
applyBackClicked()
}
TwoFactorVerifyEvent.DialogDismissed -> {
applyDialogDismissed()
}
}
}
/**
* Finds out what the account has and picks the first screen accordingly. An account with nothing but authenticator
* apps has no choice to make, so it goes straight to code entry.
*/
private suspend fun loadMethods() {
when (val result = repository.startVerification()) {
is StartResult.Success -> {
val passkeyOffered = arePasskeysSupported && result.passkey != null
when {
passkeyOffered -> {
_state.update { it.copy(step = Step.ChooseMethod(hasAuthenticatorApp = result.hasAuthenticatorApp)) }
}
result.hasAuthenticatorApp -> {
_state.update { it.copy(step = Step.EnterCode(canReturnToMethodChoice = false)) }
}
result.passkey != null -> {
Log.w(TAG, "The account only has methods this device can't verify. passkeysSupported: $arePasskeysSupported")
_actions.send(TwoFactorVerifyAction.Close)
}
else -> {
Log.w(TAG, "The account has no two-factor methods, so there is nothing to verify.")
repository.markNoMethods()
_actions.send(TwoFactorVerifyAction.Close)
}
}
}
StartResult.NetworkFailure -> {
_state.update { it.copy(dialog = Dialog.NetworkFailure) }
}
}
}
/**
* Fetches a fresh challenge before every ceremony, since the one that came with the method list may have expired
* while the user sat on the selection screen.
*/
private suspend fun applyPasskeySelected() {
_state.update { it.copy(dialog = Dialog.PasskeyInProgress) }
when (val result = repository.startVerification()) {
is StartResult.Success -> {
val passkey = result.passkey
if (passkey == null) {
Log.w(TAG, "The account no longer has a passkey to verify.")
_state.update { it.copy(dialog = Dialog.PasskeyFailed) }
return
}
_actions.send(TwoFactorVerifyAction.AuthenticateWithPasskey(passkey))
}
StartResult.NetworkFailure -> {
_state.update { it.copy(dialog = Dialog.PasskeyFailed) }
}
}
}
private suspend fun applyPasskeyCeremonyCompleted(result: PasskeyAssertionResult) {
when (result) {
is PasskeyAssertionResult.Success -> {
verifyPasskey(result.responseJson)
}
PasskeyAssertionResult.UserCanceled -> {
Log.i(TAG, "The user backed out of the passkey provider's sheet.")
_state.update { it.copy(dialog = Dialog.None) }
}
PasskeyAssertionResult.NoCredentialAvailable -> {
Log.w(TAG, "This device holds none of the account's passkeys.")
_state.update { it.copy(dialog = Dialog.PasskeyFailed) }
}
PasskeyAssertionResult.CeremonyFailed -> {
Log.w(TAG, "The assertion ceremony produced nothing usable.")
_state.update { it.copy(dialog = Dialog.PasskeyFailed) }
}
}
}
private suspend fun verifyPasskey(responseJson: String) {
when (repository.verifyPasskey(responseJson)) {
VerifyResult.Success -> {
onVerified(TwoFactorVerifyMethod.PASSKEY)
}
VerifyResult.NotVerified -> {
Log.w(TAG, "The service would not accept the passkey assertion.")
_state.update { it.copy(dialog = Dialog.PasskeyFailed) }
}
VerifyResult.NetworkFailure -> {
_state.update { it.copy(dialog = Dialog.PasskeyFailed) }
}
}
}
private suspend fun verifyCode(code: String) {
_state.update { it.copy(submitting = true, codeError = CodeError.None) }
when (repository.verifyCode(code)) {
VerifyResult.Success -> {
onVerified(TwoFactorVerifyMethod.AUTHENTICATOR_APP)
}
VerifyResult.NotVerified -> {
_state.update { it.copy(submitting = false, codeError = CodeError.IncorrectCode) }
codeEntryPresenter.onEvent(CodeEntryFieldEvents.Clear)
}
VerifyResult.NetworkFailure -> {
_state.update { it.copy(submitting = false, codeError = CodeError.NetworkFailure) }
}
}
}
private suspend fun onVerified(method: TwoFactorVerifyMethod) {
Log.i(TAG, "Verified a two-factor method: $method")
repository.markVerified()
_state.update { it.copy(dialog = Dialog.None) }
_actions.send(TwoFactorVerifyAction.Verified(method))
}
private suspend fun applyBackClicked() {
val step = _state.value.step
if (step is Step.EnterCode && step.canReturnToMethodChoice) {
codeEntryPresenter.onEvent(CodeEntryFieldEvents.Clear)
_state.update { it.copy(step = Step.ChooseMethod(hasAuthenticatorApp = true), codeError = CodeError.None) }
} else {
_actions.send(TwoFactorVerifyAction.Close)
}
}
private suspend fun applyDialogDismissed() {
val dialog = _state.value.dialog
_state.update { it.copy(dialog = Dialog.None) }
if (dialog == Dialog.NetworkFailure) {
_actions.send(TwoFactorVerifyAction.Close)
}
}
}

View file

@ -121,6 +121,7 @@ import org.thoughtcrime.securesms.migrations.SyncDistributionListsMigrationJob;
import org.thoughtcrime.securesms.migrations.SyncKeysMigrationJob;
import org.thoughtcrime.securesms.migrations.SyncStickerPacksMigrationJob;
import org.thoughtcrime.securesms.migrations.TrimByLengthSettingsMigrationJob;
import org.thoughtcrime.securesms.migrations.TwoFactorMethodsMigrationJob;
import org.thoughtcrime.securesms.migrations.UpdateSmsJobsMigrationJob;
import org.thoughtcrime.securesms.migrations.UserNotificationMigrationJob;
import org.thoughtcrime.securesms.migrations.UuidMigrationJob;
@ -401,6 +402,7 @@ public final class JobManagerFactories {
put(SyncKeysMigrationJob.KEY, new SyncKeysMigrationJob.Factory());
put(SyncStickerPacksMigrationJob.KEY, new SyncStickerPacksMigrationJob.Factory());
put(TrimByLengthSettingsMigrationJob.KEY, new TrimByLengthSettingsMigrationJob.Factory());
put(TwoFactorMethodsMigrationJob.KEY, new TwoFactorMethodsMigrationJob.Factory());
put(UpdateSmsJobsMigrationJob.KEY, new UpdateSmsJobsMigrationJob.Factory());
put(UserNotificationMigrationJob.KEY, new UserNotificationMigrationJob.Factory());
put(UuidMigrationJob.KEY, new UuidMigrationJob.Factory());

View file

@ -89,6 +89,7 @@ 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_HAS_TWO_FACTOR_METHODS = "account.has_two_factor_methods"
private const val KEY_NOT_SYNCED_ROTATED_SELF_PROFILE_KEY = "account.not_synced_rotated_self_profile_key"
private const val KEY_VERIFICATION_CODE_REQUESTED_AT = "account.verification_code_requested_at"
@ -578,6 +579,9 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context)
@get:JvmName("hasInactivePrimaryDeviceAlert")
var hasInactivePrimaryDeviceAlert: Boolean by booleanValue(KEY_HAS_INACTIVE_PRIMARY_DEVICE_ALERT, false)
/** Whether the account had at least one two-factor method the last time anything checked with the service. Only phone-numberless accounts have them. */
var hasTwoFactorMethods: Boolean by booleanValue(KEY_HAS_TWO_FACTOR_METHODS, false)
/** The local user's full username (nickname.discriminator), if set. */
var username: String?
get() {

View file

@ -41,6 +41,9 @@ public class UiHintValues extends SignalStoreValues {
private static final String HAS_SEEN_ADMIN_DELETE_EDUCATION_DIALOG = "uihints.has_seen_admin_delete_education_dialog";
private static final String DISMISSED_SET_UP_USERNAME_BANNER = "uihints.dismissed_set_up_username_banner";
private static final String HAS_PROMPTED_OPTIMIZE_DOZE = "uihints.has_prompted_optimize_doze";
private static final String TWO_FACTOR_LAST_VERIFIED_TIME = "uihints.two_factor_last_verified_time";
private static final String HAS_VERIFIED_TWO_FACTOR_BEFORE = "uihints.has_verified_two_factor_before";
private static final String HAS_SNOOZED_TWO_FACTOR_REMINDER = "uihints.has_snoozed_two_factor_reminder";
UiHintValues(@NonNull KeyValueStore store, @NonNull Context context) {
super(store);
@ -285,4 +288,31 @@ public class UiHintValues extends SignalStoreValues {
public void setHasSeenAdminDeleteEducationDialog() {
putBoolean(HAS_SEEN_ADMIN_DELETE_EDUCATION_DIALOG, true);
}
/** When the user last proved they still hold a two-factor method, or when we first learned the account had one. */
public long getTwoFactorLastVerifiedTime() {
return getLong(TWO_FACTOR_LAST_VERIFIED_TIME, 0);
}
public void setTwoFactorLastVerifiedTime(long time) {
putLong(TWO_FACTOR_LAST_VERIFIED_TIME, time);
}
/** Whether the user has ever completed the two-factor reminder's verification, which spaces later reminders out further. */
public boolean hasVerifiedTwoFactorBefore() {
return getBoolean(HAS_VERIFIED_TWO_FACTOR_BEFORE, false);
}
public void setHasVerifiedTwoFactorBefore(boolean verified) {
putBoolean(HAS_VERIFIED_TWO_FACTOR_BEFORE, verified);
}
/** Whether the user has snoozed the current two-factor reminder, which they only get to do once per reminder. */
public boolean hasSnoozedTwoFactorReminder() {
return getBoolean(HAS_SNOOZED_TWO_FACTOR_REMINDER, false);
}
public void setHasSnoozedTwoFactorReminder(boolean snoozed) {
putBoolean(HAS_SNOOZED_TWO_FACTOR_REMINDER, snoozed);
}
}

View file

@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.backup.v2.MessageBackupTier;
import org.thoughtcrime.securesms.backup.v2.ui.BackupUpsellBottomSheet;
import org.thoughtcrime.securesms.backup.v2.ui.verify.VerifyBackupKeyActivity;
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.components.settings.app.account.twofactor.verify.TwoFactorVerifyActivity;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.MegaphoneRecord;
import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord;
@ -133,6 +134,7 @@ public final class Megaphones {
put(Event.BACKUP_MESSAGE_COUNT_UPSELL, shouldShowBackupMessageCountUpsell() ? new BackupUpsellSchedule(records, TimeUnit.DAYS.toMillis(60)) : NEVER);
put(Event.BACKUPS_GENERIC_UPSELL, shouldShowGenericBackupsMegaphone(context) ? new BackupUpsellSchedule(records, TimeUnit.DAYS.toMillis(60)) : NEVER);
put(Event.VERIFY_BACKUP_KEY, new VerifyBackupKeyReminderSchedule());
put(Event.TWO_FACTOR_REMINDER, new TwoFactorReminderSchedule());
put(Event.USE_NEW_ON_DEVICE_BACKUPS, shouldShowUseNewOnDeviceBackupsMegaphone() ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(7)) : NEVER);
// The Great Wall of PIN Reminder -- megaphones below this may not be seen by users who never do reminders
@ -202,6 +204,8 @@ public final class Megaphones {
return buildBackupLowStorageUpsellMegaphone();
case VERIFY_BACKUP_KEY:
return buildVerifyBackupKeyMegaphone();
case TWO_FACTOR_REMINDER:
return buildTwoFactorReminderMegaphone();
case USE_NEW_ON_DEVICE_BACKUPS:
return buildUseNewOnDeviceBackupsMegaphone();
case INACTIVE_PRIMARY:
@ -513,6 +517,27 @@ public final class Megaphones {
return builder.build();
}
public static @NonNull Megaphone buildTwoFactorReminderMegaphone() {
Megaphone.Builder builder = new Megaphone.Builder(Event.TWO_FACTOR_REMINDER, Megaphone.Style.BASIC)
.setImage(R.drawable.megaphone_two_factor)
.setTitle(R.string.TwoFactorReminderMegaphone__verify_your_two_factor_method)
.setBody(R.string.TwoFactorReminderMegaphone__well_occasionally_ask_you_to_verify)
.setActionButton(R.string.TwoFactorReminderMegaphone__verify, (megaphone, controller) -> {
Intent intent = TwoFactorVerifyActivity.createIntent(controller.getMegaphoneActivity());
controller.onMegaphoneNavigationRequested(intent, TwoFactorVerifyActivity.REQUEST_CODE);
});
if (!SignalStore.uiHints().hasSnoozedTwoFactorReminder()) {
builder.setSecondaryButton(R.string.TwoFactorReminderMegaphone__not_now, (megaphone, controller) -> {
SignalStore.uiHints().setHasSnoozedTwoFactorReminder(true);
controller.onMegaphoneSnooze(Event.TWO_FACTOR_REMINDER);
});
}
return builder.build();
}
public static @NonNull Megaphone buildUseNewOnDeviceBackupsMegaphone() {
return new Megaphone.Builder(Event.USE_NEW_ON_DEVICE_BACKUPS, Megaphone.Style.FULLSCREEN)
.setOnVisibleListener((megaphone, controller) -> {
@ -776,7 +801,8 @@ public final class Megaphones {
BACKUP_LOW_STORAGE_UPSELL("backup_storage_upsell"),
VERIFY_BACKUP_KEY("verify_backup_key"),
USE_NEW_ON_DEVICE_BACKUPS("use_new_on_device_backups"),
INACTIVE_PRIMARY("inactive_primary");
INACTIVE_PRIMARY("inactive_primary"),
TWO_FACTOR_REMINDER("two_factor_reminder");
private final String key;

View file

@ -0,0 +1,63 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.megaphone
import org.thoughtcrime.securesms.keyvalue.SignalStore
import kotlin.time.Duration.Companion.days
/**
* Decides when to remind a phone-numberless user to verify one of their two-factor methods:
* - Only when the account is known to have a method
* - First 14 days after the account was first seen to have one, then every 6 months after each verification
* - Snoozable only once per reminder, for a week
*
*/
class TwoFactorReminderSchedule : MegaphoneSchedule {
companion object {
/**
* Records what the service says the account has. Methods are verified when they're added, so learning of them
* for the first time starts the clock on the first reminder.
*/
@JvmStatic
fun onMethodsObserved(hasMethods: Boolean) {
if (hasMethods == SignalStore.account.hasTwoFactorMethods) {
return
}
SignalStore.account.hasTwoFactorMethods = hasMethods
SignalStore.uiHints.twoFactorLastVerifiedTime = System.currentTimeMillis()
SignalStore.uiHints.setHasVerifiedTwoFactorBefore(false)
SignalStore.uiHints.setHasSnoozedTwoFactorReminder(false)
}
/** Records that the user just verified one of their methods through the reminder. */
@JvmStatic
fun onVerified() {
SignalStore.account.hasTwoFactorMethods = true
SignalStore.uiHints.twoFactorLastVerifiedTime = System.currentTimeMillis()
SignalStore.uiHints.setHasVerifiedTwoFactorBefore(true)
SignalStore.uiHints.setHasSnoozedTwoFactorReminder(false)
}
}
override fun shouldDisplay(seenCount: Int, lastSeen: Long, firstVisible: Long, currentTime: Long): Boolean {
if (!SignalStore.account.isPhoneNumberless) {
return false
}
if (!SignalStore.account.hasTwoFactorMethods) {
return false
}
val interval = if (SignalStore.uiHints.hasVerifiedTwoFactorBefore()) 183.days else 14.days
val intervalHasPassed = currentTime > SignalStore.uiHints.twoFactorLastVerifiedTime + interval.inWholeMilliseconds
val snoozeHasExpired = !SignalStore.uiHints.hasSnoozedTwoFactorReminder() || currentTime > lastSeen + 7.days.inWholeMilliseconds
return intervalHasPassed && snoozeHasExpired
}
}

View file

@ -218,9 +218,10 @@ public class ApplicationMigrations {
static final int SVR2_ENCLAVE_UPDATE_7 = 173;
static final int DISABLE_UNREAD_REMINDER = 174;
static final int MENTIONS_STORAGE_SERVICE = 175;
static final int TWO_FACTOR_METHODS = 176;
}
public static final int CURRENT_VERSION = 175;
public static final int CURRENT_VERSION = 176;
/**
* This *must* be called after the {@link JobManager} has been instantiated, but *before* the call
@ -1019,6 +1020,10 @@ public class ApplicationMigrations {
jobs.put(Version.MENTIONS_STORAGE_SERVICE, new MentionsStorageServiceMigrationJob());
}
if (lastSeenVersion < Version.TWO_FACTOR_METHODS) {
jobs.put(Version.TWO_FACTOR_METHODS, new TwoFactorMethodsMigrationJob());
}
return jobs;
}

View file

@ -0,0 +1,70 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.migrations
import kotlinx.coroutines.runBlocking
import org.signal.core.util.logging.Log
import org.signal.libsignal.net.RequestResult
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.megaphone.TwoFactorReminderSchedule
import org.thoughtcrime.securesms.net.SignalNetwork
import java.io.IOException
/**
* Asks the service whether the account already has two-factor methods, since ones added before the app started
* tracking them would otherwise never trigger the reminder megaphone.
*/
internal class TwoFactorMethodsMigrationJob private constructor(parameters: Parameters) : MigrationJob(parameters) {
companion object {
const val KEY = "TwoFactorMethodsMigrationJob"
private val TAG = Log.tag(TwoFactorMethodsMigrationJob::class)
}
internal constructor() : this(Parameters.Builder().build())
override fun isUiBlocking(): Boolean = false
override fun getFactoryKey(): String = KEY
override fun performMigration() {
if (!SignalStore.account.isRegistered || SignalStore.account.aci == null) {
Log.i(TAG, "Unregistered, skipping.")
return
}
if (!SignalStore.account.isPhoneNumberless) {
Log.i(TAG, "Not a phone-numberless account, skipping.")
return
}
when (val result = runBlocking { SignalNetwork.accountApiV2.listMfaKeys(SignalStore.svr.masterKey) }) {
is RequestResult.Success -> {
Log.i(TAG, "Account has ${result.result.size} two-factor method(s).")
TwoFactorReminderSchedule.onMethodsObserved(result.result.isNotEmpty())
}
is RequestResult.NonSuccess -> {
error("Code branch is unreachable")
}
is RequestResult.RetryableNetworkError -> {
throw result.networkError
}
is RequestResult.ApplicationError -> {
Log.w(TAG, "Couldn't list two-factor methods, skipping.", result.cause)
}
}
}
override fun shouldRetry(e: Exception): Boolean = e is IOException
class Factory : Job.Factory<TwoFactorMethodsMigrationJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): TwoFactorMethodsMigrationJob {
return TwoFactorMethodsMigrationJob(parameters)
}
}
}

View file

@ -85,6 +85,7 @@ import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.Skipped
import org.thoughtcrime.securesms.keyvalue.isDecisionPending
import org.thoughtcrime.securesms.megaphone.TwoFactorReminderSchedule
import org.thoughtcrime.securesms.pin.SvrRepository
import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository
@ -291,6 +292,10 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
SignalStore.uiHints.markHasSetOrSkippedUsernameCreation()
}
override suspend fun markHasTwoFactorMethods() = withContext(Dispatchers.Default) {
TwoFactorReminderSchedule.onMethodsObserved(true)
}
override suspend fun setRestoreDecision(decision: RestoreDecision) = withContext(Dispatchers.Default) {
if (!SignalStore.registration.restoreDecisionState.isDecisionPending) {
return@withContext

View file

@ -0,0 +1,20 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="64dp"
android:height="64dp"
android:viewportWidth="64"
android:viewportHeight="64">
<group
android:translateX="6"
android:translateY="6">
<path
android:fillColor="#FFD2D8FE"
android:pathData="M26 0A26 26 0 1 0 26 52 26 26 0 1 0 26 0z"/>
<path
android:fillColor="#FF505AF5"
android:pathData="M24.9792 31.2436C24.453 30.9067 24.1042 30.317 24.1042 29.6458C24.1042 28.5988 24.953 27.75 26 27.75C27.047 27.75 27.8958 28.5988 27.8958 29.6458C27.8958 30.317 27.547 30.9067 27.0208 31.2436V33.2917C27.0208 33.8555 26.5638 34.3125 26 34.3125C25.4362 34.3125 24.9792 33.8555 24.9792 33.2917V31.2436Z"/>
<path
android:fillColor="#FF505AF5"
android:fillType="evenOdd"
android:pathData="M18.8542 19.875C18.8542 15.9285 22.0535 12.7292 26 12.7292C29.9465 12.7292 33.1458 15.9285 33.1458 19.875V23.0637C33.1984 23.0873 33.2506 23.1122 33.3024 23.1386C34.1531 23.572 34.8447 24.2636 35.2781 25.1143C35.5544 25.6565 35.6669 26.238 35.7198 26.8847C35.7709 27.5101 35.7708 28.2801 35.7708 29.2237V32.6929C35.7708 33.6366 35.7709 34.4065 35.7198 35.0319C35.6669 35.6787 35.5544 36.2602 35.2781 36.8024C34.8447 37.6531 34.1531 38.3447 33.3024 38.7781C32.7602 39.0544 32.1787 39.1669 31.5319 39.2198C30.9065 39.2709 30.1366 39.2708 29.1929 39.2708H22.8071C21.8634 39.2708 21.0935 39.2709 20.4681 39.2198C19.8213 39.1669 19.2398 39.0544 18.6976 38.7781C17.8469 38.3447 17.1553 37.6531 16.7219 36.8024C16.4456 36.2602 16.3331 35.6787 16.2802 35.0319C16.2291 34.4065 16.2292 33.6366 16.2292 32.6929V29.2238C16.2292 28.2801 16.2291 27.5101 16.2802 26.8847C16.3331 26.238 16.4456 25.6565 16.7219 25.1143C17.1553 24.2636 17.8469 23.572 18.6976 23.1386C18.7494 23.1122 18.8016 23.0873 18.8542 23.0637V19.875ZM31.1042 19.875V22.6703C30.5643 22.6458 29.9318 22.6458 29.1929 22.6458H22.8071C22.0682 22.6458 21.4357 22.6458 20.8958 22.6703V19.875C20.8958 17.056 23.181 14.7708 26 14.7708C28.819 14.7708 31.1042 17.056 31.1042 19.875ZM19.6245 24.9577C19.8309 24.8525 20.1138 24.7743 20.6343 24.7318C21.1668 24.6883 21.8531 24.6875 22.85 24.6875H29.15C30.1469 24.6875 30.8332 24.6883 31.3657 24.7318C31.8862 24.7743 32.1691 24.8525 32.3755 24.9577C32.842 25.1954 33.2213 25.5747 33.459 26.0411C33.5641 26.2476 33.6423 26.5305 33.6849 27.051C33.7284 27.5834 33.7292 28.2697 33.7292 29.2667V32.65C33.7292 33.6469 33.7284 34.3332 33.6849 34.8657C33.6423 35.3862 33.5641 35.6691 33.459 35.8755C33.2213 36.342 32.842 36.7213 32.3755 36.959C32.1691 37.0641 31.8862 37.1423 31.3657 37.1849C30.8332 37.2284 30.1469 37.2292 29.15 37.2292H22.85C21.8531 37.2292 21.1668 37.2284 20.6343 37.1849C20.1138 37.1423 19.8309 37.0641 19.6245 36.959C19.158 36.7213 18.7787 36.342 18.541 35.8755C18.4359 35.6691 18.3577 35.3862 18.3151 34.8657C18.2716 34.3332 18.2708 33.6469 18.2708 32.65V29.2667C18.2708 28.2697 18.2716 27.5834 18.3151 27.051C18.3577 26.5305 18.4359 26.2476 18.541 26.0411C18.7787 25.5747 19.158 25.1954 19.6245 24.9577Z"/>
</group>
</vector>

View file

@ -0,0 +1,20 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="64dp"
android:height="64dp"
android:viewportWidth="64"
android:viewportHeight="64">
<group
android:translateX="6"
android:translateY="6">
<path
android:fillColor="#FFE0E5FF"
android:pathData="M26 0A26 26 0 1 0 26 52 26 26 0 1 0 26 0z"/>
<path
android:fillColor="#FF3B45FD"
android:pathData="M24.9792 31.2436C24.453 30.9067 24.1042 30.317 24.1042 29.6458C24.1042 28.5988 24.953 27.75 26 27.75C27.047 27.75 27.8958 28.5988 27.8958 29.6458C27.8958 30.317 27.547 30.9067 27.0208 31.2436V33.2917C27.0208 33.8555 26.5638 34.3125 26 34.3125C25.4362 34.3125 24.9792 33.8555 24.9792 33.2917V31.2436Z"/>
<path
android:fillColor="#FF3B45FD"
android:fillType="evenOdd"
android:pathData="M18.8542 19.875C18.8542 15.9285 22.0535 12.7292 26 12.7292C29.9465 12.7292 33.1458 15.9285 33.1458 19.875V23.0637C33.1984 23.0873 33.2506 23.1122 33.3024 23.1386C34.1531 23.572 34.8447 24.2636 35.2781 25.1143C35.5544 25.6565 35.6669 26.238 35.7198 26.8847C35.7709 27.5101 35.7708 28.2801 35.7708 29.2237V32.6929C35.7708 33.6366 35.7709 34.4065 35.7198 35.0319C35.6669 35.6787 35.5544 36.2602 35.2781 36.8024C34.8447 37.6531 34.1531 38.3447 33.3024 38.7781C32.7602 39.0544 32.1787 39.1669 31.5319 39.2198C30.9065 39.2709 30.1366 39.2708 29.1929 39.2708H22.8071C21.8634 39.2708 21.0935 39.2709 20.4681 39.2198C19.8213 39.1669 19.2398 39.0544 18.6976 38.7781C17.8469 38.3447 17.1553 37.6531 16.7219 36.8024C16.4456 36.2602 16.3331 35.6787 16.2802 35.0319C16.2291 34.4065 16.2292 33.6366 16.2292 32.6929V29.2238C16.2292 28.2801 16.2291 27.5101 16.2802 26.8847C16.3331 26.238 16.4456 25.6565 16.7219 25.1143C17.1553 24.2636 17.8469 23.572 18.6976 23.1386C18.7494 23.1122 18.8016 23.0873 18.8542 23.0637V19.875ZM31.1042 19.875V22.6703C30.5643 22.6458 29.9318 22.6458 29.1929 22.6458H22.8071C22.0682 22.6458 21.4357 22.6458 20.8958 22.6703V19.875C20.8958 17.056 23.181 14.7708 26 14.7708C28.819 14.7708 31.1042 17.056 31.1042 19.875ZM19.6245 24.9577C19.8309 24.8525 20.1138 24.7743 20.6343 24.7318C21.1668 24.6883 21.8531 24.6875 22.85 24.6875H29.15C30.1469 24.6875 30.8332 24.6883 31.3657 24.7318C31.8862 24.7743 32.1691 24.8525 32.3755 24.9577C32.842 25.1954 33.2213 25.5747 33.459 26.0411C33.5641 26.2476 33.6423 26.5305 33.6849 27.051C33.7284 27.5834 33.7292 28.2697 33.7292 29.2667V32.65C33.7292 33.6469 33.7284 34.3332 33.6849 34.8657C33.6423 35.3862 33.5641 35.6691 33.459 35.8755C33.2213 36.342 32.842 36.7213 32.3755 36.959C32.1691 37.0641 31.8862 37.1423 31.3657 37.1849C30.8332 37.2284 30.1469 37.2292 29.15 37.2292H22.85C21.8531 37.2292 21.1668 37.2284 20.6343 37.1849C20.1138 37.1423 19.8309 37.0641 19.6245 36.959C19.158 36.7213 18.7787 36.342 18.541 35.8755C18.4359 35.6691 18.3577 35.3862 18.3151 34.8657C18.2716 34.3332 18.2708 33.6469 18.2708 32.65V29.2667C18.2708 28.2697 18.2716 27.5834 18.3151 27.051C18.3577 26.5305 18.4359 26.2476 18.541 26.0411C18.7787 25.5747 19.158 25.1954 19.6245 24.9577Z"/>
</group>
</vector>

View file

@ -7321,6 +7321,19 @@
<!-- Snackbar text shown when the user chooses to snooze the recovery key reminder. -->
<string name="VerifyBackupKey__we_will_ask_again">We will ask you again in a week.</string>
<!-- Title of a megaphone shown to prompt the user to verify one of their two-factor authentication methods -->
<string name="TwoFactorReminderMegaphone__verify_your_two_factor_method">Verify your two-factor method</string>
<!-- Body of a megaphone shown to prompt the user to verify one of their two-factor authentication methods -->
<string name="TwoFactorReminderMegaphone__well_occasionally_ask_you_to_verify">We\'ll occasionally ask you to verify your two-factor authentication method to ensure it\'s still set up correctly.</string>
<!-- Button of a megaphone that will take users to the screen where they verify a two-factor method -->
<string name="TwoFactorReminderMegaphone__verify">Verify</string>
<!-- Button of a megaphone that will snooze the reminder to verify a two-factor method -->
<string name="TwoFactorReminderMegaphone__not_now">Not now</string>
<!-- Snackbar text shown after the user verified their authenticator app from the reminder -->
<string name="TwoFactorReminderMegaphone__thanks_for_confirming_your_authenticator_app">Thanks for confirming your authenticator app.</string>
<!-- Snackbar text shown after the user verified their passkey from the reminder -->
<string name="TwoFactorReminderMegaphone__thanks_for_confirming_your_passkey">Thanks for confirming your passkey.</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 -->

View file

@ -0,0 +1,376 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.account.twofactor.verify
import assertk.assertThat
import assertk.assertions.containsExactly
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isTrue
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.signal.appsettings.twofactorverify.PasskeyAssertionParameters
import org.signal.appsettings.twofactorverify.TwoFactorVerifyAction
import org.signal.appsettings.twofactorverify.TwoFactorVerifyEvent
import org.signal.appsettings.twofactorverify.TwoFactorVerifyMethod
import org.signal.appsettings.twofactorverify.TwoFactorVerifyState.CodeError
import org.signal.appsettings.twofactorverify.TwoFactorVerifyState.Dialog
import org.signal.appsettings.twofactorverify.TwoFactorVerifyState.Step
import org.signal.passwordmanager.PasskeyAssertionResult
import org.signal.uicomponents.codeentryfield.CodeEntryFieldEvents
import org.thoughtcrime.securesms.components.settings.app.account.twofactor.verify.TwoFactorVerifyRepository.StartResult
import org.thoughtcrime.securesms.components.settings.app.account.twofactor.verify.TwoFactorVerifyRepository.VerifyResult
@OptIn(ExperimentalCoroutinesApi::class)
class TwoFactorVerifyViewModelTest {
companion object {
private const val CODE = "418372"
private const val ASSERTION_JSON = "{\"type\":\"webauthn.get\"}"
private val PASSKEY = PasskeyAssertionParameters(
relyingPartyId = "login.signal.org",
challenge = byteArrayOf(1, 2, 3),
timeoutSeconds = 60,
allowedCredentialIds = listOf(byteArrayOf(4, 5, 6))
)
private val BOTH_METHODS = StartResult.Success(hasAuthenticatorApp = true, passkey = PASSKEY)
private val AUTHENTICATOR_APP_ONLY = StartResult.Success(hasAuthenticatorApp = true, passkey = null)
private val PASSKEY_ONLY = StartResult.Success(hasAuthenticatorApp = false, passkey = PASSKEY)
private val NO_METHODS = StartResult.Success(hasAuthenticatorApp = false, passkey = null)
}
private val testDispatcher = UnconfinedTestDispatcher()
private val repository = mockk<TwoFactorVerifyRepository>(relaxUnitFun = true)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
coEvery { repository.startVerification() } returns BOTH_METHODS
coEvery { repository.verifyCode(any()) } returns VerifyResult.Success
coEvery { repository.verifyPasskey(any()) } returns VerifyResult.Success
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `an account with both methods starts on the method choice`() = runTest(testDispatcher) {
val viewModel = createViewModel()
assertThat(viewModel.state.value.step).isEqualTo(Step.ChooseMethod(hasAuthenticatorApp = true))
}
@Test
fun `an account with only authenticator apps skips straight to code entry`() = runTest(testDispatcher) {
coEvery { repository.startVerification() } returns AUTHENTICATOR_APP_ONLY
val viewModel = createViewModel()
assertThat(viewModel.state.value.step).isEqualTo(Step.EnterCode(canReturnToMethodChoice = false))
}
@Test
fun `an account with only a passkey still gets the method choice`() = runTest(testDispatcher) {
coEvery { repository.startVerification() } returns PASSKEY_ONLY
val viewModel = createViewModel()
assertThat(viewModel.state.value.step).isEqualTo(Step.ChooseMethod(hasAuthenticatorApp = false))
}
@Test
fun `a passkey is left out when the device cannot run a ceremony`() = runTest(testDispatcher) {
val viewModel = createViewModel(arePasskeysSupported = false)
assertThat(viewModel.state.value.step).isEqualTo(Step.EnterCode(canReturnToMethodChoice = false))
}
@Test
fun `an account with only a passkey closes when the device cannot run a ceremony`() = runTest(testDispatcher) {
coEvery { repository.startVerification() } returns PASSKEY_ONLY
val viewModel = createViewModel(arePasskeysSupported = false)
val actions = collectActions(viewModel.actions)
assertThat(actions).containsExactly(TwoFactorVerifyAction.Close)
verify(exactly = 0) { repository.markNoMethods() }
}
@Test
fun `an account with no methods is recorded and closes`() = runTest(testDispatcher) {
coEvery { repository.startVerification() } returns NO_METHODS
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
verify { repository.markNoMethods() }
assertThat(actions).containsExactly(TwoFactorVerifyAction.Close)
}
@Test
fun `a network failure while loading shows a dialog that closes the flow`() = runTest(testDispatcher) {
coEvery { repository.startVerification() } returns StartResult.NetworkFailure
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.NetworkFailure)
assertThat(actions).isEmpty()
viewModel.onEvent(TwoFactorVerifyEvent.DialogDismissed)
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None)
assertThat(actions).containsExactly(TwoFactorVerifyAction.Close)
}
@Test
fun `AuthenticatorAppSelected moves to code entry with a way back`() = runTest(testDispatcher) {
val viewModel = createViewModel()
viewModel.onEvent(TwoFactorVerifyEvent.AuthenticatorAppSelected)
assertThat(viewModel.state.value.step).isEqualTo(Step.EnterCode(canReturnToMethodChoice = true))
}
@Test
fun `BackClicked returns to the method choice from code entry`() = runTest(testDispatcher) {
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TwoFactorVerifyEvent.AuthenticatorAppSelected)
viewModel.onEvent(TwoFactorVerifyEvent.CodeEntryEvent(CodeEntryFieldEvents.CodeChanged("418")))
viewModel.onEvent(TwoFactorVerifyEvent.BackClicked)
assertThat(viewModel.state.value.step).isEqualTo(Step.ChooseMethod(hasAuthenticatorApp = true))
assertThat(viewModel.state.value.codeEntry.code).isEqualTo("")
assertThat(actions).isEmpty()
}
@Test
fun `BackClicked closes when there is nothing to go back to`() = runTest(testDispatcher) {
coEvery { repository.startVerification() } returns AUTHENTICATOR_APP_ONLY
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TwoFactorVerifyEvent.BackClicked)
assertThat(actions).containsExactly(TwoFactorVerifyAction.Close)
}
@Test
fun `CancelClicked closes the flow`() = runTest(testDispatcher) {
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TwoFactorVerifyEvent.CancelClicked)
assertThat(actions).containsExactly(TwoFactorVerifyAction.Close)
}
@Test
fun `a complete code is checked and a good one finishes the flow`() = runTest(testDispatcher) {
coEvery { repository.startVerification() } returns AUTHENTICATOR_APP_ONLY
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TwoFactorVerifyEvent.CodeEntryEvent(CodeEntryFieldEvents.CodeChanged(CODE)))
coVerify { repository.verifyCode(CODE) }
verify { repository.markVerified() }
assertThat(viewModel.state.value.submitting).isTrue()
assertThat(actions).containsExactly(TwoFactorVerifyAction.Verified(TwoFactorVerifyMethod.AUTHENTICATOR_APP))
}
@Test
fun `a rejected code shows an error and clears the field`() = runTest(testDispatcher) {
coEvery { repository.startVerification() } returns AUTHENTICATOR_APP_ONLY
coEvery { repository.verifyCode(any()) } returns VerifyResult.NotVerified
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TwoFactorVerifyEvent.CodeEntryEvent(CodeEntryFieldEvents.CodeChanged(CODE)))
assertThat(viewModel.state.value.codeError).isEqualTo(CodeError.IncorrectCode)
assertThat(viewModel.state.value.submitting).isFalse()
assertThat(viewModel.state.value.codeEntry.code).isEqualTo("")
assertThat(actions).isEmpty()
verify(exactly = 0) { repository.markVerified() }
}
@Test
fun `a network failure while checking a code shows an error and keeps the code`() = runTest(testDispatcher) {
coEvery { repository.startVerification() } returns AUTHENTICATOR_APP_ONLY
coEvery { repository.verifyCode(any()) } returns VerifyResult.NetworkFailure
val viewModel = createViewModel()
viewModel.onEvent(TwoFactorVerifyEvent.CodeEntryEvent(CodeEntryFieldEvents.CodeChanged(CODE)))
assertThat(viewModel.state.value.codeError).isEqualTo(CodeError.NetworkFailure)
assertThat(viewModel.state.value.codeEntry.code).isEqualTo(CODE)
}
@Test
fun `typing clears the code error`() = runTest(testDispatcher) {
coEvery { repository.startVerification() } returns AUTHENTICATOR_APP_ONLY
coEvery { repository.verifyCode(any()) } returns VerifyResult.NotVerified
val viewModel = createViewModel()
viewModel.onEvent(TwoFactorVerifyEvent.CodeEntryEvent(CodeEntryFieldEvents.CodeChanged(CODE)))
viewModel.onEvent(TwoFactorVerifyEvent.CodeEntryEvent(CodeEntryFieldEvents.CodeChanged("4")))
assertThat(viewModel.state.value.codeError).isEqualTo(CodeError.None)
}
@Test
fun `the field clearing itself after a rejected code keeps the code error`() = runTest(testDispatcher) {
coEvery { repository.startVerification() } returns AUTHENTICATOR_APP_ONLY
coEvery { repository.verifyCode(any()) } returns VerifyResult.NotVerified
val viewModel = createViewModel()
viewModel.onEvent(TwoFactorVerifyEvent.CodeEntryEvent(CodeEntryFieldEvents.CodeChanged(CODE)))
viewModel.onEvent(TwoFactorVerifyEvent.CodeEntryEvent(CodeEntryFieldEvents.CodeChanged("")))
viewModel.onEvent(TwoFactorVerifyEvent.CodeEntryEvent(CodeEntryFieldEvents.OverwriteApplied))
assertThat(viewModel.state.value.codeError).isEqualTo(CodeError.IncorrectCode)
}
@Test
fun `PasskeySelected fetches a fresh challenge and hands it to the host`() = runTest(testDispatcher) {
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TwoFactorVerifyEvent.PasskeySelected)
coVerify(exactly = 2) { repository.startVerification() }
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.PasskeyInProgress)
assertThat(actions).containsExactly(TwoFactorVerifyAction.AuthenticateWithPasskey(PASSKEY))
}
@Test
fun `PasskeySelected fails when the challenge cannot be fetched`() = runTest(testDispatcher) {
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
coEvery { repository.startVerification() } returns StartResult.NetworkFailure
viewModel.onEvent(TwoFactorVerifyEvent.PasskeySelected)
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.PasskeyFailed)
assertThat(actions).isEmpty()
}
@Test
fun `a successful ceremony is checked and finishes the flow`() = runTest(testDispatcher) {
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TwoFactorVerifyEvent.PasskeyCeremonyCompleted(PasskeyAssertionResult.Success(ASSERTION_JSON)))
coVerify { repository.verifyPasskey(ASSERTION_JSON) }
verify { repository.markVerified() }
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None)
assertThat(actions).containsExactly(TwoFactorVerifyAction.Verified(TwoFactorVerifyMethod.PASSKEY))
}
@Test
fun `an assertion the service rejects shows the passkey error`() = runTest(testDispatcher) {
coEvery { repository.verifyPasskey(any()) } returns VerifyResult.NotVerified
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TwoFactorVerifyEvent.PasskeyCeremonyCompleted(PasskeyAssertionResult.Success(ASSERTION_JSON)))
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.PasskeyFailed)
assertThat(actions).isEmpty()
verify(exactly = 0) { repository.markVerified() }
}
@Test
fun `a canceled ceremony just dismisses the progress dialog`() = runTest(testDispatcher) {
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TwoFactorVerifyEvent.PasskeySelected)
viewModel.onEvent(TwoFactorVerifyEvent.PasskeyCeremonyCompleted(PasskeyAssertionResult.UserCanceled))
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None)
assertThat(viewModel.state.value.step).isEqualTo(Step.ChooseMethod(hasAuthenticatorApp = true))
assertThat(actions).containsExactly(TwoFactorVerifyAction.AuthenticateWithPasskey(PASSKEY))
}
@Test
fun `a failed ceremony shows the passkey error`() = runTest(testDispatcher) {
val viewModel = createViewModel()
viewModel.onEvent(TwoFactorVerifyEvent.PasskeyCeremonyCompleted(PasskeyAssertionResult.CeremonyFailed))
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.PasskeyFailed)
}
@Test
fun `dismissing the passkey error leaves the user on the method choice`() = runTest(testDispatcher) {
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TwoFactorVerifyEvent.PasskeyCeremonyCompleted(PasskeyAssertionResult.NoCredentialAvailable))
viewModel.onEvent(TwoFactorVerifyEvent.DialogDismissed)
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None)
assertThat(viewModel.state.value.step).isEqualTo(Step.ChooseMethod(hasAuthenticatorApp = true))
assertThat(actions).isEmpty()
}
@Test
fun `state never logs the code`() = runTest(testDispatcher) {
coEvery { repository.startVerification() } returns AUTHENTICATOR_APP_ONLY
coEvery { repository.verifyCode(any()) } returns VerifyResult.NetworkFailure
val viewModel = createViewModel()
viewModel.onEvent(TwoFactorVerifyEvent.CodeEntryEvent(CodeEntryFieldEvents.CodeChanged(CODE)))
assertThat(viewModel.state.value.toString().contains(CODE)).isFalse()
assertThat(viewModel.state.value.codeEntry.isComplete).isTrue()
}
private fun createViewModel(arePasskeysSupported: Boolean = true): TwoFactorVerifyViewModel {
return TwoFactorVerifyViewModel(arePasskeysSupported = arePasskeysSupported, repository = repository)
}
private fun TestScope.collectActions(actions: Flow<TwoFactorVerifyAction>): List<TwoFactorVerifyAction> {
val collected = mutableListOf<TwoFactorVerifyAction>()
backgroundScope.launch { actions.toList(collected) }
return collected
}
}

View file

@ -0,0 +1,90 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.megaphone
import assertk.assertThat
import assertk.assertions.isFalse
import assertk.assertions.isTrue
import io.mockk.every
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.thoughtcrime.securesms.testutil.MockSignalStoreRule
import kotlin.time.Duration.Companion.days
class TwoFactorReminderScheduleTest {
@get:Rule
val signalStore = MockSignalStoreRule()
private val now = System.currentTimeMillis()
private val schedule = TwoFactorReminderSchedule()
@Before
fun setUp() {
every { signalStore.account.isPhoneNumberless } returns true
every { signalStore.account.hasTwoFactorMethods } returns true
every { signalStore.uiHints.hasVerifiedTwoFactorBefore() } returns false
every { signalStore.uiHints.hasSnoozedTwoFactorReminder() } returns false
every { signalStore.uiHints.twoFactorLastVerifiedTime } returns now - 15.days.inWholeMilliseconds
}
@Test
fun `shows once the first interval has passed`() {
assertThat(schedule.shouldDisplay(0, 0, 0, now)).isTrue()
}
@Test
fun `hidden for accounts with a phone number`() {
every { signalStore.account.isPhoneNumberless } returns false
assertThat(schedule.shouldDisplay(0, 0, 0, now)).isFalse()
}
@Test
fun `hidden when the account has no methods`() {
every { signalStore.account.hasTwoFactorMethods } returns false
assertThat(schedule.shouldDisplay(0, 0, 0, now)).isFalse()
}
@Test
fun `hidden until two weeks after methods were first seen`() {
every { signalStore.uiHints.twoFactorLastVerifiedTime } returns now - 13.days.inWholeMilliseconds
assertThat(schedule.shouldDisplay(0, 0, 0, now)).isFalse()
}
@Test
fun `waits six months after a verification`() {
every { signalStore.uiHints.hasVerifiedTwoFactorBefore() } returns true
every { signalStore.uiHints.twoFactorLastVerifiedTime } returns now - 100.days.inWholeMilliseconds
assertThat(schedule.shouldDisplay(0, 0, 0, now)).isFalse()
}
@Test
fun `shows six months after a verification`() {
every { signalStore.uiHints.hasVerifiedTwoFactorBefore() } returns true
every { signalStore.uiHints.twoFactorLastVerifiedTime } returns now - 184.days.inWholeMilliseconds
assertThat(schedule.shouldDisplay(0, 0, 0, now)).isTrue()
}
@Test
fun `hidden for a week after a snooze`() {
every { signalStore.uiHints.hasSnoozedTwoFactorReminder() } returns true
assertThat(schedule.shouldDisplay(1, now - 6.days.inWholeMilliseconds, 0, now)).isFalse()
}
@Test
fun `shows again once a snooze has expired`() {
every { signalStore.uiHints.hasSnoozedTwoFactorReminder() } returns true
assertThat(schedule.shouldDisplay(1, now - 8.days.inWholeMilliseconds, 0, now)).isTrue()
}
}

Some files were not shown because too many files have changed in this diff Show more