Convert AccountSettingsFragment from DSL to Compose.

This commit is contained in:
Alex Hart
2025-08-19 16:47:44 -03:00
committed by Jeffrey Starke
parent 71c34e17eb
commit dcce8ea35a
6 changed files with 755 additions and 142 deletions

View File

@@ -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)

View File

@@ -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({}, {})
}
}

View File

@@ -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<AccountSettingsState> = Store(getCurrentState())
private val store: MutableStateFlow<AccountSettingsState> = MutableStateFlow(getCurrentState())
val state: LiveData<AccountSettingsState> = store.stateLiveData
val state: StateFlow<AccountSettingsState> = store
fun refreshState() {
store.update { getCurrentState() }

View File

@@ -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<AccountSettingsScreenCallbacks>(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<AccountSettingsScreenCallbacks>(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<AccountSettingsScreenCallbacks>(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<AccountSettingsScreenCallbacks>(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<AccountSettingsScreenCallbacks>(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<AccountSettingsScreenCallbacks>(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<AccountSettingsScreenCallbacks>(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<AccountSettingsScreenCallbacks>(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<AccountSettingsScreenCallbacks>(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<AccountSettingsScreenCallbacks>(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<AccountSettingsScreenCallbacks>(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<AccountSettingsScreenCallbacks>(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<AccountSettingsScreenCallbacks>(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<AccountSettingsScreenCallbacks>(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<AccountSettingsScreenCallbacks>(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<AccountSettingsScreenCallbacks>(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<AccountSettingsScreenCallbacks>(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<AccountSettingsScreenCallbacks>(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<AccountSettingsScreenCallbacks>(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
)
}
}

View File

@@ -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",

View File

@@ -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