Add some support links for numberless flows.

This commit is contained in:
Greyson Parrelli
2026-09-10 17:10:13 -04:00
committed by Cody Henthorne
parent 9df8b1845d
commit 98a4fe1069
15 changed files with 111 additions and 66 deletions
@@ -25,7 +25,6 @@ import org.signal.appsettings.account.AccountSettingsScreen
import org.signal.core.ui.compose.CollectActions
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.util.ServiceUtil
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.compose.BiometricsAuthentication
import org.thoughtcrime.securesms.components.compose.rememberBiometricsAuthentication
@@ -33,6 +32,7 @@ import org.thoughtcrime.securesms.components.settings.app.account.authenticator.
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.PlayStoreUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.signal.appsettings.R as AppSettingsR
@@ -43,10 +43,6 @@ import org.signal.appsettings.R as AppSettingsR
*/
class AccountSettingsFragment : ComposeFragment() {
companion object {
private val TAG = Log.tag(AccountSettingsFragment::class)
}
private val viewModel: AccountSettingsViewModel by viewModels()
private lateinit var pinFlowLauncher: ActivityResultLauncher<Intent>
@@ -117,8 +113,7 @@ class AccountSettingsFragment : ComposeFragment() {
AccountSettingsAction.ShowAuthenticationFailed -> toast(AppSettingsR.string.AccountSettingsFragment__authentication_required)
AccountSettingsAction.ShowTotpAppRemoved -> toast(AppSettingsR.string.AccountSettingsFragment__authenticator_app_removed)
AccountSettingsAction.ShowTotpAppRemovalFailed -> toast(AppSettingsR.string.AccountSettingsFragment__couldnt_remove_authenticator_app)
// TODO Open the two-factor authentication support article once one exists.
AccountSettingsAction.OpenLearnMore -> Log.w(TAG, "There's no support article to open yet.")
is AccountSettingsAction.OpenSupportArticle -> CommunicationActions.openBrowserLink(requireContext(), action.url)
AccountSettingsAction.NavigateToAdvancedPinSettings -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_advancedPinSettingsActivity)
AccountSettingsAction.NavigateToChangePhoneNumber -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_changePhoneNumberFragment)
AccountSettingsAction.NavigateToDeviceTransfer -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_oldDeviceTransferActivity)
@@ -91,8 +91,8 @@ class AccountSettingsViewModel(
AccountSettingsEvent.AddTotpAppClicked -> {
applyAddTotpAppClicked()
}
AccountSettingsEvent.LearnMoreClicked -> {
_actions.send(AccountSettingsAction.OpenLearnMore)
is AccountSettingsEvent.LearnMoreClicked -> {
_actions.send(AccountSettingsAction.OpenSupportArticle(event.url))
}
is AccountSettingsEvent.RenameMethodClicked -> {
applyRenameMethodClicked(event.method)
@@ -540,9 +540,9 @@ class AccountSettingsViewModelTest {
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(AccountSettingsEvent.LearnMoreClicked)
viewModel.onEvent(AccountSettingsEvent.LearnMoreClicked("https://support.signal.org/hc/articles/11228705649690"))
assertThat(actions.last()).isEqualTo(AccountSettingsAction.OpenLearnMore)
assertThat(actions.last()).isEqualTo(AccountSettingsAction.OpenSupportArticle("https://support.signal.org/hc/articles/11228705649690"))
}
@Test
@@ -51,8 +51,8 @@ sealed interface AccountSettingsAction {
/** Tell the user the removal didn't go through, so they know the app is still on the account. */
data object ShowTotpAppRemovalFailed : AccountSettingsAction
/** Send the user to a support article about two-factor authentication. */
data object OpenLearnMore : AccountSettingsAction
/** Send the user to the support article at [url]. */
data class OpenSupportArticle(val url: String) : AccountSettingsAction
/** Open the advanced PIN settings screen. */
data object NavigateToAdvancedPinSettings : AccountSettingsAction
@@ -51,8 +51,8 @@ sealed interface AccountSettingsEvent {
/** The user tapped the authenticator app option in the two-factor set-up menu. */
data object AddTotpAppClicked : AccountSettingsEvent
/** The user tapped the learn more link on the dialog explaining the authenticator app limit. */
data object LearnMoreClicked : AccountSettingsEvent
/** The user tapped a learn more link that should open the support article at [url]. */
data class LearnMoreClicked(val url: String) : AccountSettingsEvent
/** The user tapped the rename option in [method]'s overflow menu. */
data class RenameMethodClicked(val method: TwoFactorMethod) : AccountSettingsEvent
@@ -43,12 +43,14 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.dp
import org.signal.appsettings.R
import org.signal.appsettings.account.AccountSettingsState.Dialog
@@ -73,6 +75,7 @@ import org.signal.core.ui.R as CoreUiR
object AccountSettingsTestTags {
const val SCROLLER = "scroller"
const val CARD_SIGNAL_LOGIN = "card-signal-login"
const val LINK_SIGNAL_LOGIN_LEARN_MORE = "link-signal-login-learn-more"
const val ROW_SET_UP_TWO_FACTOR = "row-set-up-two-factor"
const val MENU_ITEM_AUTHENTICATOR_APP = "menu-item-authenticator-app"
const val ROW_TWO_FACTOR_METHOD = "row-two-factor-method"
@@ -81,6 +84,7 @@ object AccountSettingsTestTags {
const val MENU_ITEM_REMOVE = "menu-item-remove"
const val TWO_FACTOR_LOADING = "two-factor-loading"
const val TWO_FACTOR_LOAD_FAILED_MESSAGE = "two-factor-load-failed-message"
const val LINK_TWO_FACTOR_LEARN_MORE = "link-two-factor-learn-more"
const val ROW_MODIFY_PIN = "row-modify-pin"
const val ROW_PIN_REMINDER = "row-pin-reminder"
const val ROW_REGISTRATION_LOCK = "row-registration-lock"
@@ -139,7 +143,12 @@ fun AccountSettingsScreen(
}
item {
SectionFooter(text = stringResource(R.string.AccountSettingsFragment__your_signal_login_is_used_to_recover))
SectionFooter(
text = stringResource(R.string.AccountSettingsFragment__your_signal_login_is_used_to_recover),
url = "https://support.signal.org/hc/articles/11197884108826",
onEvent = onEvent,
modifier = Modifier.testTag(AccountSettingsTestTags.LINK_SIGNAL_LOGIN_LEARN_MORE)
)
}
item {
@@ -192,7 +201,12 @@ fun AccountSettingsScreen(
}
item {
SectionFooter(text = stringResource(R.string.AccountSettingsFragment__use_a_second_form_of_authentication))
SectionFooter(
text = stringResource(R.string.AccountSettingsFragment__use_a_second_form_of_authentication),
url = "https://support.signal.org/hc/articles/11228705649690",
onEvent = onEvent,
modifier = Modifier.testTag(AccountSettingsTestTags.LINK_TWO_FACTOR_LEARN_MORE)
)
}
item {
@@ -556,24 +570,28 @@ private fun SignalLoginCard(
}
/**
* Explanatory text shown underneath a section, ending in a "Learn more" link that has nowhere to go yet.
* Explanatory text shown underneath a section, ending in a "Learn more" link that opens the support article at [url].
*/
@Composable
private fun SectionFooter(
text: String,
url: String,
onEvent: (AccountSettingsEvent) -> Unit,
modifier: Modifier = Modifier
) {
val learnMore = stringResource(R.string.AccountSettingsFragment__learn_more)
val primaryColor = MaterialTheme.colorScheme.primary
Text(
text = remember(text, learnMore, primaryColor) {
buildAnnotatedString {
append(text)
append(" ")
withStyle(SpanStyle(color = primaryColor)) {
append(learnMore)
}
text = buildAnnotatedString {
append(text)
append(" ")
withLink(
LinkAnnotation.Clickable(
tag = "learn-more",
styles = TextLinkStyles(style = SpanStyle(color = MaterialTheme.colorScheme.primary)),
linkInteractionListener = { onEvent(AccountSettingsEvent.LearnMoreClicked(url)) }
)
) {
append(stringResource(R.string.AccountSettingsFragment__learn_more))
}
},
style = MaterialTheme.typography.bodyMedium,
@@ -625,7 +643,7 @@ private fun MaxTotpAppsReachedDialog(
onConfirm = {},
onDismiss = { onEvent(AccountSettingsEvent.DialogDismissed) },
dismiss = stringResource(R.string.AccountSettingsFragment__learn_more),
onDeny = { onEvent(AccountSettingsEvent.LearnMoreClicked) },
onDeny = { onEvent(AccountSettingsEvent.LearnMoreClicked("https://support.signal.org/hc/articles/11228705649690")) },
onDismissRequest = { onEvent(AccountSettingsEvent.DialogDismissed) },
modifier = Modifier.testTag(AccountSettingsTestTags.DIALOG_MAX_TOTP_APPS_REACHED)
)
@@ -7,6 +7,7 @@ package org.signal.appsettings.account
import android.app.Application
import android.text.format.DateUtils
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
@@ -15,10 +16,13 @@ import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onChildren
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performSemanticsAction
import assertk.assertThat
import assertk.assertions.contains
import org.junit.Rule
@@ -422,10 +426,30 @@ class AccountSettingsScreenTest {
composeTestRule.onNodeWithTag(AccountSettingsTestTags.DIALOG_MAX_TOTP_APPS_REACHED).assertIsDisplayed()
composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_DISMISS_BUTTON).performClick()
assertThat(events).contains(AccountSettingsEvent.LearnMoreClicked)
assertThat(events).contains(AccountSettingsEvent.LearnMoreClicked("https://support.signal.org/hc/articles/11228705649690"))
assertThat(events).contains(AccountSettingsEvent.DialogDismissed)
}
@Test
fun whenIClickTheSignalLoginLearnMore_thenIExpectLearnMoreForTheSignalLoginArticle() {
setContent(createState(signalLogin = signalLogin()))
scrollTo(AccountSettingsTestTags.LINK_SIGNAL_LOGIN_LEARN_MORE)
clickLink(AccountSettingsTestTags.LINK_SIGNAL_LOGIN_LEARN_MORE)
assertThat(events).contains(AccountSettingsEvent.LearnMoreClicked("https://support.signal.org/hc/articles/11197884108826"))
}
@Test
fun whenIClickTheTwoFactorLearnMore_thenIExpectLearnMoreForTheTwoFactorArticle() {
setContent(createState(signalLogin = signalLogin()))
scrollTo(AccountSettingsTestTags.LINK_TWO_FACTOR_LEARN_MORE)
clickLink(AccountSettingsTestTags.LINK_TWO_FACTOR_LEARN_MORE)
assertThat(events).contains(AccountSettingsEvent.LearnMoreClicked("https://support.signal.org/hc/articles/11228705649690"))
}
@Test
fun whenTheTwoFactorListHasntArrived_thenIExpectASpinnerRatherThanAnEmptyList() {
setContent(createState(signalLogin = signalLogin(loadState = LoadState.LOADING)))
@@ -478,6 +502,14 @@ class AccountSettingsScreenTest {
return AccountSettingsState.SignalLogin(twoFactorMethods = twoFactorMethods, loadState = loadState, maxTotpApps = maxTotpApps)
}
/** Links inside an [androidx.compose.ui.text.AnnotatedString] have no bounds to tap, so their click action is invoked directly. */
private fun clickLink(testTag: String) {
composeTestRule.onNodeWithTag(testTag)
.onChildren()
.onFirst()
.performSemanticsAction(SemanticsActions.OnClick)
}
private fun scrollTo(testTag: String) {
composeTestRule.onNodeWithTag(AccountSettingsTestTags.SCROLLER)
.performScrollToNode(hasTestTag(testTag))
@@ -62,6 +62,7 @@ import org.signal.registration.screens.accountlocked.AccountLockedScreen
import org.signal.registration.screens.accountlocked.AccountLockedScreenEvents
import org.signal.registration.screens.accountlocked.AccountLockedState
import org.signal.registration.screens.addusername.AddUsernameScreen
import org.signal.registration.screens.addusername.AddUsernameScreenActions
import org.signal.registration.screens.addusername.AddUsernameViewModel
import org.signal.registration.screens.aepentry.EnterAepForLocalBackupResult
import org.signal.registration.screens.aepentry.EnterAepForLocalBackupViewModel
@@ -405,12 +406,8 @@ private const val AEP_FOR_LOCAL_BACKUP_RESULT = "aep_for_local_backup_result"
private const val LOCAL_BACKUP_RESTORE_RESULT = "local_backup_restore_result"
private const val PHONE_NUMBER_DISCOVERABILITY_RESULT = "phone_number_discoverability_result"
private const val TWO_FACTOR_CODE_RESULT = "two_factor_code_result"
private const val PIN_LEARN_MORE_URL = "https://support.signal.org/hc/articles/360007059792"
private const val CLIPBOARD_TIMEOUT_SECONDS = 60
// TODO [phonenumberless] Point at the real support article once it exists.
private const val SIGNAL_LOGIN_LEARN_MORE_URL = "https://support.signal.org/"
/** Opens [url] in a browser, surfacing a toast if the device has none. */
private fun openUrl(context: Context, url: String) {
LinkActions.openUrl(context, url) { error ->
@@ -724,7 +721,7 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
val purchaseScope = rememberCoroutineScope()
CollectActions(viewModel.actions) { action ->
when (action) {
SignalLoginPaymentScreenActions.OpenLearnMoreArticle -> openUrl(context, SIGNAL_LOGIN_LEARN_MORE_URL)
SignalLoginPaymentScreenActions.OpenLearnMoreArticle -> openUrl(context, "https://support.signal.org/hc/articles/11197884108826")
is SignalLoginPaymentScreenActions.LaunchPurchaseFlow -> {
purchaseScope.launch {
@@ -900,7 +897,7 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
val context = LocalContext.current
CollectActions(viewModel.actions) { action ->
when (action) {
SignalLoginCredentialEntryScreenActions.OpenNeedHelpArticle -> openUrl(context, SIGNAL_LOGIN_LEARN_MORE_URL)
SignalLoginCredentialEntryScreenActions.OpenNeedHelpArticle -> openUrl(context, "https://support.signal.org/hc/articles/11197884108826")
}
}
@@ -961,6 +958,12 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
)
}
val state by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
CollectActions(viewModel.actions) { action ->
when (action) {
AddUsernameScreenActions.OpenLearnMoreArticle -> openUrl(context, "https://support.signal.org/hc/articles/6712070553754")
}
}
AddUsernameScreen(
state = state,
@@ -998,7 +1001,7 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
val context = LocalContext.current
CollectActions(viewModel.actions) { action ->
when (action) {
PinCreationScreenActions.OpenLearnMoreArticle -> openUrl(context, PIN_LEARN_MORE_URL)
PinCreationScreenActions.OpenLearnMoreArticle -> openUrl(context, "https://support.signal.org/hc/articles/360007059792")
}
}
@@ -115,15 +115,6 @@ fun AddUsernameScreen(
)
}
if (state.dialogs.learnMore) {
Dialogs.SimpleMessageDialog(
title = stringResource(R.string.AddUsernameScreen__what_is_this_number),
message = stringResource(R.string.AddUsernameScreen__these_digits_help_keep),
dismiss = stringResource(android.R.string.ok),
onDismiss = { onEvent(AddUsernameScreenEvents.LearnMoreDialogDismissed) }
)
}
Surface(
modifier = modifier
.fillMaxSize()
@@ -0,0 +1,11 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.addusername
sealed interface AddUsernameScreenActions {
/** Open the article explaining phone number privacy and usernames. */
data object OpenLearnMoreArticle : AddUsernameScreenActions
}
@@ -37,9 +37,6 @@ sealed class AddUsernameScreenEvents {
/** The user tapped the "learn more" link under the username field. */
data object LearnMoreClicked : AddUsernameScreenEvents()
/** The user dismissed the dialog explaining the digits after the username. */
data object LearnMoreDialogDismissed : AddUsernameScreenEvents()
/** The user tapped the skip button, asking to opt out of choosing a username. */
data object SkipClicked : AddUsernameScreenEvents()
@@ -58,8 +58,6 @@ data class AddUsernameState(
}
data class Dialogs(
/** Explains what the digits after the username are for. */
val learnMore: Boolean = false,
/** Confirms the user wants to skip choosing a username. */
val confirmSkip: Boolean = false,
val networkError: Boolean = false,
@@ -10,6 +10,8 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -18,6 +20,7 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.UsernameUtil
@@ -53,6 +56,9 @@ class AddUsernameViewModel(
private val _state = MutableStateFlow(AddUsernameState())
val state: StateFlow<AddUsernameState> = _state.asStateFlow()
private val _actions = Channel<AddUsernameScreenActions>(Channel.BUFFERED)
val actions: Flow<AddUsernameScreenActions> = _actions.receiveAsFlow()
private val entryChanges = MutableSharedFlow<AddUsernameScreenEvents.EntrySettled>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
/** The in-flight reservation request. Only one may be live at a time -- starting a new one cancels the old one. */
@@ -86,8 +92,7 @@ class AddUsernameViewModel(
is AddUsernameScreenEvents.DiscriminatorChanged -> applyDiscriminatorChanged(state, event.value, stateEmitter)
is AddUsernameScreenEvents.EntrySettled -> applyEntrySettled(state, event, stateEmitter)
is AddUsernameScreenEvents.ReservationCompleted -> applyReservationCompleted(state, event, stateEmitter)
is AddUsernameScreenEvents.LearnMoreClicked -> stateEmitter(state.copy(dialogs = state.dialogs.copy(learnMore = true)))
is AddUsernameScreenEvents.LearnMoreDialogDismissed -> stateEmitter(state.copy(dialogs = state.dialogs.copy(learnMore = false)))
is AddUsernameScreenEvents.LearnMoreClicked -> _actions.trySend(AddUsernameScreenActions.OpenLearnMoreArticle)
is AddUsernameScreenEvents.SkipClicked -> stateEmitter(state.copy(dialogs = state.dialogs.copy(confirmSkip = true)))
is AddUsernameScreenEvents.SkipConfirmed -> applySkipConfirmed(parentEventEmitter)
is AddUsernameScreenEvents.SkipDialogDismissed -> stateEmitter(state.copy(dialogs = state.dialogs.copy(confirmSkip = false)))
@@ -747,12 +747,8 @@
<string name="AddUsernameScreen__username">Username</string>
<!-- Helper text below the username field. Followed by a "learn more" link. -->
<string name="AddUsernameScreen__usernames_are_always_paired_with_a_set_of_numbers">Usernames are always paired with a set of numbers.</string>
<!-- Link in the helper text that opens an explanation of the digits paired with a username. -->
<!-- Link in the helper text that opens a support article about usernames. -->
<string name="AddUsernameScreen__learn_more">Learn more</string>
<!-- Title of the dialog explaining the digits paired with a username. -->
<string name="AddUsernameScreen__what_is_this_number">What is this number?</string>
<!-- Body of the dialog explaining the digits paired with a username. -->
<string name="AddUsernameScreen__these_digits_help_keep">These digits help keep your username private so you can avoid unwanted messages. Share your username with only the people and groups you\'d like to chat with. If you change usernames you\'ll get a new set of digits.</string>
<!-- Button that skips choosing a username. -->
<string name="AddUsernameScreen__skip">Skip</string>
<!-- Button that submits the chosen username. -->
@@ -17,6 +17,7 @@ import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
@@ -61,16 +62,14 @@ class AddUsernameViewModelTest {
}
@Test
fun `LearnMoreClicked shows the dialog explaining the discriminator`() = runTest(testDispatcher) {
fun `LearnMoreClicked emits an action to open the learn more article`() = runTest(testDispatcher) {
val actions = mutableListOf<AddUsernameScreenActions>()
backgroundScope.launch(testDispatcher) { viewModel.actions.collect { actions.add(it) } }
viewModel.onEvent(AddUsernameScreenEvents.LearnMoreClicked)
advanceUntilIdle()
assertThat(viewModel.state.value.dialogs.learnMore).isTrue()
viewModel.onEvent(AddUsernameScreenEvents.LearnMoreDialogDismissed)
advanceUntilIdle()
assertThat(viewModel.state.value.dialogs.learnMore).isFalse()
assertThat(actions).containsExactly(AddUsernameScreenActions.OpenLearnMoreArticle)
}
@Test