diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 21c800ebfc..7a6619aee1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -640,6 +640,9 @@ dependencies { testImplementation(testFixtures(project(":libsignal-service"))) testImplementation(testLibs.espresso.core) testImplementation(testLibs.kotlinx.coroutines.test) + testImplementation(libs.androidx.compose.ui.test.junit4) + + "perfImplementation"(libs.androidx.compose.ui.test.manifest) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt index 2b920521ab..a61fe14bda 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.components.settings.app.account import android.content.Context -import android.content.DialogInterface import android.content.Intent import android.graphics.Typeface import android.text.InputType @@ -10,21 +9,44 @@ import android.view.ViewGroup import android.widget.EditText import android.widget.TextView import android.widget.Toast +import androidx.annotation.ColorRes +import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AlertDialog import androidx.autofill.HintConstants +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource import androidx.core.app.DialogCompat -import androidx.core.content.ContextCompat import androidx.core.view.ViewCompat -import androidx.lifecycle.ViewModelProvider -import androidx.navigation.Navigation +import androidx.fragment.app.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.fragment.findNavController import com.google.android.material.button.MaterialButton import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar +import org.signal.core.ui.compose.Dialogs +import org.signal.core.ui.compose.Dividers +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.Rows +import org.signal.core.ui.compose.Scaffolds +import org.signal.core.ui.compose.SignalPreview +import org.signal.core.ui.compose.Texts import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.settings.DSLConfiguration -import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment -import org.thoughtcrime.securesms.components.settings.DSLSettingsText -import org.thoughtcrime.securesms.components.settings.configure +import org.thoughtcrime.securesms.compose.ComposeFragment import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore @@ -36,13 +58,12 @@ import org.thoughtcrime.securesms.registration.ui.RegistrationActivity import org.thoughtcrime.securesms.util.PlayStoreUtil import org.thoughtcrime.securesms.util.ServiceUtil import org.thoughtcrime.securesms.util.ViewUtil -import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.navigation.safeNavigate import org.whispersystems.signalservice.api.kbs.PinHashUtil -class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFragment__account) { +class AccountSettingsFragment : ComposeFragment() { - lateinit var viewModel: AccountSettingsViewModel + private val viewModel: AccountSettingsViewModel by viewModels() override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (requestCode == CreateSvrPinActivity.REQUEST_NEW_PIN && resultCode == CreateSvrPinActivity.RESULT_OK) { @@ -55,132 +76,15 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag viewModel.refreshState() } - override fun bindAdapter(adapter: MappingAdapter) { - viewModel = ViewModelProvider(this)[AccountSettingsViewModel::class.java] + @Composable + override fun FragmentContent() { + val state by viewModel.state.collectAsStateWithLifecycle() + val callbacks = remember { Callbacks() } - viewModel.state.observe(viewLifecycleOwner) { state -> - adapter.submitList(getConfiguration(state).toMappingModelList()) - } - } - - private fun getConfiguration(state: AccountSettingsState): DSLConfiguration { - return configure { - sectionHeaderPref(R.string.preferences_app_protection__signal_pin) - - @Suppress("DEPRECATION") - clickPref( - title = DSLSettingsText.from(if (state.hasPin || state.hasRestoredAep) R.string.preferences_app_protection__change_your_pin else R.string.preferences_app_protection__create_a_pin), - isEnabled = state.isNotDeprecatedOrUnregistered(), - onClick = { - if (state.hasPin) { - startActivityForResult(CreateSvrPinActivity.getIntentForPinChangeFromSettings(requireContext()), CreateSvrPinActivity.REQUEST_NEW_PIN) - } else { - startActivityForResult(CreateSvrPinActivity.getIntentForPinCreate(requireContext()), CreateSvrPinActivity.REQUEST_NEW_PIN) - } - } - ) - - switchPref( - title = DSLSettingsText.from(R.string.preferences_app_protection__pin_reminders), - summary = DSLSettingsText.from(R.string.AccountSettingsFragment__youll_be_asked_less_frequently), - isChecked = state.hasPin && state.pinRemindersEnabled, - isEnabled = state.hasPin && state.isNotDeprecatedOrUnregistered(), - onClick = { - setPinRemindersEnabled(!state.pinRemindersEnabled) - } - ) - - switchPref( - title = DSLSettingsText.from(R.string.preferences_app_protection__registration_lock), - summary = DSLSettingsText.from(R.string.AccountSettingsFragment__require_your_signal_pin), - isChecked = state.registrationLockEnabled, - isEnabled = state.hasPin && state.isNotDeprecatedOrUnregistered(), - onClick = { - setRegistrationLockEnabled(!state.registrationLockEnabled) - } - ) - - clickPref( - title = DSLSettingsText.from(R.string.preferences__advanced_pin_settings), - isEnabled = state.isNotDeprecatedOrUnregistered(), - onClick = { - Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_advancedPinSettingsActivity) - } - ) - - dividerPref() - - sectionHeaderPref(R.string.AccountSettingsFragment__account) - - if (SignalStore.account.isRegistered) { - clickPref( - title = DSLSettingsText.from(R.string.AccountSettingsFragment__change_phone_number), - isEnabled = state.isNotDeprecatedOrUnregistered(), - onClick = { - Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_changePhoneNumberFragment) - } - ) - } - - clickPref( - title = DSLSettingsText.from(R.string.preferences_chats__transfer_account), - summary = DSLSettingsText.from(R.string.preferences_chats__transfer_account_to_a_new_android_device), - isEnabled = state.canTransferWhileUnregistered || state.isNotDeprecatedOrUnregistered(), - onClick = { - Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_oldDeviceTransferActivity) - } - ) - - clickPref( - title = DSLSettingsText.from(R.string.AccountSettingsFragment__request_account_data), - isEnabled = state.isNotDeprecatedOrUnregistered(), - onClick = { - Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_exportAccountFragment) - } - ) - - if (!state.isNotDeprecatedOrUnregistered()) { - if (state.clientDeprecated) { - clickPref( - title = DSLSettingsText.from(R.string.preferences_account_update_signal), - onClick = { - PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext()) - } - ) - } else if (state.userUnregistered) { - clickPref( - title = DSLSettingsText.from(R.string.preferences_account_reregister), - onClick = { - startActivity(RegistrationActivity.newIntentForReRegistration(requireContext())) - } - ) - } - - clickPref( - title = DSLSettingsText.from(R.string.preferences_account_delete_all_data, ContextCompat.getColor(requireContext(), R.color.signal_alert_primary)), - onClick = { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.preferences_account_delete_all_data_confirmation_title) - .setMessage(R.string.preferences_account_delete_all_data_confirmation_message) - .setPositiveButton(R.string.preferences_account_delete_all_data_confirmation_proceed) { _: DialogInterface, _: Int -> - if (!ServiceUtil.getActivityManager(AppDependencies.application).clearApplicationUserData()) { - Toast.makeText(requireContext(), R.string.preferences_account_delete_all_data_failed, Toast.LENGTH_LONG).show() - } - } - .setNegativeButton(R.string.preferences_account_delete_all_data_confirmation_cancel, null) - .show() - } - ) - } - - clickPref( - title = DSLSettingsText.from(R.string.preferences__delete_account, ContextCompat.getColor(requireContext(), if (state.isNotDeprecatedOrUnregistered()) R.color.signal_alert_primary else R.color.signal_alert_primary_50)), - isEnabled = state.isNotDeprecatedOrUnregistered(), - onClick = { - Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_deleteAccountFragment) - } - ) - } + AccountSettingsScreen( + state = state, + callbacks = callbacks + ) } private fun setRegistrationLockEnabled(enabled: Boolean) { @@ -232,6 +136,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag pinEditText.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD changeKeyboard.setIconResource(PinKeyboardType.ALPHA_NUMERIC.iconResource) } + PinKeyboardType.ALPHA_NUMERIC -> { pinEditText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD changeKeyboard.setIconResource(PinKeyboardType.NUMERIC.iconResource) @@ -263,4 +168,323 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag viewModel.refreshState() } } + + private inner class Callbacks : AccountSettingsScreenCallbacks { + override fun onNavigationClick() { + activity?.onBackPressedDispatcher?.onBackPressed() + } + + @Suppress("DEPRECATION") + override fun onChangePinClick() { + startActivityForResult(CreateSvrPinActivity.getIntentForPinChangeFromSettings(requireContext()), CreateSvrPinActivity.REQUEST_NEW_PIN) + } + + @Suppress("DEPRECATION") + override fun onCreatePinClick() { + startActivityForResult(CreateSvrPinActivity.getIntentForPinCreate(requireContext()), CreateSvrPinActivity.REQUEST_NEW_PIN) + } + + override fun setPinRemindersEnabled(enabled: Boolean) { + this@AccountSettingsFragment.setPinRemindersEnabled(enabled) + } + + override fun setRegistrationLockEnabled(enabled: Boolean) { + this@AccountSettingsFragment.setRegistrationLockEnabled(enabled) + } + + override fun openAdvancedPinSettings() { + findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_advancedPinSettingsActivity) + } + + override fun openChangeNumberFlow() { + findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_changePhoneNumberFragment) + } + + override fun openDeviceTransferFlow() { + findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_oldDeviceTransferActivity) + } + + override fun openExportAccountDataFlow() { + findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_exportAccountFragment) + } + + override fun openUpdateAppFlow() { + PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext()) + } + + override fun openReRegistrationFlow() { + startActivity(RegistrationActivity.newIntentForReRegistration(requireContext())) + } + + override fun openDeleteAccountFlow() { + findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_deleteAccountFragment) + } + + override fun deleteAllData() { + if (!ServiceUtil.getActivityManager(AppDependencies.application).clearApplicationUserData()) { + Toast.makeText(requireContext(), R.string.preferences_account_delete_all_data_failed, Toast.LENGTH_LONG).show() + } + } + } +} + +@Stable +@VisibleForTesting +interface AccountSettingsScreenCallbacks { + + fun onNavigationClick() = Unit + fun onChangePinClick() = Unit + fun onCreatePinClick() = Unit + fun setPinRemindersEnabled(enabled: Boolean) = Unit + fun setRegistrationLockEnabled(enabled: Boolean) = Unit + fun openAdvancedPinSettings() = Unit + fun openChangeNumberFlow() = Unit + fun openDeviceTransferFlow() = Unit + fun openExportAccountDataFlow() = Unit + fun openUpdateAppFlow() = Unit + fun openReRegistrationFlow() = Unit + fun openDeleteAccountFlow() = Unit + fun deleteAllData() = Unit + + object Empty : AccountSettingsScreenCallbacks +} + +@VisibleForTesting +object AccountSettingsTestTags { + const val SCROLLER = "scroller" + const val ROW_MODIFY_PIN = "row-modify-pin" + const val ROW_PIN_REMINDER = "row-pin-reminder" + const val ROW_REGISTRATION_LOCK = "row-registration-lock" + const val ROW_ADVANCED_PIN_SETTINGS = "row-advanced-pin-settings" + const val ROW_CHANGE_PHONE_NUMBER = "row-change-phone-number" + const val ROW_TRANSFER_ACCOUNT = "row-transfer-account" + const val ROW_REQUEST_ACCOUNT_DATA = "row-request-account-data" + const val ROW_UPDATE_SIGNAL = "row-update-signal" + const val ROW_RE_REGISTER = "row-re-register" + const val ROW_DELETE_ALL_DATA = "row-delete-all-data" + const val ROW_DELETE_ACCOUNT = "row-delete-account" + const val DIALOG_CONFIRM_DELETE_ALL_DATA = "dialog-confirm-delete-all-data" +} + +@Composable +@VisibleForTesting +fun AccountSettingsScreen( + state: AccountSettingsState, + callbacks: AccountSettingsScreenCallbacks +) { + Scaffolds.Settings( + title = stringResource(R.string.AccountSettingsFragment__account), + onNavigationClick = callbacks::onNavigationClick, + navigationIcon = ImageVector.vectorResource(R.drawable.ic_arrow_left_24) + ) { contentPadding -> + LazyColumn( + modifier = Modifier.padding(contentPadding).testTag(AccountSettingsTestTags.SCROLLER) + ) { + item { + Texts.SectionHeader( + text = stringResource(R.string.preferences_app_protection__signal_pin) + ) + } + + item { + @StringRes val textId = if (state.hasPin || state.hasRestoredAep) { + R.string.preferences_app_protection__change_your_pin + } else { + R.string.preferences_app_protection__create_a_pin + } + + Rows.TextRow( + text = stringResource(textId), + enabled = state.isNotDeprecatedOrUnregistered(), + onClick = { + if (state.hasPin) { + callbacks.onChangePinClick() + } else { + callbacks.onCreatePinClick() + } + }, + modifier = Modifier.testTag(AccountSettingsTestTags.ROW_MODIFY_PIN) + ) + } + + item { + Rows.ToggleRow( + text = stringResource(R.string.preferences_app_protection__pin_reminders), + label = stringResource(R.string.AccountSettingsFragment__youll_be_asked_less_frequently), + checked = state.hasPin && state.pinRemindersEnabled, + enabled = state.hasPin && state.isNotDeprecatedOrUnregistered(), + onCheckChanged = callbacks::setPinRemindersEnabled, + modifier = Modifier.testTag(AccountSettingsTestTags.ROW_PIN_REMINDER) + ) + } + + item { + Rows.ToggleRow( + text = stringResource(R.string.preferences_app_protection__registration_lock), + label = stringResource(R.string.AccountSettingsFragment__require_your_signal_pin), + checked = state.registrationLockEnabled, + enabled = state.hasPin && state.isNotDeprecatedOrUnregistered(), + onCheckChanged = callbacks::setRegistrationLockEnabled, + modifier = Modifier.testTag(AccountSettingsTestTags.ROW_REGISTRATION_LOCK) + ) + } + + item { + Rows.TextRow( + text = stringResource(R.string.preferences__advanced_pin_settings), + enabled = state.isNotDeprecatedOrUnregistered(), + onClick = callbacks::openAdvancedPinSettings, + modifier = Modifier.testTag(AccountSettingsTestTags.ROW_ADVANCED_PIN_SETTINGS) + ) + } + + item { + Dividers.Default() + } + + item { + Texts.SectionHeader( + text = stringResource(R.string.AccountSettingsFragment__account) + ) + } + + if (!state.userUnregistered) { + item { + Rows.TextRow( + text = stringResource(R.string.AccountSettingsFragment__change_phone_number), + enabled = state.isNotDeprecatedOrUnregistered(), + onClick = callbacks::openChangeNumberFlow, + modifier = Modifier.testTag(AccountSettingsTestTags.ROW_CHANGE_PHONE_NUMBER) + ) + } + } + + item { + Rows.TextRow( + text = stringResource(R.string.preferences_chats__transfer_account), + label = stringResource(R.string.preferences_chats__transfer_account_to_a_new_android_device), + enabled = state.canTransferWhileUnregistered || state.isNotDeprecatedOrUnregistered(), + onClick = callbacks::openDeviceTransferFlow, + modifier = Modifier.testTag(AccountSettingsTestTags.ROW_TRANSFER_ACCOUNT) + ) + } + + item { + Rows.TextRow( + text = stringResource(R.string.AccountSettingsFragment__request_account_data), + enabled = state.isNotDeprecatedOrUnregistered(), + onClick = callbacks::openExportAccountDataFlow, + modifier = Modifier.testTag(AccountSettingsTestTags.ROW_REQUEST_ACCOUNT_DATA) + ) + } + + if (!state.isNotDeprecatedOrUnregistered()) { + if (state.clientDeprecated) { + item { + Rows.TextRow( + text = stringResource(R.string.preferences_account_update_signal), + onClick = callbacks::openUpdateAppFlow, + modifier = Modifier.testTag(AccountSettingsTestTags.ROW_UPDATE_SIGNAL) + ) + } + } else if (state.userUnregistered) { + item { + Rows.TextRow( + text = stringResource(R.string.preferences_account_reregister), + onClick = callbacks::openReRegistrationFlow, + modifier = Modifier.testTag(AccountSettingsTestTags.ROW_RE_REGISTER) + ) + } + } + + item { + var displayDialog by remember { mutableStateOf(false) } + + Rows.TextRow( + text = { + Text( + text = stringResource(R.string.preferences_account_delete_all_data), + style = MaterialTheme.typography.bodyLarge, + color = colorResource(R.color.signal_alert_primary) + ) + }, + onClick = { + displayDialog = true + }, + modifier = Modifier.testTag(AccountSettingsTestTags.ROW_DELETE_ALL_DATA) + ) + + if (displayDialog) { + DeleteAllDataConfirmationDialog( + onDismissRequest = { displayDialog = false }, + onConfirm = callbacks::deleteAllData + ) + } + } + } + + item { + @ColorRes val textColor = if (state.isNotDeprecatedOrUnregistered()) { + R.color.signal_alert_primary + } else { + R.color.signal_alert_primary_50 + } + + Rows.TextRow( + text = { + Text( + text = stringResource(R.string.preferences__delete_account), + color = colorResource(textColor) + ) + }, + enabled = state.isNotDeprecatedOrUnregistered(), + onClick = callbacks::openDeleteAccountFlow, + modifier = Modifier.testTag(AccountSettingsTestTags.ROW_DELETE_ACCOUNT) + ) + } + } + } +} + +@Composable +private fun DeleteAllDataConfirmationDialog( + onConfirm: () -> Unit, + onDismissRequest: () -> Unit +) { + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.preferences_account_delete_all_data_confirmation_title), + body = stringResource(R.string.preferences_account_delete_all_data_confirmation_message), + confirm = stringResource(R.string.preferences_account_delete_all_data_confirmation_proceed), + onConfirm = onConfirm, + dismiss = stringResource(R.string.preferences_account_delete_all_data_confirmation_cancel), + onDismissRequest = onDismissRequest, + modifier = Modifier.testTag(AccountSettingsTestTags.DIALOG_CONFIRM_DELETE_ALL_DATA) + ) +} + +@SignalPreview +@Composable +private fun AccountSettingsScreenPreview() { + Previews.Preview { + AccountSettingsScreen( + state = AccountSettingsState( + hasPin = true, + hasRestoredAep = true, + pinRemindersEnabled = true, + registrationLockEnabled = true, + userUnregistered = false, + clientDeprecated = false, + canTransferWhileUnregistered = true + ), + callbacks = AccountSettingsScreenCallbacks.Empty + ) + } +} + +@SignalPreview +@Composable +private fun DeleteAllDataConfirmationDialogPreview() { + Previews.Preview { + DeleteAllDataConfirmationDialog({}, {}) + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsViewModel.kt index 08c20ef4e8..7ca0ffa42e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsViewModel.kt @@ -1,17 +1,18 @@ package org.thoughtcrime.securesms.components.settings.app.account -import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.util.RemoteConfig import org.thoughtcrime.securesms.util.TextSecurePreferences -import org.thoughtcrime.securesms.util.livedata.Store class AccountSettingsViewModel : ViewModel() { - private val store: Store = Store(getCurrentState()) + private val store: MutableStateFlow = MutableStateFlow(getCurrentState()) - val state: LiveData = store.stateLiveData + val state: StateFlow = store fun refreshState() { store.update { getCurrentState() } diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsScreenTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsScreenTest.kt new file mode 100644 index 0000000000..a45fdd69d0 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsScreenTest.kt @@ -0,0 +1,384 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.account + +import android.app.Application +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import io.mockk.mockk +import io.mockk.verify +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class AccountSettingsScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun givenANormalRegisteredUserWithAPin_whenIDisplayScreen_thenIExpectChangePinFlowToLaunch() { + val callback = mockk(relaxUnitFun = true) + val state = createState() + + composeTestRule.setContent { + AccountSettingsScreen( + state = state, + callbacks = callback + ) + } + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_MODIFY_PIN).performClick() + verify { callback.onChangePinClick() } + } + + @Test + fun givenANormalRegisteredUserWithoutAPin_whenIDisplayScreen_thenIExpectChangePinFlowToLaunch() { + val callback = mockk(relaxUnitFun = true) + val state = createState(hasPin = false) + + composeTestRule.setContent { + AccountSettingsScreen( + state = state, + callbacks = callback + ) + } + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_MODIFY_PIN).performClick() + verify { callback.onCreatePinClick() } + } + + @Test + fun givenUserWithPin_whenPinReminderToggleClicked_thenCallbackInvoked() { + val callback = mockk(relaxUnitFun = true) + val state = createState(hasPin = true, pinRemindersEnabled = true) + + composeTestRule.setContent { + AccountSettingsScreen(state = state, callbacks = callback) + } + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_PIN_REMINDER) + .assertIsDisplayed() + .assertIsEnabled() + .performClick() + + verify { callback.setPinRemindersEnabled(false) } + } + + @Test + fun givenUserWithoutPin_whenPinReminderDisplayed_thenRowIsDisabled() { + val callback = mockk(relaxUnitFun = true) + val state = createState(hasPin = false) + + composeTestRule.setContent { + AccountSettingsScreen(state = state, callbacks = callback) + } + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_PIN_REMINDER) + .assertIsDisplayed() + .assertIsNotEnabled() + } + + @Test + fun givenRegistrationLockEnabled_whenToggleClicked_thenCallbackInvoked() { + val callback = mockk(relaxUnitFun = true) + val state = createState(hasPin = true, registrationLockEnabled = true) + + composeTestRule.setContent { + AccountSettingsScreen(state = state, callbacks = callback) + } + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_REGISTRATION_LOCK) + .assertIsDisplayed() + .assertIsEnabled() + .performClick() + + verify { callback.setRegistrationLockEnabled(false) } + } + + @Test + fun givenUserWithoutPin_whenRegistrationLockDisplayed_thenRowIsDisabled() { + val callback = mockk(relaxUnitFun = true) + val state = createState(hasPin = false, registrationLockEnabled = false) + + composeTestRule.setContent { + AccountSettingsScreen(state = state, callbacks = callback) + } + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_REGISTRATION_LOCK) + .assertIsDisplayed() + .assertIsNotEnabled() + } + + @Test + fun givenNormalUser_whenAdvancedPinSettingsClicked_thenCallbackInvoked() { + val callback = mockk(relaxUnitFun = true) + val state = createState() + + composeTestRule.setContent { + AccountSettingsScreen(state = state, callbacks = callback) + } + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_ADVANCED_PIN_SETTINGS) + .assertIsDisplayed() + .assertIsEnabled() + .performClick() + + verify { callback.openAdvancedPinSettings() } + } + + @Test + fun givenRegisteredUser_whenChangePhoneNumberClicked_thenCallbackInvoked() { + val callback = mockk(relaxUnitFun = true) + val state = createState(userUnregistered = false) + + composeTestRule.setContent { + AccountSettingsScreen(state = state, callbacks = callback) + } + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.SCROLLER) + .performScrollToNode(hasTestTag(AccountSettingsTestTags.ROW_CHANGE_PHONE_NUMBER)) + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_CHANGE_PHONE_NUMBER) + .assertIsDisplayed() + .assertIsEnabled() + .performClick() + + verify { callback.openChangeNumberFlow() } + } + + @Test(expected = AssertionError::class) + fun whenUnregisteredUser_thenChangePhoneNumberNotDisplayed() { + val callback = mockk(relaxUnitFun = true) + val state = createState(userUnregistered = true) + + composeTestRule.setContent { + AccountSettingsScreen(state = state, callbacks = callback) + } + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.SCROLLER) + .performScrollToNode(hasTestTag(AccountSettingsTestTags.ROW_CHANGE_PHONE_NUMBER)) + } + + @Test + fun givenNormalUser_whenTransferAccountClicked_thenCallbackInvoked() { + val callback = mockk(relaxUnitFun = true) + val state = createState() + + composeTestRule.setContent { + AccountSettingsScreen(state = state, callbacks = callback) + } + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.SCROLLER) + .performScrollToNode(hasTestTag(AccountSettingsTestTags.ROW_TRANSFER_ACCOUNT)) + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_TRANSFER_ACCOUNT) + .assertIsDisplayed() + .assertIsEnabled() + .performClick() + + verify { callback.openDeviceTransferFlow() } + } + + @Test + fun givenNormalUser_whenRequestAccountDataClicked_thenCallbackInvoked() { + val callback = mockk(relaxUnitFun = true) + val state = createState() + + composeTestRule.setContent { + AccountSettingsScreen(state = state, callbacks = callback) + } + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.SCROLLER) + .performScrollToNode(hasTestTag(AccountSettingsTestTags.ROW_REQUEST_ACCOUNT_DATA)) + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_REQUEST_ACCOUNT_DATA) + .assertIsDisplayed() + .assertIsEnabled() + .performClick() + + verify { callback.openExportAccountDataFlow() } + } + + @Test + fun givenDeprecatedClient_whenUpdateSignalDisplayed_thenCallbackInvoked() { + val callback = mockk(relaxUnitFun = true) + val state = createState(clientDeprecated = true) + + composeTestRule.setContent { + AccountSettingsScreen(state = state, callbacks = callback) + } + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.SCROLLER) + .performScrollToNode(hasTestTag(AccountSettingsTestTags.ROW_UPDATE_SIGNAL)) + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_UPDATE_SIGNAL) + .assertIsDisplayed() + .assertHasClickAction() + .performClick() + + verify { callback.openUpdateAppFlow() } + } + + @Test + fun givenUnregisteredUser_whenReRegisterDisplayed_thenCallbackInvoked() { + val callback = mockk(relaxUnitFun = true) + val state = createState(userUnregistered = true) + + composeTestRule.setContent { + AccountSettingsScreen(state = state, callbacks = callback) + } + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.SCROLLER) + .performScrollToNode(hasTestTag(AccountSettingsTestTags.ROW_RE_REGISTER)) + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_RE_REGISTER) + .assertIsDisplayed() + .assertHasClickAction() + .performClick() + + verify { callback.openReRegistrationFlow() } + } + + @Test + fun givenDeprecatedClient_whenDeleteAllDataClicked_thenDialogDisplayed() { + val callback = mockk(relaxUnitFun = true) + val state = createState(clientDeprecated = true) + + composeTestRule.setContent { + AccountSettingsScreen(state = state, callbacks = callback) + } + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.SCROLLER) + .performScrollToNode(hasTestTag(AccountSettingsTestTags.ROW_DELETE_ALL_DATA)) + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_DELETE_ALL_DATA) + .assertIsDisplayed() + .performClick() + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.DIALOG_CONFIRM_DELETE_ALL_DATA) + .assertIsDisplayed() + } + + @Test + fun givenNormalUser_whenDeleteAccountClicked_thenCallbackInvoked() { + val callback = mockk(relaxUnitFun = true) + val state = createState() + + composeTestRule.setContent { + AccountSettingsScreen(state = state, callbacks = callback) + } + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.SCROLLER) + .performScrollToNode(hasTestTag(AccountSettingsTestTags.ROW_DELETE_ACCOUNT)) + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_DELETE_ACCOUNT) + .assertIsDisplayed() + .assertIsEnabled() + .performClick() + + verify { callback.openDeleteAccountFlow() } + } + + @Test + fun givenDeprecatedClient_whenDeleteAccountDisplayed_thenDisabled() { + val callback = mockk(relaxUnitFun = true) + val state = createState(clientDeprecated = true) + + composeTestRule.setContent { + AccountSettingsScreen(state = state, callbacks = callback) + } + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.SCROLLER) + .performScrollToNode(hasTestTag(AccountSettingsTestTags.ROW_DELETE_ACCOUNT)) + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_DELETE_ACCOUNT) + .assertIsDisplayed() + .assertIsNotEnabled() + } + + @Test + fun givenUnregisteredUser_whenDeleteAccountDisplayed_thenDisabled() { + val callback = mockk(relaxUnitFun = true) + val state = createState(userUnregistered = true) + + composeTestRule.setContent { + AccountSettingsScreen(state = state, callbacks = callback) + } + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.SCROLLER) + .performScrollToNode(hasTestTag(AccountSettingsTestTags.ROW_DELETE_ACCOUNT)) + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_DELETE_ACCOUNT) + .assertIsDisplayed() + .assertIsNotEnabled() + } + + @Test + fun whenUnregisteredButCanTransfer_thenTransferAccountEnabled() { + val callback = mockk(relaxUnitFun = true) + val state = createState(userUnregistered = true, canTransferWhileUnregistered = true) + + composeTestRule.setContent { + AccountSettingsScreen(state = state, callbacks = callback) + } + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.SCROLLER) + .performScrollToNode(hasTestTag(AccountSettingsTestTags.ROW_TRANSFER_ACCOUNT)) + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_TRANSFER_ACCOUNT) + .assertIsDisplayed() + .assertIsEnabled() + } + + @Test + fun givenUnregisteredAndCannotTransfer_whenTransferAccountDisabled() { + val callback = mockk(relaxUnitFun = true) + val state = createState(userUnregistered = true, canTransferWhileUnregistered = false) + + composeTestRule.setContent { + AccountSettingsScreen(state = state, callbacks = callback) + } + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.SCROLLER) + .performScrollToNode(hasTestTag(AccountSettingsTestTags.ROW_TRANSFER_ACCOUNT)) + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_TRANSFER_ACCOUNT) + .assertIsDisplayed() + .assertIsNotEnabled() + } + + private fun createState( + hasPin: Boolean = true, + hasRestoredAep: Boolean = true, + pinRemindersEnabled: Boolean = true, + registrationLockEnabled: Boolean = true, + userUnregistered: Boolean = false, + clientDeprecated: Boolean = false, + canTransferWhileUnregistered: Boolean = true + ): AccountSettingsState { + return AccountSettingsState( + hasPin = hasPin, + hasRestoredAep = hasRestoredAep, + pinRemindersEnabled = pinRemindersEnabled, + registrationLockEnabled = registrationLockEnabled, + userUnregistered = userUnregistered, + clientDeprecated = clientDeprecated, + canTransferWhileUnregistered = canTransferWhileUnregistered + ) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 3215ec6220..200e068a42 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -85,7 +85,7 @@ tasks.register("qa") { "clean", "checkStopship", "buildQa", - ":Signal-Android:testPlayProdReleaseUnitTest", + ":Signal-Android:testPlayProdPerfUnitTest", ":Signal-Android:lintPlayProdRelease", "Signal-Android:ktlintCheck", ":libsignal-service:test", diff --git a/core-ui/src/main/java/org/signal/core/ui/compose/Rows.kt b/core-ui/src/main/java/org/signal/core/ui/compose/Rows.kt index 0d862bd54f..9287bf4db0 100644 --- a/core-ui/src/main/java/org/signal/core/ui/compose/Rows.kt +++ b/core-ui/src/main/java/org/signal/core/ui/compose/Rows.kt @@ -158,9 +158,10 @@ object Rows { label: String? = null, icon: ImageVector? = null, textColor: Color = MaterialTheme.colorScheme.onSurface, + enabled: Boolean = true, isLoading: Boolean = false ) { - val enabled = !isLoading + val enabled = enabled && !isLoading Row( modifier = modifier