mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-19 16:24:41 +01:00
Add numberless delete account screen.
This commit is contained in:
@@ -18,7 +18,8 @@ import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.signal.appsettings.deleteaccount.DeleteAccountAction
|
||||
import org.signal.appsettings.deleteaccount.DeleteAccountEvent
|
||||
import org.signal.appsettings.deleteaccount.DeleteAccountScreen
|
||||
import org.signal.appsettings.deleteaccount.DeleteAccountWithNumberScreen
|
||||
import org.signal.appsettings.deleteaccount.DeleteAccountWithoutNumberScreen
|
||||
import org.signal.core.ui.compose.CollectActions
|
||||
import org.signal.core.ui.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -27,7 +28,8 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.signal.appsettings.R as AppSettingsR
|
||||
|
||||
/**
|
||||
* Lets a user delete their account. Carries out the [DeleteAccountAction]s that need an Activity or the nav graph.
|
||||
* Lets a user delete their account, showing whichever of the two screens fits the account. Carries out the
|
||||
* [DeleteAccountAction]s that need an Activity or the nav graph.
|
||||
*/
|
||||
class DeleteAccountFragment : ComposeFragment() {
|
||||
|
||||
@@ -50,10 +52,17 @@ class DeleteAccountFragment : ComposeFragment() {
|
||||
|
||||
CollectActions(viewModel.actions) { action -> handleAction(action) }
|
||||
|
||||
DeleteAccountScreen(
|
||||
state = state,
|
||||
onEvent = viewModel::onEvent
|
||||
)
|
||||
if (state.hasPhoneNumber) {
|
||||
DeleteAccountWithNumberScreen(
|
||||
state = state,
|
||||
onEvent = viewModel::onEvent
|
||||
)
|
||||
} else {
|
||||
DeleteAccountWithoutNumberScreen(
|
||||
state = state,
|
||||
onEvent = viewModel::onEvent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAction(action: DeleteAccountAction) {
|
||||
|
||||
@@ -44,6 +44,12 @@ class DeleteAccountRepository {
|
||||
|
||||
fun getRegionCountryCode(region: String): Int = PhoneNumberUtil.getInstance().getCountryCodeForRegion(region)
|
||||
|
||||
/** Whether this account has no phone number, which changes how the user confirms the deletion. */
|
||||
fun isPhoneNumberless(): Boolean = SignalStore.account.isPhoneNumberless
|
||||
|
||||
/** The user's username, or null if they haven't set one. */
|
||||
fun getUsername(): String? = SignalStore.account.username
|
||||
|
||||
/** The user's payments balance, formatted for display, or null if there's nothing in there worth mentioning. */
|
||||
fun getFormattedWalletBalance(): String? {
|
||||
val amount = SignalStore.payments.mobileCoinLatestBalance().fullAmount
|
||||
|
||||
@@ -24,8 +24,8 @@ import org.signal.core.ui.compose.EventDrivenViewModel
|
||||
import org.signal.core.util.logging.Log
|
||||
|
||||
/**
|
||||
* Drives the screen that lets a user delete their account, which asks them to key in their own phone number before
|
||||
* anything is torn down.
|
||||
* Drives both screens that let a user delete their account. An account with a phone number has to have that number
|
||||
* keyed back in before anything is torn down; a numberless one has a box ticked instead.
|
||||
*/
|
||||
class DeleteAccountViewModel(
|
||||
private val repository: DeleteAccountRepository = DeleteAccountRepository()
|
||||
@@ -39,7 +39,13 @@ class DeleteAccountViewModel(
|
||||
|
||||
private val phoneNumberUtil = PhoneNumberUtil.getInstance()
|
||||
|
||||
private val _state = MutableStateFlow(DeleteAccountState(walletBalance = repository.getFormattedWalletBalance()))
|
||||
private val _state = MutableStateFlow(
|
||||
DeleteAccountState(
|
||||
hasPhoneNumber = !repository.isPhoneNumberless(),
|
||||
username = repository.getUsername(),
|
||||
walletBalance = repository.getFormattedWalletBalance()
|
||||
)
|
||||
)
|
||||
private val _actions = Channel<DeleteAccountAction>(Channel.BUFFERED)
|
||||
|
||||
val state: StateFlow<DeleteAccountState> = _state.asStateFlow()
|
||||
@@ -68,6 +74,9 @@ class DeleteAccountViewModel(
|
||||
DeleteAccountEvent.DeleteAccountClicked -> {
|
||||
applyDeleteAccountClicked()
|
||||
}
|
||||
is DeleteAccountEvent.ConfirmationCheckedChanged -> {
|
||||
applyConfirmationCheckedChanged(event.checked)
|
||||
}
|
||||
DeleteAccountEvent.DeletionConfirmed -> {
|
||||
applyDeletionConfirmed()
|
||||
}
|
||||
@@ -137,6 +146,12 @@ class DeleteAccountViewModel(
|
||||
|
||||
private suspend fun applyDeleteAccountClicked() {
|
||||
val state = _state.value
|
||||
|
||||
if (!state.hasPhoneNumber) {
|
||||
_state.update { it.copy(dialog = Dialog.ConfirmNumberlessDeletion()) }
|
||||
return
|
||||
}
|
||||
|
||||
val countryCode = state.countryCode.toIntOrNull() ?: 0
|
||||
|
||||
if (countryCode == 0) {
|
||||
@@ -154,6 +169,12 @@ class DeleteAccountViewModel(
|
||||
_state.update { it.copy(dialog = dialog) }
|
||||
}
|
||||
|
||||
private fun applyConfirmationCheckedChanged(checked: Boolean) {
|
||||
_state.update {
|
||||
if (it.dialog is Dialog.ConfirmNumberlessDeletion) it.copy(dialog = Dialog.ConfirmNumberlessDeletion(checked)) else it
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun applyDeletionConfirmed() {
|
||||
_state.update { it.copy(dialog = Dialog.DeletingAccount) }
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ package org.thoughtcrime.securesms.delete
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.contains
|
||||
import assertk.assertions.containsExactly
|
||||
import assertk.assertions.isEmpty
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
@@ -46,6 +48,8 @@ class DeleteAccountViewModelTest {
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
|
||||
every { repository.isPhoneNumberless() } returns false
|
||||
every { repository.getUsername() } returns null
|
||||
every { repository.getFormattedWalletBalance() } returns null
|
||||
every { repository.getRegionDisplayName(any()) } returns ""
|
||||
every { repository.getRegionDisplayName("US") } returns "United States"
|
||||
@@ -145,6 +149,68 @@ class DeleteAccountViewModelTest {
|
||||
coVerify(exactly = 0) { repository.deleteAccount(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a numberless account is set up to show the numberless screen, along with the username on it`() = runTest(testDispatcher) {
|
||||
every { repository.isPhoneNumberless() } returns true
|
||||
every { repository.getUsername() } returns "alice.01"
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
assertThat(viewModel.state.value.hasPhoneNumber).isFalse()
|
||||
assertThat(viewModel.state.value.username).isEqualTo("alice.01")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DeleteAccountClicked on a numberless account asks for confirmation without any number`() = runTest(testDispatcher) {
|
||||
every { repository.isPhoneNumberless() } returns true
|
||||
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(DeleteAccountEvent.DeleteAccountClicked)
|
||||
|
||||
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.ConfirmNumberlessDeletion(confirmationChecked = false))
|
||||
assertThat(actions).isEmpty()
|
||||
coVerify(exactly = 0) { repository.deleteAccount(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ConfirmationCheckedChanged ticks the box on the numberless confirmation`() = runTest(testDispatcher) {
|
||||
every { repository.isPhoneNumberless() } returns true
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.onEvent(DeleteAccountEvent.DeleteAccountClicked)
|
||||
|
||||
viewModel.onEvent(DeleteAccountEvent.ConfirmationCheckedChanged(true))
|
||||
|
||||
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.ConfirmNumberlessDeletion(confirmationChecked = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ConfirmationCheckedChanged is ignored when the numberless confirmation isn't up`() = runTest(testDispatcher) {
|
||||
every { repository.isPhoneNumberless() } returns true
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.onEvent(DeleteAccountEvent.ConfirmationCheckedChanged(true))
|
||||
|
||||
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a dismissed numberless confirmation comes back unticked`() = runTest(testDispatcher) {
|
||||
every { repository.isPhoneNumberless() } returns true
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.onEvent(DeleteAccountEvent.DeleteAccountClicked)
|
||||
viewModel.onEvent(DeleteAccountEvent.ConfirmationCheckedChanged(true))
|
||||
|
||||
viewModel.onEvent(DeleteAccountEvent.DialogDismissed)
|
||||
viewModel.onEvent(DeleteAccountEvent.DeleteAccountClicked)
|
||||
|
||||
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.ConfirmNumberlessDeletion(confirmationChecked = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DeletionConfirmed reports the progress it's told about`() = runTest(testDispatcher) {
|
||||
lateinit var viewModel: DeleteAccountViewModel
|
||||
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.appsettings.deleteaccount
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import org.signal.appsettings.R
|
||||
import org.signal.appsettings.deleteaccount.DeleteAccountState.Dialog
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
/**
|
||||
* Every dialog both delete account screens show the same way, which is all of them bar the confirmation prompt. That
|
||||
* one differs between the two, so each screen renders its own.
|
||||
*/
|
||||
@Composable
|
||||
internal fun DeleteAccountDialogs(
|
||||
dialog: Dialog,
|
||||
onEvent: (DeleteAccountEvent) -> Unit
|
||||
) {
|
||||
when (dialog) {
|
||||
Dialog.None,
|
||||
Dialog.ConfirmDeletion,
|
||||
is Dialog.ConfirmNumberlessDeletion -> Unit
|
||||
|
||||
Dialog.NumberDoesNotMatch -> {
|
||||
Dialogs.SimpleMessageDialog(
|
||||
message = stringResource(R.string.DeleteAccountFragment__the_phone_number),
|
||||
dismiss = stringResource(android.R.string.ok),
|
||||
onDismiss = { onEvent(DeleteAccountEvent.DialogDismissed) },
|
||||
modifier = Modifier.testTag(DeleteAccountTestTags.DIALOG_NUMBER_DOES_NOT_MATCH)
|
||||
)
|
||||
}
|
||||
|
||||
Dialog.DeletionFailed -> {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.DeleteAccountFragment__account_not_deleted),
|
||||
body = stringResource(R.string.DeleteAccountFragment__there_was_a_problem),
|
||||
confirm = stringResource(android.R.string.ok),
|
||||
dismiss = stringResource(android.R.string.cancel),
|
||||
onConfirm = { onEvent(DeleteAccountEvent.DeletionConfirmed) },
|
||||
onDismiss = { onEvent(DeleteAccountEvent.DialogDismissed) },
|
||||
modifier = Modifier.testTag(DeleteAccountTestTags.DIALOG_DELETION_FAILED)
|
||||
)
|
||||
}
|
||||
|
||||
Dialog.LocalDataDeletionFailed -> {
|
||||
Dialogs.SimpleMessageDialog(
|
||||
message = stringResource(R.string.DeleteAccountFragment__failed_to_delete_local_data),
|
||||
dismiss = stringResource(R.string.DeleteAccountFragment__launch_app_settings),
|
||||
onDismiss = { onEvent(DeleteAccountEvent.LaunchAppSettingsClicked) },
|
||||
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false),
|
||||
modifier = Modifier.testTag(DeleteAccountTestTags.DIALOG_LOCAL_DATA_DELETION_FAILED)
|
||||
)
|
||||
}
|
||||
|
||||
Dialog.CancelingSubscription -> {
|
||||
ProgressDialog(
|
||||
title = stringResource(R.string.DeleteAccountFragment__deleting_account),
|
||||
message = stringResource(R.string.DeleteAccountFragment__canceling_your_subscription),
|
||||
progress = null
|
||||
)
|
||||
}
|
||||
|
||||
is Dialog.LeavingGroups -> {
|
||||
ProgressDialog(
|
||||
title = stringResource(R.string.DeleteAccountFragment__leaving_groups),
|
||||
message = stringResource(R.string.DeleteAccountFragment__depending_on_the_number_of_groups),
|
||||
progress = if (dialog.totalCount > 0) dialog.leaveCount.toFloat() / dialog.totalCount else null
|
||||
)
|
||||
}
|
||||
|
||||
Dialog.DeletingAccount -> {
|
||||
ProgressDialog(
|
||||
title = stringResource(R.string.DeleteAccountFragment__deleting_account),
|
||||
message = stringResource(R.string.DeleteAccountFragment__deleting_all_user_data_and_resetting),
|
||||
progress = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-dismissable spinner shown for the length of the deletion, which reports what part of it is underway.
|
||||
*/
|
||||
@Composable
|
||||
private fun ProgressDialog(
|
||||
title: String,
|
||||
message: String,
|
||||
progress: Float?
|
||||
) {
|
||||
Dialogs.BaseAlertDialog(
|
||||
onDismissRequest = {},
|
||||
confirmButton = {},
|
||||
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false),
|
||||
text = {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
if (progress == null) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(48.dp))
|
||||
} else {
|
||||
CircularProgressIndicator(progress = { progress }, modifier = Modifier.size(48.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter))
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter))
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
},
|
||||
modifier = Modifier.testTag(DeleteAccountTestTags.DIALOG_PROGRESS)
|
||||
)
|
||||
}
|
||||
+3
@@ -32,6 +32,9 @@ sealed interface DeleteAccountEvent {
|
||||
/** The user asked to delete their account, which we only act on once they've confirmed. */
|
||||
data object DeleteAccountClicked : DeleteAccountEvent
|
||||
|
||||
/** The user ticked or unticked the box that gates the deletion for a numberless account. */
|
||||
data class ConfirmationCheckedChanged(val checked: Boolean) : DeleteAccountEvent
|
||||
|
||||
/** The user confirmed the deletion, either from the confirmation dialog or by retrying a failed one. */
|
||||
data object DeletionConfirmed : DeleteAccountEvent
|
||||
|
||||
|
||||
+13
-2
@@ -8,9 +8,12 @@ package org.signal.appsettings.deleteaccount
|
||||
import org.signal.core.util.censor
|
||||
|
||||
/**
|
||||
* Everything [DeleteAccountScreen] needs to render.
|
||||
* Everything [DeleteAccountWithNumberScreen] and [DeleteAccountWithoutNumberScreen] need to render. Which of the two
|
||||
* is shown comes down to [hasPhoneNumber], and the fields each one leans on are called out below.
|
||||
*/
|
||||
data class DeleteAccountState(
|
||||
/** Whether this account has a phone number, which is what decides which of the two screens the user sees. */
|
||||
val hasPhoneNumber: Boolean = true,
|
||||
/** The region the phone number is being entered for, or "ZZ" while we don't know it. */
|
||||
val regionCode: String = UNKNOWN_REGION,
|
||||
/** The display name of [regionCode], which is empty until the user picks a country we recognize. */
|
||||
@@ -21,12 +24,14 @@ data class DeleteAccountState(
|
||||
val nationalNumber: String = "",
|
||||
/** The national number as the user sees it, formatted for [regionCode]. */
|
||||
val formattedNumber: String = "",
|
||||
/** The user's username, or null when they don't have one. Only shown on [DeleteAccountWithoutNumberScreen]. */
|
||||
val username: String? = null,
|
||||
/** The user's payments balance, formatted for display, or null when they have nothing in there. */
|
||||
val walletBalance: String? = null,
|
||||
val dialog: Dialog = Dialog.None
|
||||
) {
|
||||
|
||||
override fun toString(): String = "DeleteAccountState(regionCode=$regionCode, countryDisplayName=$countryDisplayName, countryCode=$countryCode, nationalNumber=${nationalNumber.censor()}, formattedNumber=${formattedNumber.censor()}, walletBalance=${walletBalance?.censor()}, dialog=$dialog)"
|
||||
override fun toString(): String = "DeleteAccountState(hasPhoneNumber=$hasPhoneNumber, regionCode=$regionCode, countryDisplayName=$countryDisplayName, countryCode=$countryCode, nationalNumber=${nationalNumber.censor()}, formattedNumber=${formattedNumber.censor()}, username=${username?.censor()}, walletBalance=${walletBalance?.censor()}, dialog=$dialog)"
|
||||
|
||||
/** Whichever dialog the screen is showing, if any. Only one is ever up at a time. */
|
||||
sealed interface Dialog {
|
||||
@@ -38,6 +43,12 @@ data class DeleteAccountState(
|
||||
/** Asks the user to confirm that they really do want their account deleted. */
|
||||
data object ConfirmDeletion : Dialog
|
||||
|
||||
/**
|
||||
* Asks a numberless user to confirm that they really do want their account deleted. They have no number to key in,
|
||||
* so instead they have to tick a box before we'll let them go ahead.
|
||||
*/
|
||||
data class ConfirmNumberlessDeletion(val confirmationChecked: Boolean = false) : Dialog
|
||||
|
||||
/** Deletion is underway and we're canceling the user's donation subscription. */
|
||||
data object CancelingSubscription : Dialog
|
||||
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.appsettings.deleteaccount
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
|
||||
/**
|
||||
* Tags for both delete account screens and the dialogs they share.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
object DeleteAccountTestTags {
|
||||
const val SCROLLER = "scroller"
|
||||
const val ROW_COUNTRY_PICKER = "row-country-picker"
|
||||
const val FIELD_COUNTRY_CODE = "field-country-code"
|
||||
const val FIELD_NUMBER = "field-number"
|
||||
const val BUTTON_DELETE = "button-delete"
|
||||
const val DIALOG_NUMBER_DOES_NOT_MATCH = "dialog-number-does-not-match"
|
||||
const val DIALOG_CONFIRM_DELETION = "dialog-confirm-deletion"
|
||||
const val DIALOG_DELETION_FAILED = "dialog-deletion-failed"
|
||||
const val DIALOG_LOCAL_DATA_DELETION_FAILED = "dialog-local-data-deletion-failed"
|
||||
const val DIALOG_PROGRESS = "dialog-progress"
|
||||
|
||||
const val NUMBERLESS_SCROLLER = "numberless-scroller"
|
||||
const val NUMBERLESS_BUTTON_DELETE = "numberless-button-delete"
|
||||
const val NUMBERLESS_DIALOG_CONFIRM_DELETION = "numberless-dialog-confirm-deletion"
|
||||
const val NUMBERLESS_ROW_CONFIRMATION = "numberless-row-confirmation"
|
||||
const val NUMBERLESS_BUTTON_CONFIRM = "numberless-button-confirm"
|
||||
}
|
||||
+27
-164
@@ -5,7 +5,6 @@
|
||||
|
||||
package org.signal.appsettings.deleteaccount
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
@@ -14,7 +13,6 @@ 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.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@@ -26,7 +24,6 @@ import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -43,7 +40,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.TextRange
|
||||
@@ -51,9 +47,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import org.signal.appsettings.R
|
||||
import org.signal.appsettings.deleteaccount.DeleteAccountState.Dialog
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
@@ -62,24 +56,12 @@ import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.core.ui.compose.TextFields
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
@VisibleForTesting
|
||||
object DeleteAccountTestTags {
|
||||
const val SCROLLER = "scroller"
|
||||
const val ROW_COUNTRY_PICKER = "row-country-picker"
|
||||
const val FIELD_COUNTRY_CODE = "field-country-code"
|
||||
const val FIELD_NUMBER = "field-number"
|
||||
const val BUTTON_DELETE = "button-delete"
|
||||
const val DIALOG_NUMBER_DOES_NOT_MATCH = "dialog-number-does-not-match"
|
||||
const val DIALOG_CONFIRM_DELETION = "dialog-confirm-deletion"
|
||||
const val DIALOG_DELETION_FAILED = "dialog-deletion-failed"
|
||||
const val DIALOG_LOCAL_DATA_DELETION_FAILED = "dialog-local-data-deletion-failed"
|
||||
const val DIALOG_PROGRESS = "dialog-progress"
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets a user with a phone number delete their account, which they confirm by keying that number back in.
|
||||
*/
|
||||
@Composable
|
||||
fun DeleteAccountScreen(
|
||||
fun DeleteAccountWithNumberScreen(
|
||||
state: DeleteAccountState,
|
||||
onEvent: (DeleteAccountEvent) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
@@ -159,6 +141,19 @@ fun DeleteAccountScreen(
|
||||
}
|
||||
|
||||
DeleteAccountDialogs(dialog = state.dialog, onEvent = onEvent)
|
||||
|
||||
if (state.dialog == Dialog.ConfirmDeletion) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.DeleteAccountFragment__are_you_sure),
|
||||
body = stringResource(R.string.DeleteAccountFragment__this_will_delete_your_signal_account),
|
||||
confirm = stringResource(R.string.DeleteAccountFragment__delete_account),
|
||||
dismiss = stringResource(android.R.string.cancel),
|
||||
confirmColor = MaterialTheme.colorScheme.error,
|
||||
onConfirm = { onEvent(DeleteAccountEvent.DeletionConfirmed) },
|
||||
onDismiss = { onEvent(DeleteAccountEvent.DialogDismissed) },
|
||||
modifier = Modifier.testTag(DeleteAccountTestTags.DIALOG_CONFIRM_DELETION)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,143 +293,11 @@ private fun DeleteButton(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeleteAccountDialogs(
|
||||
dialog: Dialog,
|
||||
onEvent: (DeleteAccountEvent) -> Unit
|
||||
) {
|
||||
when (dialog) {
|
||||
Dialog.None -> Unit
|
||||
|
||||
Dialog.NumberDoesNotMatch -> {
|
||||
Dialogs.SimpleMessageDialog(
|
||||
message = stringResource(R.string.DeleteAccountFragment__the_phone_number),
|
||||
dismiss = stringResource(android.R.string.ok),
|
||||
onDismiss = { onEvent(DeleteAccountEvent.DialogDismissed) },
|
||||
modifier = Modifier.testTag(DeleteAccountTestTags.DIALOG_NUMBER_DOES_NOT_MATCH)
|
||||
)
|
||||
}
|
||||
|
||||
Dialog.ConfirmDeletion -> {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.DeleteAccountFragment__are_you_sure),
|
||||
body = stringResource(R.string.DeleteAccountFragment__this_will_delete_your_signal_account),
|
||||
confirm = stringResource(R.string.DeleteAccountFragment__delete_account),
|
||||
dismiss = stringResource(android.R.string.cancel),
|
||||
confirmColor = MaterialTheme.colorScheme.error,
|
||||
onConfirm = { onEvent(DeleteAccountEvent.DeletionConfirmed) },
|
||||
onDismiss = { onEvent(DeleteAccountEvent.DialogDismissed) },
|
||||
modifier = Modifier.testTag(DeleteAccountTestTags.DIALOG_CONFIRM_DELETION)
|
||||
)
|
||||
}
|
||||
|
||||
Dialog.DeletionFailed -> {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.DeleteAccountFragment__account_not_deleted),
|
||||
body = stringResource(R.string.DeleteAccountFragment__there_was_a_problem),
|
||||
confirm = stringResource(android.R.string.ok),
|
||||
dismiss = stringResource(android.R.string.cancel),
|
||||
onConfirm = { onEvent(DeleteAccountEvent.DeletionConfirmed) },
|
||||
onDismiss = { onEvent(DeleteAccountEvent.DialogDismissed) },
|
||||
modifier = Modifier.testTag(DeleteAccountTestTags.DIALOG_DELETION_FAILED)
|
||||
)
|
||||
}
|
||||
|
||||
Dialog.LocalDataDeletionFailed -> {
|
||||
Dialogs.SimpleMessageDialog(
|
||||
message = stringResource(R.string.DeleteAccountFragment__failed_to_delete_local_data),
|
||||
dismiss = stringResource(R.string.DeleteAccountFragment__launch_app_settings),
|
||||
onDismiss = { onEvent(DeleteAccountEvent.LaunchAppSettingsClicked) },
|
||||
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false),
|
||||
modifier = Modifier.testTag(DeleteAccountTestTags.DIALOG_LOCAL_DATA_DELETION_FAILED)
|
||||
)
|
||||
}
|
||||
|
||||
Dialog.CancelingSubscription -> {
|
||||
ProgressDialog(
|
||||
title = stringResource(R.string.DeleteAccountFragment__deleting_account),
|
||||
message = stringResource(R.string.DeleteAccountFragment__canceling_your_subscription),
|
||||
progress = null
|
||||
)
|
||||
}
|
||||
|
||||
is Dialog.LeavingGroups -> {
|
||||
ProgressDialog(
|
||||
title = stringResource(R.string.DeleteAccountFragment__leaving_groups),
|
||||
message = stringResource(R.string.DeleteAccountFragment__depending_on_the_number_of_groups),
|
||||
progress = if (dialog.totalCount > 0) dialog.leaveCount.toFloat() / dialog.totalCount else null
|
||||
)
|
||||
}
|
||||
|
||||
Dialog.DeletingAccount -> {
|
||||
ProgressDialog(
|
||||
title = stringResource(R.string.DeleteAccountFragment__deleting_account),
|
||||
message = stringResource(R.string.DeleteAccountFragment__deleting_all_user_data_and_resetting),
|
||||
progress = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-dismissable spinner shown for the length of the deletion, which reports what part of it is underway.
|
||||
*/
|
||||
@Composable
|
||||
private fun ProgressDialog(
|
||||
title: String,
|
||||
message: String,
|
||||
progress: Float?
|
||||
) {
|
||||
Dialogs.BaseAlertDialog(
|
||||
onDismissRequest = {},
|
||||
confirmButton = {},
|
||||
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false),
|
||||
text = {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
if (progress == null) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(48.dp))
|
||||
} else {
|
||||
CircularProgressIndicator(progress = { progress }, modifier = Modifier.size(48.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter))
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter))
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
},
|
||||
modifier = Modifier.testTag(DeleteAccountTestTags.DIALOG_PROGRESS)
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun DeleteAccountScreenPreview() {
|
||||
private fun DeleteAccountWithNumberScreenPreview() {
|
||||
Previews.Preview {
|
||||
DeleteAccountScreen(
|
||||
DeleteAccountWithNumberScreen(
|
||||
state = DeleteAccountState(),
|
||||
onEvent = {}
|
||||
)
|
||||
@@ -443,9 +306,9 @@ private fun DeleteAccountScreenPreview() {
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun DeleteAccountScreenFilledPreview() {
|
||||
private fun DeleteAccountWithNumberScreenFilledPreview() {
|
||||
Previews.Preview {
|
||||
DeleteAccountScreen(
|
||||
DeleteAccountWithNumberScreen(
|
||||
state = DeleteAccountState(
|
||||
regionCode = "US",
|
||||
countryDisplayName = "United States",
|
||||
@@ -461,9 +324,9 @@ private fun DeleteAccountScreenFilledPreview() {
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun DeleteAccountScreenConfirmDeletionPreview() {
|
||||
private fun DeleteAccountWithNumberScreenConfirmDeletionPreview() {
|
||||
Previews.Preview {
|
||||
DeleteAccountScreen(
|
||||
DeleteAccountWithNumberScreen(
|
||||
state = DeleteAccountState(dialog = Dialog.ConfirmDeletion),
|
||||
onEvent = {}
|
||||
)
|
||||
@@ -472,9 +335,9 @@ private fun DeleteAccountScreenConfirmDeletionPreview() {
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun DeleteAccountScreenLeavingGroupsPreview() {
|
||||
private fun DeleteAccountWithNumberScreenLeavingGroupsPreview() {
|
||||
Previews.Preview {
|
||||
DeleteAccountScreen(
|
||||
DeleteAccountWithNumberScreen(
|
||||
state = DeleteAccountState(dialog = Dialog.LeavingGroups(totalCount = 10, leaveCount = 3)),
|
||||
onEvent = {}
|
||||
)
|
||||
@@ -483,9 +346,9 @@ private fun DeleteAccountScreenLeavingGroupsPreview() {
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun DeleteAccountScreenDeletionFailedPreview() {
|
||||
private fun DeleteAccountWithNumberScreenDeletionFailedPreview() {
|
||||
Previews.Preview {
|
||||
DeleteAccountScreen(
|
||||
DeleteAccountWithNumberScreen(
|
||||
state = DeleteAccountState(dialog = Dialog.DeletionFailed),
|
||||
onEvent = {}
|
||||
)
|
||||
+298
@@ -0,0 +1,298 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.appsettings.deleteaccount
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.appsettings.R
|
||||
import org.signal.appsettings.deleteaccount.DeleteAccountState.Dialog
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
|
||||
/**
|
||||
* Lets a user with no phone number delete their account. There's no number for them to key in, so they confirm by
|
||||
* ticking a box in the dialog instead. Their username is worked into the copy when they have one, since it goes away
|
||||
* with the account.
|
||||
*/
|
||||
@Composable
|
||||
fun DeleteAccountWithoutNumberScreen(
|
||||
state: DeleteAccountState,
|
||||
onEvent: (DeleteAccountEvent) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = "",
|
||||
onNavigationClick = { onEvent(DeleteAccountEvent.NavigateBackClicked) },
|
||||
navigationIcon = SignalIcons.ArrowStart.imageVector,
|
||||
modifier = modifier
|
||||
) { contentPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(contentPadding)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp)
|
||||
.testTag(DeleteAccountTestTags.NUMBERLESS_SCROLLER)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_delete_account_warning_40),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier
|
||||
.padding(top = 24.dp)
|
||||
.size(40.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.DeleteAccountFragment__delete_account),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = if (state.username != null) {
|
||||
stringResource(R.string.DeleteAccountScreen__deleting_your_account_with_username_s_will, state.username)
|
||||
} else {
|
||||
stringResource(R.string.DeleteAccountFragment__deleting_your_account_will)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
)
|
||||
|
||||
Bullets(
|
||||
username = state.username,
|
||||
walletBalance = state.walletBalance,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
Buttons.LargePrimary(
|
||||
onClick = { onEvent(DeleteAccountEvent.DeleteAccountClicked) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 40.dp)
|
||||
.padding(top = 16.dp, bottom = 32.dp)
|
||||
.testTag(DeleteAccountTestTags.NUMBERLESS_BUTTON_DELETE)
|
||||
) {
|
||||
Text(text = stringResource(R.string.DeleteAccountFragment__delete_account))
|
||||
}
|
||||
}
|
||||
|
||||
DeleteAccountDialogs(dialog = state.dialog, onEvent = onEvent)
|
||||
|
||||
val dialog = state.dialog
|
||||
if (dialog is Dialog.ConfirmNumberlessDeletion) {
|
||||
ConfirmDeletionDialog(
|
||||
confirmationChecked = dialog.confirmationChecked,
|
||||
username = state.username,
|
||||
onEvent = onEvent
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Bullets(
|
||||
username: String?,
|
||||
walletBalance: String?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = modifier
|
||||
) {
|
||||
Bullet(
|
||||
text = if (username != null) {
|
||||
stringResource(R.string.DeleteAccountScreen__delete_your_account_info_and_profile_photo_including_your_username_s, username)
|
||||
} else {
|
||||
stringResource(R.string.DeleteAccountFragment__delete_your_account_info_and_profile_photo)
|
||||
}
|
||||
)
|
||||
|
||||
Bullet(text = stringResource(R.string.DeleteAccountFragment__delete_all_your_messages))
|
||||
|
||||
if (walletBalance != null) {
|
||||
Bullet(text = stringResource(R.string.DeleteAccountFragment__delete_s_in_your_payments_account, walletBalance))
|
||||
}
|
||||
|
||||
Bullet(text = stringResource(R.string.DeleteAccountScreen__this_action_can_not_be_undone))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Bullet(text: String) {
|
||||
Row {
|
||||
Text(
|
||||
text = "•",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the user to confirm the deletion, which they can only go ahead with once they've ticked the box.
|
||||
*/
|
||||
@Composable
|
||||
private fun ConfirmDeletionDialog(
|
||||
confirmationChecked: Boolean,
|
||||
username: String?,
|
||||
onEvent: (DeleteAccountEvent) -> Unit
|
||||
) {
|
||||
Dialogs.BaseAlertDialog(
|
||||
onDismissRequest = { onEvent(DeleteAccountEvent.DialogDismissed) },
|
||||
title = { Text(text = stringResource(R.string.DeleteAccountFragment__are_you_sure)) },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = if (username != null) {
|
||||
stringResource(R.string.DeleteAccountScreen__this_will_delete_your_signal_account_with_username_s, username)
|
||||
} else {
|
||||
stringResource(R.string.DeleteAccountFragment__this_will_delete_your_signal_account)
|
||||
}
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(top = 16.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.toggleable(
|
||||
value = confirmationChecked,
|
||||
role = Role.Checkbox,
|
||||
onValueChange = { onEvent(DeleteAccountEvent.ConfirmationCheckedChanged(it)) }
|
||||
)
|
||||
.padding(vertical = 8.dp)
|
||||
.testTag(DeleteAccountTestTags.NUMBERLESS_ROW_CONFIRMATION)
|
||||
) {
|
||||
Checkbox(
|
||||
checked = confirmationChecked,
|
||||
onCheckedChange = null
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.DeleteAccountScreen__yes_delete_my_account),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = confirmationChecked,
|
||||
onClick = { onEvent(DeleteAccountEvent.DeletionConfirmed) },
|
||||
modifier = Modifier.testTag(DeleteAccountTestTags.NUMBERLESS_BUTTON_CONFIRM)
|
||||
) {
|
||||
Text(text = stringResource(R.string.DeleteAccountFragment__delete_account))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { onEvent(DeleteAccountEvent.DialogDismissed) }) {
|
||||
Text(text = stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
modifier = Modifier.testTag(DeleteAccountTestTags.NUMBERLESS_DIALOG_CONFIRM_DELETION)
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun DeleteAccountWithoutNumberScreenPreview() {
|
||||
Previews.Preview {
|
||||
DeleteAccountWithoutNumberScreen(
|
||||
state = DeleteAccountState(hasPhoneNumber = false),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun DeleteAccountWithoutNumberScreenUsernamePreview() {
|
||||
Previews.Preview {
|
||||
DeleteAccountWithoutNumberScreen(
|
||||
state = DeleteAccountState(hasPhoneNumber = false, username = "alice.01"),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun DeleteAccountWithoutNumberScreenConfirmDeletionPreview() {
|
||||
Previews.Preview {
|
||||
DeleteAccountWithoutNumberScreen(
|
||||
state = DeleteAccountState(hasPhoneNumber = false, dialog = Dialog.ConfirmNumberlessDeletion(confirmationChecked = true)),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun DeleteAccountWithoutNumberScreenConfirmDeletionUsernamePreview() {
|
||||
Previews.Preview {
|
||||
DeleteAccountWithoutNumberScreen(
|
||||
state = DeleteAccountState(hasPhoneNumber = false, username = "alice.01", dialog = Dialog.ConfirmNumberlessDeletion()),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -201,6 +201,16 @@
|
||||
<string name="DeleteAccountFragment__account_not_deleted">Account Not Deleted</string>
|
||||
<!-- Message of error dialog shown when a network error occurs during account deletion -->
|
||||
<string name="DeleteAccountFragment__there_was_a_problem">There was a problem completing the deletion process. Check your network connection and try again.</string>
|
||||
<!-- Body text above the bullet list on the delete account screen, shown when the account has a username. Placeholder is the username. -->
|
||||
<string name="DeleteAccountScreen__deleting_your_account_with_username_s_will">Deleting your account with username \"%1$s\" will:</string>
|
||||
<!-- Bullet point on the delete account screen, shown in place of the plain one when the account has a username. Placeholder is the username. -->
|
||||
<string name="DeleteAccountScreen__delete_your_account_info_and_profile_photo_including_your_username_s">Delete your account info and profile photo including your username \"%1$s\"</string>
|
||||
<!-- Bullet point on the delete account screen warning the user that the deletion is permanent -->
|
||||
<string name="DeleteAccountScreen__this_action_can_not_be_undone">This action can not be undone</string>
|
||||
<!-- Message of the deletion confirmation dialog, shown when the account has a username. Placeholder is the username. -->
|
||||
<string name="DeleteAccountScreen__this_will_delete_your_signal_account_with_username_s">This will delete your Signal account with username \"%1$s\" and reset the application. The app will close after the process is complete.</string>
|
||||
<!-- Label of the checkbox the user has to tick in the confirmation dialog before they can delete their account -->
|
||||
<string name="DeleteAccountScreen__yes_delete_my_account">Yes, delete my account</string>
|
||||
|
||||
<!-- Shared with the registration screens -->
|
||||
<string name="RegistrationActivity_select_your_country">Select your country</string>
|
||||
|
||||
+2
-2
@@ -29,7 +29,7 @@ import org.signal.appsettings.deleteaccount.DeleteAccountState.Dialog
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(application = Application::class)
|
||||
class DeleteAccountScreenTest {
|
||||
class DeleteAccountWithNumberScreenTest {
|
||||
|
||||
private val context: Application = RuntimeEnvironment.getApplication()
|
||||
|
||||
@@ -119,7 +119,7 @@ class DeleteAccountScreenTest {
|
||||
|
||||
private fun setContent(state: DeleteAccountState) {
|
||||
composeTestRule.setContent {
|
||||
DeleteAccountScreen(
|
||||
DeleteAccountWithNumberScreen(
|
||||
state = state,
|
||||
onEvent = { events += it }
|
||||
)
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.appsettings.deleteaccount
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.contains
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import org.signal.appsettings.R
|
||||
import org.signal.appsettings.deleteaccount.DeleteAccountState.Dialog
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(application = Application::class)
|
||||
class DeleteAccountWithoutNumberScreenTest {
|
||||
|
||||
private val context: Application = RuntimeEnvironment.getApplication()
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
private val events = mutableListOf<DeleteAccountEvent>()
|
||||
|
||||
@Test
|
||||
fun givenTheScreen_whenIClickDelete_thenIExpectDeleteAccountClickedEvent() {
|
||||
setContent(createState())
|
||||
|
||||
composeTestRule.onNodeWithTag(DeleteAccountTestTags.NUMBERLESS_BUTTON_DELETE).performClick()
|
||||
|
||||
assertThat(events).contains(DeleteAccountEvent.DeleteAccountClicked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenNoUsername_whenTheScreenIsShown_thenIExpectThePlainCopy() {
|
||||
setContent(createState())
|
||||
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.DeleteAccountFragment__deleting_your_account_will)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.DeleteAccountFragment__delete_your_account_info_and_profile_photo)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAUsername_whenTheScreenIsShown_thenIExpectItInTheCopy() {
|
||||
setContent(createState(username = "alice.01"))
|
||||
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.DeleteAccountScreen__deleting_your_account_with_username_s_will, "alice.01")).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.DeleteAccountScreen__delete_your_account_info_and_profile_photo_including_your_username_s, "alice.01")).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAWalletBalance_whenTheScreenIsShown_thenIExpectAPaymentsBullet() {
|
||||
setContent(createState(walletBalance = "0.1000 MOB"))
|
||||
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.DeleteAccountFragment__delete_s_in_your_payments_account, "0.1000 MOB")).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAnUncheckedConfirmationDialog_whenTheScreenIsShown_thenIExpectDeletionToBeBlocked() {
|
||||
setContent(createState(dialog = Dialog.ConfirmNumberlessDeletion()))
|
||||
|
||||
composeTestRule.onNodeWithTag(DeleteAccountTestTags.NUMBERLESS_BUTTON_CONFIRM).assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAnUncheckedConfirmationDialog_whenIClickTheCheckbox_thenIExpectConfirmationCheckedChangedEvent() {
|
||||
setContent(createState(dialog = Dialog.ConfirmNumberlessDeletion()))
|
||||
|
||||
composeTestRule.onNodeWithTag(DeleteAccountTestTags.NUMBERLESS_ROW_CONFIRMATION).performClick()
|
||||
|
||||
assertThat(events).contains(DeleteAccountEvent.ConfirmationCheckedChanged(true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenACheckedConfirmationDialog_whenIConfirm_thenIExpectDeletionConfirmedEvent() {
|
||||
setContent(createState(dialog = Dialog.ConfirmNumberlessDeletion(confirmationChecked = true)))
|
||||
|
||||
composeTestRule.onNodeWithTag(DeleteAccountTestTags.NUMBERLESS_BUTTON_CONFIRM).assertIsEnabled().performClick()
|
||||
|
||||
assertThat(events).contains(DeleteAccountEvent.DeletionConfirmed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenACheckedConfirmationDialog_whenIUncheckTheCheckbox_thenIExpectConfirmationCheckedChangedEvent() {
|
||||
setContent(createState(dialog = Dialog.ConfirmNumberlessDeletion(confirmationChecked = true)))
|
||||
|
||||
composeTestRule.onNodeWithTag(DeleteAccountTestTags.NUMBERLESS_ROW_CONFIRMATION).performClick()
|
||||
|
||||
assertThat(events).contains(DeleteAccountEvent.ConfirmationCheckedChanged(false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAUsername_whenTheConfirmationDialogIsShown_thenIExpectItInTheMessage() {
|
||||
setContent(createState(username = "alice.01", dialog = Dialog.ConfirmNumberlessDeletion()))
|
||||
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.DeleteAccountScreen__this_will_delete_your_signal_account_with_username_s, "alice.01")).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenGroupsAreBeingLeft_whenTheScreenIsShown_thenIExpectTheProgressDialog() {
|
||||
setContent(createState(dialog = Dialog.LeavingGroups(totalCount = 10, leaveCount = 3)))
|
||||
|
||||
composeTestRule.onNodeWithTag(DeleteAccountTestTags.DIALOG_PROGRESS).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.DeleteAccountFragment__leaving_groups)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenTheLocalDataFailureDialog_whenIClickLaunchAppSettings_thenIExpectLaunchAppSettingsClickedEvent() {
|
||||
setContent(createState(dialog = Dialog.LocalDataDeletionFailed))
|
||||
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.DeleteAccountFragment__launch_app_settings)).performClick()
|
||||
|
||||
assertThat(events).contains(DeleteAccountEvent.LaunchAppSettingsClicked)
|
||||
}
|
||||
|
||||
private fun setContent(state: DeleteAccountState) {
|
||||
composeTestRule.setContent {
|
||||
DeleteAccountWithoutNumberScreen(
|
||||
state = state,
|
||||
onEvent = { events += it }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createState(
|
||||
username: String? = null,
|
||||
walletBalance: String? = null,
|
||||
dialog: Dialog = Dialog.None
|
||||
): DeleteAccountState {
|
||||
return DeleteAccountState(
|
||||
hasPhoneNumber = false,
|
||||
username = username,
|
||||
walletBalance = walletBalance,
|
||||
dialog = dialog
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user