From 424ff4ad4da5456f24005c26c6a246edb90cab6c Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Fri, 18 Sep 2026 13:59:05 -0400 Subject: [PATCH] Add numberless delete account screen. --- .../securesms/delete/DeleteAccountFragment.kt | 21 +- .../delete/DeleteAccountRepository.kt | 6 + .../delete/DeleteAccountViewModel.kt | 27 +- .../delete/DeleteAccountViewModelTest.kt | 66 ++++ .../deleteaccount/DeleteAccountDialogs.kt | 155 +++++++++ .../deleteaccount/DeleteAccountEvent.kt | 3 + .../deleteaccount/DeleteAccountState.kt | 15 +- .../deleteaccount/DeleteAccountTestTags.kt | 31 ++ ...en.kt => DeleteAccountWithNumberScreen.kt} | 191 ++--------- .../DeleteAccountWithoutNumberScreen.kt | 298 ++++++++++++++++++ .../src/main/res/values/strings.xml | 10 + ...t => DeleteAccountWithNumberScreenTest.kt} | 4 +- .../DeleteAccountWithoutNumberScreenTest.kt | 149 +++++++++ 13 files changed, 799 insertions(+), 177 deletions(-) create mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountDialogs.kt create mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountTestTags.kt rename feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/{DeleteAccountScreen.kt => DeleteAccountWithNumberScreen.kt} (65%) create mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountWithoutNumberScreen.kt rename feature/app-settings/src/test/java/org/signal/appsettings/deleteaccount/{DeleteAccountScreenTest.kt => DeleteAccountWithNumberScreenTest.kt} (98%) create mode 100644 feature/app-settings/src/test/java/org/signal/appsettings/deleteaccount/DeleteAccountWithoutNumberScreenTest.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountFragment.kt index 53f2bf5ba9..5fac419de2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountFragment.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountRepository.kt index 2c12978992..871496c3ab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountRepository.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountViewModel.kt index ad94938404..7a2bd3d453 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountViewModel.kt @@ -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(Channel.BUFFERED) val state: StateFlow = _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) } diff --git a/app/src/test/java/org/thoughtcrime/securesms/delete/DeleteAccountViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/delete/DeleteAccountViewModelTest.kt index b340679151..4a63bf8ee9 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/delete/DeleteAccountViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/delete/DeleteAccountViewModelTest.kt @@ -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 diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountDialogs.kt b/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountDialogs.kt new file mode 100644 index 0000000000..a4b4cd195b --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountDialogs.kt @@ -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) + ) +} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountEvent.kt b/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountEvent.kt index ebae8112ce..e28e07be4e 100644 --- a/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountEvent.kt +++ b/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountEvent.kt @@ -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 diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountState.kt b/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountState.kt index 7489c1779e..ba200fb196 100644 --- a/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountState.kt +++ b/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountState.kt @@ -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 diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountTestTags.kt b/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountTestTags.kt new file mode 100644 index 0000000000..f502dff693 --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountTestTags.kt @@ -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" +} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountScreen.kt b/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountWithNumberScreen.kt similarity index 65% rename from feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountScreen.kt rename to feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountWithNumberScreen.kt index 253da59728..346c3d6918 100644 --- a/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountScreen.kt +++ b/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountWithNumberScreen.kt @@ -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 = {} ) diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountWithoutNumberScreen.kt b/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountWithoutNumberScreen.kt new file mode 100644 index 0000000000..bbcb48ca07 --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountWithoutNumberScreen.kt @@ -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 = {} + ) + } +} diff --git a/feature/app-settings/src/main/res/values/strings.xml b/feature/app-settings/src/main/res/values/strings.xml index 931625014d..c3262e31eb 100644 --- a/feature/app-settings/src/main/res/values/strings.xml +++ b/feature/app-settings/src/main/res/values/strings.xml @@ -201,6 +201,16 @@ Account Not Deleted There was a problem completing the deletion process. Check your network connection and try again. + + Deleting your account with username \"%1$s\" will: + + Delete your account info and profile photo including your username \"%1$s\" + + This action can not be undone + + This will delete your Signal account with username \"%1$s\" and reset the application. The app will close after the process is complete. + + Yes, delete my account Select your country diff --git a/feature/app-settings/src/test/java/org/signal/appsettings/deleteaccount/DeleteAccountScreenTest.kt b/feature/app-settings/src/test/java/org/signal/appsettings/deleteaccount/DeleteAccountWithNumberScreenTest.kt similarity index 98% rename from feature/app-settings/src/test/java/org/signal/appsettings/deleteaccount/DeleteAccountScreenTest.kt rename to feature/app-settings/src/test/java/org/signal/appsettings/deleteaccount/DeleteAccountWithNumberScreenTest.kt index 72d3ab1ff2..d32a05f909 100644 --- a/feature/app-settings/src/test/java/org/signal/appsettings/deleteaccount/DeleteAccountScreenTest.kt +++ b/feature/app-settings/src/test/java/org/signal/appsettings/deleteaccount/DeleteAccountWithNumberScreenTest.kt @@ -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 } ) diff --git a/feature/app-settings/src/test/java/org/signal/appsettings/deleteaccount/DeleteAccountWithoutNumberScreenTest.kt b/feature/app-settings/src/test/java/org/signal/appsettings/deleteaccount/DeleteAccountWithoutNumberScreenTest.kt new file mode 100644 index 0000000000..751df2c01a --- /dev/null +++ b/feature/app-settings/src/test/java/org/signal/appsettings/deleteaccount/DeleteAccountWithoutNumberScreenTest.kt @@ -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() + + @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 + ) + } +}