Add a megaphone for confirming two-factor methods.
This commit is contained in:
parent
072f328054
commit
7abc060dc1
35 changed files with 2120 additions and 4 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
20
app/src/main/res/drawable-night/megaphone_two_factor.xml
Normal file
20
app/src/main/res/drawable-night/megaphone_two_factor.xml
Normal 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>
|
||||
20
app/src/main/res/drawable/megaphone_two_factor.xml
Normal file
20
app/src/main/res/drawable/megaphone_two_factor.xml
Normal 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>
|
||||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue