Add linked device account screen.

This commit is contained in:
Cody Henthorne
2026-07-15 11:12:01 -04:00
committed by Greyson Parrelli
parent f70ae10067
commit 1d11e29e94
37 changed files with 695 additions and 41 deletions
@@ -90,7 +90,11 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
is AppSettingsRoute.BackupsRoute.Backups -> AppSettingsFragmentDirections.actionDirectToBackupsSettingsFragment().setLaunchCheckoutFlow(appSettingsRoute.launchCheckoutFlow)
AppSettingsRoute.Invite -> AppSettingsFragmentDirections.actionDirectToInviteFragment()
AppSettingsRoute.DataAndStorageRoute.DataAndStorage -> AppSettingsFragmentDirections.actionDirectToStoragePreferenceFragment()
AppSettingsRoute.AccountRoute.Account -> AppSettingsFragmentDirections.actionDirectToAccountSettingsFragment()
AppSettingsRoute.AccountRoute.Account -> if (SignalStore.account.isPrimaryDevice) {
AppSettingsFragmentDirections.actionDirectToAccountSettingsFragment()
} else {
AppSettingsFragmentDirections.actionDirectToLinkedDeviceAccountSettingsFragment()
}
else -> error("Unsupported start location: ${appSettingsRoute?.javaClass?.name}")
}
}
@@ -76,6 +76,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaym
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.InAppPaymentsBottomSheetDelegate
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.CommunicationActions
@@ -96,7 +97,11 @@ class AppSettingsFragment : ComposeFragment(), Callbacks {
appSettingsRouter.currentRoute.collect { route ->
when (route) {
is AppSettingsRoute.BackupsRoute.Remote -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_remoteBackupsSettingsFragment)
is AppSettingsRoute.AccountRoute.Account -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_accountSettingsFragment)
is AppSettingsRoute.AccountRoute.Account -> if (SignalStore.account.isPrimaryDevice) {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_accountSettingsFragment)
} else {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_linkedDeviceAccountSettingsFragment)
}
is AppSettingsRoute.LinkDeviceRoute.LinkDevice -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_linkDeviceFragment)
is AppSettingsRoute.DonationsRoute.Donations -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_manageDonationsFragment)
is AppSettingsRoute.AppearanceRoute.Appearance -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_appearanceSettingsFragment)
@@ -292,17 +297,17 @@ private fun AppSettingsContent(
BackupFailureState.NONE -> Unit
}
if (state.isPrimaryDevice) {
item {
Rows.TextRow(
text = stringResource(R.string.AccountSettingsFragment__account),
icon = painterResource(CoreUiR.drawable.symbol_person_circle_24),
onClick = {
callbacks.navigate(AppSettingsRoute.AccountRoute.Account)
}
)
}
item {
Rows.TextRow(
text = stringResource(R.string.AccountSettingsFragment__account),
icon = painterResource(CoreUiR.drawable.symbol_person_circle_24),
onClick = {
callbacks.navigate(AppSettingsRoute.AccountRoute.Account)
}
)
}
if (state.isPrimaryDevice) {
item {
Rows.TextRow(
text = stringResource(R.string.preferences__linked_devices),
@@ -0,0 +1,142 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.account
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentManager
import org.signal.core.ui.BottomSheetUtil
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
/**
* Bottom sheet shown from the linked-device Account settings screen explaining what linked devices
* are and that account management lives on the primary device.
*/
class LinkedDeviceAccountLearnMoreBottomSheet : ComposeBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 0.75f
companion object {
@JvmStatic
fun show(fragmentManager: FragmentManager) {
LinkedDeviceAccountLearnMoreBottomSheet()
.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
@Composable
override fun SheetContent() {
LinkedDeviceAccountLearnMoreSheet()
}
}
@Composable
private fun LinkedDeviceAccountLearnMoreSheet() {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.horizontalGutters()
.padding(bottom = 48.dp)
) {
BottomSheets.Handle()
Icon(
imageVector = SignalIcons.Devices.imageVector,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(top = 16.dp, bottom = 24.dp)
.clip(RoundedCornerShape(50))
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f))
.padding(horizontal = 20.dp, vertical = 6.dp)
.size(40.dp)
)
val linkedDevices = stringResource(R.string.LinkedDeviceAccountLearnMoreBottomSheet__linked_devices)
val text = stringResource(R.string.LinkedDeviceAccountLearnMoreBottomSheet__linked_devices_let_you_access)
val annotatedText = remember(text, linkedDevices) {
buildAnnotatedString {
val start = text.indexOf(linkedDevices)
if (start >= 0) {
append(text.substring(0, start))
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(linkedDevices)
}
append(text.substring(start + linkedDevices.length))
} else {
append(text)
}
}
}
Text(
text = annotatedText,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 20.dp)
)
BulletRow(stringResource(R.string.LinkedDeviceAccountLearnMoreBottomSheet__you_can_link_a_desktop))
BulletRow(stringResource(R.string.LinkedDeviceAccountLearnMoreBottomSheet__your_primary_device_manages))
BulletRow(stringResource(R.string.LinkedDeviceAccountLearnMoreBottomSheet__some_account_settings))
}
}
@Composable
private fun BulletRow(text: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
verticalAlignment = Alignment.Top
) {
Text(
text = "\u2022",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(end = 8.dp)
)
Text(
text = text,
style = MaterialTheme.typography.bodyLarge
)
}
}
@DayNightPreviews
@Composable
private fun LinkedDeviceAccountLearnMoreSheetPreview() {
Previews.BottomSheetContentPreview {
LinkedDeviceAccountLearnMoreSheet()
}
}
@@ -0,0 +1,29 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.account
sealed interface LinkedDeviceAccountSettingsEvent {
/** The user tapped "Learn more" on the linked-device callout. */
data object LearnMoreClicked : LinkedDeviceAccountSettingsEvent
/** The user tapped the navigation (back) icon. */
data object NavigateBackClicked : LinkedDeviceAccountSettingsEvent
/** The user tapped the "Delete app data" row. */
data object DeleteAppDataClicked : LinkedDeviceAccountSettingsEvent
/** The user confirmed the delete in the confirmation dialog. */
data object DeleteConfirmed : LinkedDeviceAccountSettingsEvent
/** The user dismissed the delete confirmation dialog. */
data object DeleteDismissed : LinkedDeviceAccountSettingsEvent
/** The fragment reported that clearing application data failed. */
data object DataWipeFailed : LinkedDeviceAccountSettingsEvent
/** The fragment has handled the current [LinkedDeviceAccountSettingsState.oneTimeEvent]. */
data object ConsumeOneTimeEvent : LinkedDeviceAccountSettingsEvent
}
@@ -0,0 +1,220 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.account
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.ServiceUtil
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.account.LinkedDeviceAccountSettingsState.OneTimeEvent
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
import org.thoughtcrime.securesms.dependencies.AppDependencies
/**
* Account settings shown when the current device is a linked (non-primary) device. Account
* management lives on the primary device, so this screen only surfaces an informational callout
* and the ability to delete the Signal data stored on this device.
*/
class LinkedDeviceAccountSettingsFragment : ComposeFragment() {
private val viewModel: LinkedDeviceAccountSettingsViewModel by viewModels()
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(state.oneTimeEvent) {
viewModel.onEvent(LinkedDeviceAccountSettingsEvent.ConsumeOneTimeEvent)
when (state.oneTimeEvent) {
OneTimeEvent.OpenLearnMore -> LinkedDeviceAccountLearnMoreBottomSheet.show(childFragmentManager)
OneTimeEvent.NavigateBack -> requireActivity().onBackPressedDispatcher.onBackPressed()
OneTimeEvent.WipeData -> {
if (!ServiceUtil.getActivityManager(AppDependencies.application).clearApplicationUserData()) {
viewModel.onEvent(LinkedDeviceAccountSettingsEvent.DataWipeFailed)
}
}
OneTimeEvent.DeleteFailed -> Toast.makeText(requireContext(), R.string.preferences_account_delete_all_data_failed, Toast.LENGTH_LONG).show()
null -> Unit
}
}
LinkedDeviceAccountSettingsScreen(
state = state,
onEvent = viewModel::onEvent
)
}
}
@Composable
private fun LinkedDeviceAccountSettingsScreen(
state: LinkedDeviceAccountSettingsState,
onEvent: (LinkedDeviceAccountSettingsEvent) -> Unit
) {
Scaffolds.Settings(
title = stringResource(R.string.AccountSettingsFragment__account),
onNavigationClick = { onEvent(LinkedDeviceAccountSettingsEvent.NavigateBackClicked) },
navigationIcon = SignalIcons.ArrowStart.imageVector
) { paddingValues ->
LazyColumn(
modifier = Modifier
.padding(paddingValues)
.then(rememberStatusBarColorNestedScrollModifier())
) {
item {
LinkedDeviceCallout(onLearnMoreClick = { onEvent(LinkedDeviceAccountSettingsEvent.LearnMoreClicked) })
}
item {
Dividers.Default()
}
item {
Rows.TextRow(
text = stringResource(R.string.LinkedDeviceAccountSettingsFragment__delete_app_data),
label = stringResource(R.string.LinkedDeviceAccountSettingsFragment__this_will_remove_all_data),
onClick = { onEvent(LinkedDeviceAccountSettingsEvent.DeleteAppDataClicked) }
)
}
}
}
if (state.showDeleteConfirmationDialog) {
DeleteAppDataConfirmationDialog(
onConfirm = { onEvent(LinkedDeviceAccountSettingsEvent.DeleteConfirmed) },
onDismiss = { onEvent(LinkedDeviceAccountSettingsEvent.DeleteDismissed) }
)
}
if (state.deleting) {
Dialogs.IndeterminateProgressDialog()
}
}
@Composable
private fun LinkedDeviceCallout(
onLearnMoreClick: () -> Unit
) {
Row(
modifier = Modifier
.padding(horizontal = 24.dp, vertical = 12.dp)
.fillMaxWidth()
.background(color = SignalTheme.colors.colorSurface2, shape = RoundedCornerShape(12.dp))
.padding(20.dp)
) {
Icon(
imageVector = SignalIcons.Devices.imageVector,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.width(24.dp))
Column {
Text(
text = stringResource(R.string.LinkedDeviceAccountSettingsFragment__this_is_a_linked_device),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.LinkedDeviceAccountSettingsFragment__to_manage_your_account_settings),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
Buttons.Small(
onClick = onLearnMoreClick,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer)
) {
Text(text = stringResource(R.string.LearnMoreTextView_learn_more))
}
}
}
}
@Composable
private fun DeleteAppDataConfirmationDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.LinkedDeviceAccountSettingsFragment__delete_app_data_question),
body = stringResource(R.string.LinkedDeviceAccountSettingsFragment__this_will_delete_all_data_and_messages),
confirm = stringResource(R.string.delete),
confirmColor = MaterialTheme.colorScheme.error,
onConfirm = onConfirm,
dismiss = stringResource(android.R.string.cancel),
onDismiss = onDismiss
)
}
@DayNightPreviews
@Composable
private fun LinkedDeviceAccountSettingsScreenPreview() {
Previews.Preview {
LinkedDeviceAccountSettingsScreen(
state = LinkedDeviceAccountSettingsState(),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun LinkedDeviceAccountSettingsScreenDeleteConfirmationPreview() {
Previews.Preview {
LinkedDeviceAccountSettingsScreen(
state = LinkedDeviceAccountSettingsState(showDeleteConfirmationDialog = true),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun LinkedDeviceAccountSettingsScreenDeletingPreview() {
Previews.Preview {
LinkedDeviceAccountSettingsScreen(
state = LinkedDeviceAccountSettingsState(deleting = true),
onEvent = {}
)
}
}
@@ -0,0 +1,20 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.account
data class LinkedDeviceAccountSettingsState(
val showDeleteConfirmationDialog: Boolean = false,
val deleting: Boolean = false,
val oneTimeEvent: OneTimeEvent? = null
) {
/** One-time side effects the fragment must carry out (they need Android/fragment context). Consumed via [LinkedDeviceAccountSettingsEvent.ConsumeOneTimeEvent]. */
sealed interface OneTimeEvent {
data object OpenLearnMore : OneTimeEvent
data object NavigateBack : OneTimeEvent
data object WipeData : OneTimeEvent
data object DeleteFailed : OneTimeEvent
}
}
@@ -0,0 +1,58 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.account
import androidx.annotation.VisibleForTesting
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.settings.app.account.LinkedDeviceAccountSettingsState.OneTimeEvent
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.linkdevice.LinkDeviceRepository
/**
* Drives the account settings screen shown on a linked (non-primary) device. Owns the screen state
* and, on delete, makes a best-effort attempt to unlink this device from the server before the
* fragment wipes local data.
*/
class LinkedDeviceAccountSettingsViewModel(
private val selfDeviceId: () -> Int = { SignalStore.account.deviceId },
private val removeDevice: suspend (Int) -> Boolean = LinkDeviceRepository::removeDevice
) : EventDrivenViewModel<LinkedDeviceAccountSettingsEvent>(TAG) {
companion object {
private val TAG = Log.tag(LinkedDeviceAccountSettingsViewModel::class)
}
private val _state = MutableStateFlow(LinkedDeviceAccountSettingsState())
val state: StateFlow<LinkedDeviceAccountSettingsState> = _state.asStateFlow()
override suspend fun processEvent(event: LinkedDeviceAccountSettingsEvent) {
applyEvent(_state.value, event) { _state.value = it }
}
@VisibleForTesting
suspend fun applyEvent(state: LinkedDeviceAccountSettingsState, event: LinkedDeviceAccountSettingsEvent, stateEmitter: (LinkedDeviceAccountSettingsState) -> Unit) {
when (event) {
LinkedDeviceAccountSettingsEvent.LearnMoreClicked -> stateEmitter(state.copy(oneTimeEvent = OneTimeEvent.OpenLearnMore))
LinkedDeviceAccountSettingsEvent.NavigateBackClicked -> stateEmitter(state.copy(oneTimeEvent = OneTimeEvent.NavigateBack))
LinkedDeviceAccountSettingsEvent.DeleteAppDataClicked -> stateEmitter(state.copy(showDeleteConfirmationDialog = true))
LinkedDeviceAccountSettingsEvent.DeleteDismissed -> stateEmitter(state.copy(showDeleteConfirmationDialog = false))
LinkedDeviceAccountSettingsEvent.DeleteConfirmed -> {
val deletingState = state.copy(showDeleteConfirmationDialog = false, deleting = true)
stateEmitter(deletingState)
withContext(Dispatchers.IO) { removeDevice(selfDeviceId()) }
stateEmitter(deletingState.copy(oneTimeEvent = OneTimeEvent.WipeData))
}
LinkedDeviceAccountSettingsEvent.DataWipeFailed -> stateEmitter(state.copy(deleting = false, oneTimeEvent = OneTimeEvent.DeleteFailed))
LinkedDeviceAccountSettingsEvent.ConsumeOneTimeEvent -> stateEmitter(state.copy(oneTimeEvent = null))
}
}
}
@@ -51,7 +51,7 @@ object LinkDeviceRepository {
private val TAG = Log.tag(LinkDeviceRepository::class)
private const val DECRYPTION_INFO = "deviceCreatedAt"
fun removeDevice(deviceId: Int): Boolean {
suspend fun removeDevice(deviceId: Int): Boolean {
return when (val result = AppDependencies.linkDeviceApi.removeDevice(deviceId)) {
is NetworkResult.Success -> {
LinkedDeviceInactiveCheckJob.enqueue()