diff --git a/.editorconfig b/.editorconfig
index f2003881ee..bed625a051 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -14,9 +14,12 @@ ij_kotlin_allow_trailing_comma_on_call_site = true
ij_kotlin_name_count_to_use_star_import = 2147483647
ij_kotlin_name_count_to_use_star_import_for_members = 2147483647
ij_kotlin_packages_to_use_import_on_demand = unset
+ktlint_class_signature_rule_force_multiline_when_parameter_count_greater_or_equal_than = 1
ij_kotlin_line_break_after_multiline_when_entry = false
ktlint_code_style = android_studio
+ktlint_class_signature_rule_force_multiline_when_parameter_count_greater_or_equal_than = 1
ktlint_function_naming_ignore_when_annotated_with = Composable
+ktlint_standard_filename = disabled
ktlint_standard_function-expression-body = disabled
ktlint_standard_function-signature = disabled
ktlint_standard_trailing-comma-on-call-site = disabled
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 75ceec99ef..32796548c0 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -253,11 +253,11 @@
+ android:theme="@style/Theme.Compose"/>
().configureEach {
+ javaLauncher.set(
+ javaToolchains.launcherFor {
+ languageVersion.set(JavaLanguageVersion.of(21))
+ },
+ )
+}
+
detekt {
basePath.set(rootDir)
buildUponDefaultConfig = true
@@ -66,6 +74,13 @@ android {
res.directories.add("../res")
}
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ isReturnDefaultValues = true
+ }
+ }
+
lint {
abortOnError = false
disable += setOf("UnusedResources", "UnusedIds")
@@ -85,12 +100,14 @@ dependencies {
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.guava)
+ implementation(libs.kotlinx.immutable)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.material)
@@ -103,6 +120,7 @@ dependencies {
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.compose.ui.tooling)
+ testImplementation(platform(libs.androidx.compose.bom))
testImplementation(libs.junit4)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockk)
@@ -110,6 +128,7 @@ dependencies {
testImplementation(libs.mockk.android)
testImplementation(libs.robolectric)
testImplementation(libs.turbine)
+ testImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
diff --git a/app/src/test/kotlin/com/android/contacts/data/accounts/repository/AccountsRepositoryImplTest.kt b/app/src/test/kotlin/com/android/contacts/data/accounts/repository/AccountsRepositoryImplTest.kt
new file mode 100644
index 0000000000..be53a9caed
--- /dev/null
+++ b/app/src/test/kotlin/com/android/contacts/data/accounts/repository/AccountsRepositoryImplTest.kt
@@ -0,0 +1,109 @@
+package com.android.contacts.data.accounts.repository
+
+import com.android.contacts.model.AccountTypeManager
+import com.android.contacts.model.account.AccountInfo
+import com.android.contacts.model.account.AccountWithDataSet
+import com.android.contacts.preference.ContactsPreferences
+import com.google.common.util.concurrent.Futures
+import com.google.common.util.concurrent.ListenableFuture
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+class AccountsRepositoryImplTest {
+
+ private val contactsPreferences = mockk()
+ private val accountTypeManager = mockk()
+
+ private val repository = AccountsRepositoryImpl(
+ contactsPreferences = contactsPreferences,
+ accountTypeManager = accountTypeManager,
+ ioDispatcher = UnconfinedTestDispatcher(),
+ )
+
+ @Test
+ fun getDefaultAccountLabel_whenDefaultAccountIsWritable_returnsItsNameLabel() = runTest {
+ givenDefaultAccount(DEFAULT_ACCOUNT)
+ givenWritableAccounts(
+ accountInfo(OTHER_ACCOUNT, label = "other@example.org"),
+ accountInfo(DEFAULT_ACCOUNT, label = "Device"),
+ )
+
+ assertEquals("Device", repository.getDefaultAccountLabel())
+ }
+
+ @Test
+ fun getDefaultAccountLabel_whenThereIsNoDefaultAccount_returnsNull() = runTest {
+ givenDefaultAccount(null)
+
+ assertNull(repository.getDefaultAccountLabel())
+ }
+
+ @Test
+ fun getDefaultAccountLabel_whenDefaultAccountIsNotWritable_returnsNull() = runTest {
+ givenDefaultAccount(DEFAULT_ACCOUNT)
+ givenWritableAccounts(accountInfo(OTHER_ACCOUNT, label = "other@example.org"))
+
+ assertNull(repository.getDefaultAccountLabel())
+ }
+
+ @Test
+ fun getDefaultAccountLabel_whenThereAreNoWritableAccounts_returnsNull() = runTest {
+ givenDefaultAccount(DEFAULT_ACCOUNT)
+ givenWritableAccounts()
+
+ assertNull(repository.getDefaultAccountLabel())
+ }
+
+ @Test
+ fun getDefaultAccountLabel_whenLoadingAccountsFails_returnsNull() = runTest {
+ givenDefaultAccount(DEFAULT_ACCOUNT)
+ every { accountTypeManager.filterAccountsAsync(any()) } returns
+ Futures.immediateFailedFuture(IllegalStateException("accounts unavailable"))
+
+ assertNull(repository.getDefaultAccountLabel())
+ }
+
+ @Test
+ fun getDefaultAccountLabel_whenLoadingAccountsIsInterrupted_returnsNull() = runTest {
+ val future = mockk>>()
+ every { future.get() } throws InterruptedException()
+ givenDefaultAccount(DEFAULT_ACCOUNT)
+ every { accountTypeManager.filterAccountsAsync(any()) } returns future
+
+ assertNull(repository.getDefaultAccountLabel())
+ }
+
+ private fun givenDefaultAccount(account: AccountWithDataSet?) {
+ every { contactsPreferences.defaultAccount } returns account
+ }
+
+ private fun givenWritableAccounts(vararg accounts: AccountInfo) {
+ every { accountTypeManager.filterAccountsAsync(any()) } returns
+ Futures.immediateFuture(accounts.toList())
+ }
+
+ private fun accountInfo(
+ account: AccountWithDataSet,
+ label: String,
+ ): AccountInfo {
+ val accountInfo = mockk()
+ every { accountInfo.account } returns account
+ every { accountInfo.nameLabel } returns label
+ return accountInfo
+ }
+
+ private companion object {
+ val DEFAULT_ACCOUNT = AccountWithDataSet("default@example.org", "com.example", null)
+ val OTHER_ACCOUNT = AccountWithDataSet("other@example.org", "com.example", null)
+ }
+}
diff --git a/app/src/test/kotlin/com/android/contacts/data/appinfo/repository/AppInfoRepositoryImplTest.kt b/app/src/test/kotlin/com/android/contacts/data/appinfo/repository/AppInfoRepositoryImplTest.kt
new file mode 100644
index 0000000000..5ab86fd1ab
--- /dev/null
+++ b/app/src/test/kotlin/com/android/contacts/data/appinfo/repository/AppInfoRepositoryImplTest.kt
@@ -0,0 +1,70 @@
+package com.android.contacts.data.appinfo.repository
+
+import android.content.Context
+import android.content.pm.PackageInfo
+import android.content.pm.PackageManager
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+class AppInfoRepositoryImplTest {
+
+ private val context = mockk()
+ private val packageManager = mockk()
+
+ private val repository = AppInfoRepositoryImpl(
+ context = context,
+ packageManager = packageManager,
+ ioDispatcher = UnconfinedTestDispatcher(),
+ )
+
+ @Before
+ fun setUp() {
+ every { context.packageName } returns PACKAGE_NAME
+ }
+
+ @Test
+ fun getBuildVersion_returnsTheVersionOfTheInstalledPackage() = runTest {
+ val packageInfo = PackageInfo().apply { versionName = "1.7.40" }
+ givenPackageInfo { packageInfo }
+
+ assertEquals("1.7.40", repository.getBuildVersion())
+ }
+
+ @Test
+ fun getBuildVersion_whenPackageHasNoVersion_returnsNull() = runTest {
+ givenPackageInfo { PackageInfo() }
+
+ assertNull(repository.getBuildVersion())
+ }
+
+ @Test
+ fun getBuildVersion_whenPackageIsNotFound_returnsNull() = runTest {
+ givenPackageInfo { throw PackageManager.NameNotFoundException() }
+
+ assertNull(repository.getBuildVersion())
+ }
+
+ private fun givenPackageInfo(packageInfo: () -> PackageInfo) {
+ every {
+ packageManager.getPackageInfo(
+ PACKAGE_NAME,
+ any(),
+ )
+ } answers { packageInfo() }
+ }
+
+ private companion object {
+ const val PACKAGE_NAME = "com.android.contacts"
+ }
+}
diff --git a/app/src/test/kotlin/com/android/contacts/data/contactsfilter/repository/ContactsFilterRepositoryImplTest.kt b/app/src/test/kotlin/com/android/contacts/data/contactsfilter/repository/ContactsFilterRepositoryImplTest.kt
new file mode 100644
index 0000000000..0c6bc68315
--- /dev/null
+++ b/app/src/test/kotlin/com/android/contacts/data/contactsfilter/repository/ContactsFilterRepositoryImplTest.kt
@@ -0,0 +1,68 @@
+package com.android.contacts.data.contactsfilter.repository
+
+import com.android.contacts.data.contactsfilter.model.ContactsFilter
+import com.android.contacts.list.ContactListFilter
+import com.android.contacts.list.ContactListFilterController
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+class ContactsFilterRepositoryImplTest {
+
+ private val contactListFilterController = mockk()
+
+ private val repository = ContactsFilterRepositoryImpl(
+ contactListFilterController = contactListFilterController,
+ ioDispatcher = UnconfinedTestDispatcher(),
+ )
+
+ @Test
+ fun getContactsFilter_whenDefaultFilterIsPersisted_returnsAllAccounts() = runTest {
+ givenPersistedFilter(ContactListFilter.FILTER_TYPE_DEFAULT)
+
+ assertEquals(ContactsFilter.ALL_ACCOUNTS, repository.getContactsFilter())
+ }
+
+ @Test
+ fun getContactsFilter_whenAllAccountsFilterIsPersisted_returnsAllAccounts() = runTest {
+ givenPersistedFilter(ContactListFilter.FILTER_TYPE_ALL_ACCOUNTS)
+
+ assertEquals(ContactsFilter.ALL_ACCOUNTS, repository.getContactsFilter())
+ }
+
+ @Test
+ fun getContactsFilter_whenCustomFilterIsPersisted_returnsCustom() = runTest {
+ givenPersistedFilter(ContactListFilter.FILTER_TYPE_CUSTOM)
+
+ assertEquals(ContactsFilter.CUSTOM, repository.getContactsFilter())
+ }
+
+ @Test
+ fun getContactsFilter_whenAnotherFilterTypeIsPersisted_returnsNull() = runTest {
+ givenPersistedFilter(ContactListFilter.FILTER_TYPE_ACCOUNT)
+
+ assertNull(repository.getContactsFilter())
+ }
+
+ @Test
+ fun getContactsFilter_whenNothingIsPersisted_returnsNull() = runTest {
+ every { contactListFilterController.persistedFilter } returns null
+
+ assertNull(repository.getContactsFilter())
+ }
+
+ private fun givenPersistedFilter(filterType: Int) {
+ every {
+ contactListFilterController.persistedFilter
+ } returns ContactListFilter.createFilterWithType(filterType)
+ }
+}
diff --git a/app/src/test/kotlin/com/android/contacts/data/permissions/repository/PermissionsRepositoryImplTest.kt b/app/src/test/kotlin/com/android/contacts/data/permissions/repository/PermissionsRepositoryImplTest.kt
new file mode 100644
index 0000000000..b80a9d9d0f
--- /dev/null
+++ b/app/src/test/kotlin/com/android/contacts/data/permissions/repository/PermissionsRepositoryImplTest.kt
@@ -0,0 +1,39 @@
+package com.android.contacts.data.permissions.repository
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+internal class PermissionsRepositoryImplTest {
+
+ private val context = mockk()
+
+ private val repository = PermissionsRepositoryImpl(context = context)
+
+ @Test
+ fun isCallLogGranted_whenPermissionIsGranted_isTrue() = runTest {
+ givenCallLogPermission(PackageManager.PERMISSION_GRANTED)
+
+ assertTrue(repository.isCallLogGranted())
+ }
+
+ @Test
+ fun isCallLogGranted_whenPermissionIsDenied_isFalse() = runTest {
+ givenCallLogPermission(PackageManager.PERMISSION_DENIED)
+
+ assertFalse(repository.isCallLogGranted())
+ }
+
+ private fun givenCallLogPermission(result: Int) {
+ every { context.checkSelfPermission(Manifest.permission.READ_CALL_LOG) } returns result
+ }
+}
diff --git a/app/src/test/kotlin/com/android/contacts/data/profile/repository/ProfileRepositoryImplTest.kt b/app/src/test/kotlin/com/android/contacts/data/profile/repository/ProfileRepositoryImplTest.kt
new file mode 100644
index 0000000000..ee9e864523
--- /dev/null
+++ b/app/src/test/kotlin/com/android/contacts/data/profile/repository/ProfileRepositoryImplTest.kt
@@ -0,0 +1,251 @@
+package com.android.contacts.data.profile.repository
+
+import android.content.ContentResolver
+import android.database.ContentObserver
+import android.database.MatrixCursor
+import android.database.sqlite.SQLiteException
+import android.provider.ContactsContract.Contacts
+import android.provider.ContactsContract.DisplayNameSources
+import android.provider.ContactsContract.Profile
+import app.cash.turbine.test
+import com.android.contacts.data.profile.model.ProfileData
+import com.android.contacts.preference.ContactsPreferences
+import io.mockk.CapturingSlot
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+class ProfileRepositoryImplTest {
+
+ private val contentResolver = mockk(relaxed = true)
+ private val contactsPreferences = mockk(relaxed = true)
+ private val projectionSlot = slot>()
+
+ private val repository = ProfileRepositoryImpl(
+ contentResolver = contentResolver,
+ contactsPreferences = contactsPreferences,
+ ioDispatcher = UnconfinedTestDispatcher(),
+ )
+
+ @Before
+ fun setUp() {
+ every { contactsPreferences.displayOrder } returns ContactsPreferences.DISPLAY_ORDER_PRIMARY
+ givenProfileRows()
+ }
+
+ @Test
+ fun observeProfile_whenProfileExists_emitsProfileData() = runTest {
+ givenProfileRows(
+ profileRow(
+ contactId = 42L,
+ displayName = "Anna Smith",
+ isUserProfile = 1,
+ displayNameSource = DisplayNameSources.STRUCTURED_NAME,
+ ),
+ )
+
+ repository.observeProfile().test {
+ assertEquals(
+ ProfileData(
+ hasProfile = true,
+ contactId = 42L,
+ displayName = "Anna Smith",
+ isDisplayNameFromPhoneNumber = false,
+ ),
+ awaitItem(),
+ )
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun observeProfile_whenThereIsNoProfileRow_emitsEmptyProfileData() = runTest {
+ givenProfileRows()
+
+ repository.observeProfile().test {
+ assertEquals(ProfileData(), awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun observeProfile_whenRowIsNotTheUserProfile_reportsNoProfileButKeepsContactId() = runTest {
+ givenProfileRows(profileRow(contactId = 7L, isUserProfile = 0))
+
+ repository.observeProfile().test {
+ val profile = awaitItem()
+
+ assertFalse(profile.hasProfile)
+ assertEquals(7L, profile.contactId)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun observeProfile_whenDisplayNameComesFromPhoneNumber_marksItAsPhoneNumber() = runTest {
+ givenProfileRows(profileRow(displayNameSource = DisplayNameSources.PHONE))
+
+ repository.observeProfile().test {
+ assertTrue(awaitItem().isDisplayNameFromPhoneNumber)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun observeProfile_whenProfileHasNoName_emitsNullDisplayName() = runTest {
+ givenProfileRows(profileRow(displayName = null))
+
+ repository.observeProfile().test {
+ assertNull(awaitItem().displayName)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun observeProfile_whenProviderReturnsNoCursor_emitsEmptyProfileData() = runTest {
+ every { contentResolver.query(any(), any(), any(), any(), any()) } returns null
+
+ repository.observeProfile().test {
+ assertEquals(ProfileData(), awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun observeProfile_whenProviderFails_emitsEmptyProfileData() = runTest {
+ every {
+ contentResolver.query(any(), any(), any(), any(), any())
+ } throws SQLiteException("provider is down")
+
+ repository.observeProfile().test {
+ assertEquals(ProfileData(), awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun observeProfile_whenPermissionIsMissing_emitsEmptyProfileData() = runTest {
+ every {
+ contentResolver.query(any(), any(), any(), any(), any())
+ } throws SecurityException("no read permission")
+
+ repository.observeProfile().test {
+ assertEquals(ProfileData(), awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun observeProfile_whenDisplayOrderIsPrimary_queriesPrimaryDisplayName() = runTest {
+ every { contactsPreferences.displayOrder } returns ContactsPreferences.DISPLAY_ORDER_PRIMARY
+
+ repository.observeProfile().test {
+ awaitItem()
+ cancelAndIgnoreRemainingEvents()
+ }
+
+ assertArrayEquals(
+ arrayOf(
+ Contacts._ID,
+ Contacts.DISPLAY_NAME_PRIMARY,
+ Contacts.IS_USER_PROFILE,
+ Contacts.DISPLAY_NAME_SOURCE,
+ ),
+ projectionSlot.captured,
+ )
+ }
+
+ @Test
+ fun observeProfile_whenDisplayOrderIsAlternative_queriesAlternativeDisplayName() = runTest {
+ every {
+ contactsPreferences.displayOrder
+ } returns ContactsPreferences.DISPLAY_ORDER_ALTERNATIVE
+
+ repository.observeProfile().test {
+ awaitItem()
+ cancelAndIgnoreRemainingEvents()
+ }
+
+ assertEquals(Contacts.DISPLAY_NAME_ALTERNATIVE, projectionSlot.captured[1])
+ }
+
+ @Test
+ fun observeProfile_whenProfileChanges_emitsAgain() = runTest {
+ val observerSlot = givenRegisteredContentObserver()
+ givenProfileRows(profileRow(displayName = "Anna Smith"))
+
+ repository.observeProfile().test {
+ assertEquals("Anna Smith", awaitItem().displayName)
+
+ givenProfileRows(profileRow(displayName = "Anna Jones"))
+ observerSlot.captured.onChange(false)
+
+ assertEquals("Anna Jones", awaitItem().displayName)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun observeProfile_whenCollectionStops_unregistersObserver() = runTest {
+ val observerSlot = givenRegisteredContentObserver()
+
+ repository.observeProfile().test {
+ awaitItem()
+ cancelAndIgnoreRemainingEvents()
+ }
+
+ verify { contentResolver.unregisterContentObserver(observerSlot.captured) }
+ }
+
+ private fun givenRegisteredContentObserver(): CapturingSlot {
+ val observerSlot = slot()
+ every {
+ contentResolver.registerContentObserver(
+ Profile.CONTENT_URI,
+ true,
+ capture(observerSlot),
+ )
+ } returns Unit
+ return observerSlot
+ }
+
+ private fun givenProfileRows(vararg rows: Array) {
+ every {
+ contentResolver.query(
+ Profile.CONTENT_URI,
+ capture(projectionSlot),
+ any(),
+ any(),
+ any(),
+ )
+ } answers {
+ MatrixCursor(projectionSlot.captured).apply {
+ rows.forEach { addRow(it) }
+ }
+ }
+ }
+
+ private fun profileRow(
+ contactId: Long = 1L,
+ displayName: String? = "Anna Smith",
+ isUserProfile: Int = 1,
+ displayNameSource: Int = DisplayNameSources.STRUCTURED_NAME,
+ ): Array {
+ return arrayOf(contactId, displayName, isUserProfile, displayNameSource)
+ }
+}
diff --git a/app/src/test/kotlin/com/android/contacts/data/settings/repository/DisplaySettingsRepositoryImplTest.kt b/app/src/test/kotlin/com/android/contacts/data/settings/repository/DisplaySettingsRepositoryImplTest.kt
new file mode 100644
index 0000000000..f0c5c6ad15
--- /dev/null
+++ b/app/src/test/kotlin/com/android/contacts/data/settings/repository/DisplaySettingsRepositoryImplTest.kt
@@ -0,0 +1,267 @@
+package com.android.contacts.data.settings.repository
+
+import app.cash.turbine.test
+import com.android.contacts.data.settings.model.DisplayOrder
+import com.android.contacts.data.settings.model.DisplaySettings
+import com.android.contacts.data.settings.model.PhoneticNameDisplay
+import com.android.contacts.data.settings.model.SortOrder
+import com.android.contacts.preference.ContactsPreferences
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class DisplaySettingsRepositoryImplTest {
+
+ private val contactsPreferences = mockk(relaxed = true)
+
+ private val repository = DisplaySettingsRepositoryImpl(
+ contactsPreferences = contactsPreferences,
+ ioDispatcher = UnconfinedTestDispatcher(),
+ )
+
+ @Test
+ fun observeDisplaySettings_whenPrimaryValuesAreStored_mapsToGivenNameFirst() = runTest {
+ givenStoredValues(
+ sortOrder = ContactsPreferences.SORT_ORDER_PRIMARY,
+ displayOrder = ContactsPreferences.DISPLAY_ORDER_PRIMARY,
+ phoneticNameDisplay = ContactsPreferences.PHONETIC_NAME_DISPLAY_SHOW_ALWAYS,
+ )
+
+ repository.observeDisplaySettings().test {
+ val settings = awaitItem()
+
+ assertEquals(SortOrder.GIVEN_NAME_FIRST, settings.sortOrder)
+ assertEquals(DisplayOrder.GIVEN_NAME_FIRST, settings.displayOrder)
+ assertEquals(PhoneticNameDisplay.SHOW_ALWAYS, settings.phoneticNameDisplay)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun observeDisplaySettings_whenAlternativeValuesAreStored_mapsToFamilyNameFirst() = runTest {
+ givenStoredValues(
+ sortOrder = ContactsPreferences.SORT_ORDER_ALTERNATIVE,
+ displayOrder = ContactsPreferences.DISPLAY_ORDER_ALTERNATIVE,
+ phoneticNameDisplay = ContactsPreferences.PHONETIC_NAME_DISPLAY_HIDE_IF_EMPTY,
+ )
+
+ repository.observeDisplaySettings().test {
+ val settings = awaitItem()
+
+ assertEquals(SortOrder.FAMILY_NAME_FIRST, settings.sortOrder)
+ assertEquals(DisplayOrder.FAMILY_NAME_FIRST, settings.displayOrder)
+ assertEquals(PhoneticNameDisplay.HIDE_IF_EMPTY, settings.phoneticNameDisplay)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun observeDisplaySettings_whenStoredValuesAreUnknown_fallsBackToPrimaryOptions() = runTest {
+ givenStoredValues(
+ sortOrder = UNKNOWN_PREFERENCE_VALUE,
+ displayOrder = UNKNOWN_PREFERENCE_VALUE,
+ phoneticNameDisplay = UNKNOWN_PREFERENCE_VALUE,
+ )
+
+ repository.observeDisplaySettings().test {
+ val settings = awaitItem()
+
+ assertEquals(SortOrder.GIVEN_NAME_FIRST, settings.sortOrder)
+ assertEquals(DisplayOrder.GIVEN_NAME_FIRST, settings.displayOrder)
+ assertEquals(PhoneticNameDisplay.SHOW_ALWAYS, settings.phoneticNameDisplay)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun observeDisplaySettings_whenOptionsAreChangeable_reportsThemAsChangeable() = runTest {
+ givenStoredValues()
+ givenOptionsChangeable(true)
+
+ repository.observeDisplaySettings().test {
+ assertEquals(
+ DisplaySettings(
+ sortOrder = SortOrder.GIVEN_NAME_FIRST,
+ isSortOrderChangeable = true,
+ displayOrder = DisplayOrder.GIVEN_NAME_FIRST,
+ isDisplayOrderChangeable = true,
+ phoneticNameDisplay = PhoneticNameDisplay.SHOW_ALWAYS,
+ isPhoneticNameDisplayChangeable = true,
+ ),
+ awaitItem(),
+ )
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun observeDisplaySettings_whenOptionsAreLocked_reportsThemAsNotChangeable() = runTest {
+ givenStoredValues()
+ givenOptionsChangeable(false)
+
+ repository.observeDisplaySettings().test {
+ val settings = awaitItem()
+
+ assertFalse(settings.isSortOrderChangeable)
+ assertFalse(settings.isDisplayOrderChangeable)
+ assertFalse(settings.isPhoneticNameDisplayChangeable)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun observeDisplaySettings_whenASettingIsChanged_emitsTheStoredValueAgain() = runTest {
+ givenStoredValues()
+ givenSortOrderWritesAreStored()
+
+ repository.observeDisplaySettings().test {
+ assertEquals(SortOrder.GIVEN_NAME_FIRST, awaitItem().sortOrder)
+
+ repository.setSortOrder(SortOrder.FAMILY_NAME_FIRST)
+
+ assertEquals(SortOrder.FAMILY_NAME_FIRST, awaitItem().sortOrder)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun observeDisplaySettings_whenNothingIsWritten_doesNotEmitAgain() = runTest {
+ givenStoredValues(sortOrder = ContactsPreferences.SORT_ORDER_PRIMARY)
+
+ repository.observeDisplaySettings().test {
+ assertEquals(SortOrder.GIVEN_NAME_FIRST, awaitItem().sortOrder)
+
+ repository.setSortOrder(SortOrder.GIVEN_NAME_FIRST)
+
+ expectNoEvents()
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun setSortOrder_whenGivenNameFirstIsSelected_storesPrimaryValue() = runTest {
+ givenStoredValues(sortOrder = ContactsPreferences.SORT_ORDER_ALTERNATIVE)
+
+ repository.setSortOrder(SortOrder.GIVEN_NAME_FIRST)
+
+ verify { contactsPreferences.sortOrder = ContactsPreferences.SORT_ORDER_PRIMARY }
+ }
+
+ @Test
+ fun setSortOrder_whenFamilyNameFirstIsSelected_storesAlternativeValue() = runTest {
+ givenStoredValues(sortOrder = ContactsPreferences.SORT_ORDER_PRIMARY)
+
+ repository.setSortOrder(SortOrder.FAMILY_NAME_FIRST)
+
+ verify { contactsPreferences.sortOrder = ContactsPreferences.SORT_ORDER_ALTERNATIVE }
+ }
+
+ @Test
+ fun setSortOrder_whenSelectedValueIsAlreadyStored_doesNotWrite() = runTest {
+ givenStoredValues(sortOrder = ContactsPreferences.SORT_ORDER_PRIMARY)
+
+ repository.setSortOrder(SortOrder.GIVEN_NAME_FIRST)
+
+ verify(exactly = 0) { contactsPreferences.sortOrder = any() }
+ }
+
+ @Test
+ fun setDisplayOrder_whenGivenNameFirstIsSelected_storesPrimaryValue() = runTest {
+ givenStoredValues(displayOrder = ContactsPreferences.DISPLAY_ORDER_ALTERNATIVE)
+
+ repository.setDisplayOrder(DisplayOrder.GIVEN_NAME_FIRST)
+
+ verify { contactsPreferences.displayOrder = ContactsPreferences.DISPLAY_ORDER_PRIMARY }
+ }
+
+ @Test
+ fun setDisplayOrder_whenFamilyNameFirstIsSelected_storesAlternativeValue() = runTest {
+ givenStoredValues(displayOrder = ContactsPreferences.DISPLAY_ORDER_PRIMARY)
+
+ repository.setDisplayOrder(DisplayOrder.FAMILY_NAME_FIRST)
+
+ verify { contactsPreferences.displayOrder = ContactsPreferences.DISPLAY_ORDER_ALTERNATIVE }
+ }
+
+ @Test
+ fun setDisplayOrder_whenSelectedValueIsAlreadyStored_doesNotWrite() = runTest {
+ givenStoredValues(displayOrder = ContactsPreferences.DISPLAY_ORDER_PRIMARY)
+
+ repository.setDisplayOrder(DisplayOrder.GIVEN_NAME_FIRST)
+
+ verify(exactly = 0) { contactsPreferences.displayOrder = any() }
+ }
+
+ @Test
+ fun setPhoneticNameDisplay_whenShowAlwaysIsSelected_storesShowAlwaysValue() = runTest {
+ givenStoredValues(
+ phoneticNameDisplay = ContactsPreferences.PHONETIC_NAME_DISPLAY_HIDE_IF_EMPTY,
+ )
+
+ repository.setPhoneticNameDisplay(PhoneticNameDisplay.SHOW_ALWAYS)
+
+ verify {
+ contactsPreferences.phoneticNameDisplayPreference =
+ ContactsPreferences.PHONETIC_NAME_DISPLAY_SHOW_ALWAYS
+ }
+ }
+
+ @Test
+ fun setPhoneticNameDisplay_whenHideIfEmptyIsSelected_storesHideIfEmptyValue() = runTest {
+ givenStoredValues(
+ phoneticNameDisplay = ContactsPreferences.PHONETIC_NAME_DISPLAY_SHOW_ALWAYS,
+ )
+
+ repository.setPhoneticNameDisplay(PhoneticNameDisplay.HIDE_IF_EMPTY)
+
+ verify {
+ contactsPreferences.phoneticNameDisplayPreference =
+ ContactsPreferences.PHONETIC_NAME_DISPLAY_HIDE_IF_EMPTY
+ }
+ }
+
+ @Test
+ fun setPhoneticNameDisplay_whenSelectedValueIsAlreadyStored_doesNotWrite() = runTest {
+ givenStoredValues(
+ phoneticNameDisplay = ContactsPreferences.PHONETIC_NAME_DISPLAY_SHOW_ALWAYS,
+ )
+
+ repository.setPhoneticNameDisplay(PhoneticNameDisplay.SHOW_ALWAYS)
+
+ verify(exactly = 0) { contactsPreferences.phoneticNameDisplayPreference = any() }
+ }
+
+ private fun givenStoredValues(
+ sortOrder: Int = ContactsPreferences.SORT_ORDER_PRIMARY,
+ displayOrder: Int = ContactsPreferences.DISPLAY_ORDER_PRIMARY,
+ phoneticNameDisplay: Int = ContactsPreferences.PHONETIC_NAME_DISPLAY_SHOW_ALWAYS,
+ ) {
+ every { contactsPreferences.sortOrder } returns sortOrder
+ every { contactsPreferences.displayOrder } returns displayOrder
+ every { contactsPreferences.phoneticNameDisplayPreference } returns phoneticNameDisplay
+ }
+
+ private fun givenSortOrderWritesAreStored() {
+ var storedSortOrder = ContactsPreferences.SORT_ORDER_PRIMARY
+
+ every { contactsPreferences.sortOrder } answers { storedSortOrder }
+ every { contactsPreferences.sortOrder = any() } answers { storedSortOrder = firstArg() }
+ }
+
+ private fun givenOptionsChangeable(isChangeable: Boolean) {
+ every { contactsPreferences.isSortOrderUserChangeable } returns isChangeable
+ every { contactsPreferences.isDisplayOrderUserChangeable } returns isChangeable
+ every { contactsPreferences.isPhoneticNameDisplayPreferenceChangeable } returns isChangeable
+ }
+
+ private companion object {
+ const val UNKNOWN_PREFERENCE_VALUE = 42
+ }
+}
diff --git a/app/src/test/kotlin/com/android/contacts/data/settings/repository/SettingsAvailabilityRepositoryImplTest.kt b/app/src/test/kotlin/com/android/contacts/data/settings/repository/SettingsAvailabilityRepositoryImplTest.kt
new file mode 100644
index 0000000000..7256c51a8c
--- /dev/null
+++ b/app/src/test/kotlin/com/android/contacts/data/settings/repository/SettingsAvailabilityRepositoryImplTest.kt
@@ -0,0 +1,111 @@
+package com.android.contacts.data.settings.repository
+
+import android.content.Context
+import android.provider.BlockedNumberContract
+import android.provider.ContactsContract.ProviderStatus
+import android.telephony.TelephonyManager
+import com.android.contacts.compat.TelephonyManagerCompat
+import com.android.contacts.list.ProviderStatusWatcher
+import com.android.contactsbind.HelpUtils
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.unmockkAll
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+class SettingsAvailabilityRepositoryImplTest {
+
+ private val context = mockk(relaxed = true)
+ private val telephonyManager = mockk(relaxed = true)
+ private val providerStatusWatcher = mockk()
+
+ private val repository = SettingsAvailabilityRepositoryImpl(
+ context = context,
+ providerStatusWatcher = providerStatusWatcher,
+ telephonyManager = telephonyManager,
+ ioDispatcher = UnconfinedTestDispatcher(),
+ )
+
+ @Before
+ fun setUp() {
+ mockkStatic(TelephonyManagerCompat::class)
+ mockkStatic(BlockedNumberContract::class)
+ mockkStatic(HelpUtils::class)
+ every { providerStatusWatcher.providerStatus } returns ProviderStatus.STATUS_NORMAL
+ givenBlockedNumbersSupport(isVoiceCapable = true, canBlockNumbers = true)
+ every { HelpUtils.isHelpAndFeedbackAvailable() } returns false
+ }
+
+ @After
+ fun tearDown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun getSettingsAvailability_whenProviderStatusIsNormal_reportsContactsAsAvailable() = runTest {
+ assertTrue(repository.getSettingsAvailability().areContactsAvailable)
+ }
+
+ @Test
+ fun getSettingsAvailability_whenProviderStatusIsNotNormal_reportsContactsAsUnavailable() =
+ runTest {
+ every { providerStatusWatcher.providerStatus } returns ProviderStatus.STATUS_BUSY
+
+ assertFalse(repository.getSettingsAvailability().areContactsAvailable)
+ }
+
+ @Test
+ fun getSettingsAvailability_whenDeviceCanBlockNumbers_reportsBlockedNumbersAsAvailable() =
+ runTest {
+ assertTrue(repository.getSettingsAvailability().areBlockedNumbersAvailable)
+ }
+
+ @Test
+ fun getSettingsAvailability_whenDeviceIsNotVoiceCapable_reportsBlockedNumbersAsUnavailable() =
+ runTest {
+ givenBlockedNumbersSupport(isVoiceCapable = false, canBlockNumbers = true)
+
+ assertFalse(repository.getSettingsAvailability().areBlockedNumbersAvailable)
+ }
+
+ @Test
+ fun getSettingsAvailability_whenUserCannotBlockNumbers_reportsBlockedNumbersAsUnavailable() =
+ runTest {
+ givenBlockedNumbersSupport(isVoiceCapable = true, canBlockNumbers = false)
+
+ assertFalse(repository.getSettingsAvailability().areBlockedNumbersAvailable)
+ }
+
+ @Test
+ fun getSettingsAvailability_whenHelpAndFeedbackIsUnavailable_reportsAboutAsAvailable() =
+ runTest {
+ assertTrue(repository.getSettingsAvailability().isAboutAvailable)
+ }
+
+ @Test
+ fun getSettingsAvailability_whenHelpAndFeedbackIsAvailable_reportsAboutAsUnavailable() =
+ runTest {
+ every { HelpUtils.isHelpAndFeedbackAvailable() } returns true
+
+ assertFalse(repository.getSettingsAvailability().isAboutAvailable)
+ }
+
+ private fun givenBlockedNumbersSupport(
+ isVoiceCapable: Boolean,
+ canBlockNumbers: Boolean,
+ ) {
+ every { TelephonyManagerCompat.isVoiceCapable(telephonyManager) } returns isVoiceCapable
+ every { BlockedNumberContract.canCurrentUserBlockNumbers(context) } returns canBlockNumbers
+ }
+}
diff --git a/app/src/test/kotlin/com/android/contacts/data/simimport/repository/SimImportResultRepositoryImplTest.kt b/app/src/test/kotlin/com/android/contacts/data/simimport/repository/SimImportResultRepositoryImplTest.kt
new file mode 100644
index 0000000000..677476234f
--- /dev/null
+++ b/app/src/test/kotlin/com/android/contacts/data/simimport/repository/SimImportResultRepositoryImplTest.kt
@@ -0,0 +1,185 @@
+package com.android.contacts.data.simimport.repository
+
+import android.content.BroadcastReceiver
+import android.content.Intent
+import android.content.IntentFilter
+import androidx.localbroadcastmanager.content.LocalBroadcastManager
+import app.cash.turbine.TurbineTestContext
+import app.cash.turbine.test
+import com.android.contacts.SimImportService
+import com.android.contacts.data.simimport.model.SimImportResult
+import com.android.contacts.util.core.CurrentTimeProvider
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.runs
+import io.mockk.slot
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+class SimImportResultRepositoryImplTest {
+
+ private val localBroadcastManager = mockk(relaxed = true)
+ private val receiverSlot = slot()
+ private val intentFilterSlot = slot()
+
+ private val repository = SimImportResultRepositoryImpl(
+ localBroadcastManager = localBroadcastManager,
+ currentTimeProvider = CurrentTimeProvider { NOW_MILLIS },
+ )
+
+ @Before
+ fun setUp() {
+ every {
+ localBroadcastManager.registerReceiver(capture(receiverSlot), capture(intentFilterSlot))
+ } just runs
+ }
+
+ @Test
+ fun observeSimImportResults_whenImportSucceeded_emitsSuccessWithCount() = runTest {
+ observeResults {
+ sendResult(resultCode = SimImportService.RESULT_SUCCESS, count = 3)
+
+ assertEquals(SimImportResult.Success(importedCount = 3), awaitItem())
+ }
+ }
+
+ @Test
+ fun observeSimImportResults_whenNothingWasImported_emitsNothing() = runTest {
+ observeResults {
+ sendResult(resultCode = SimImportService.RESULT_SUCCESS, count = 0)
+
+ expectNoEvents()
+ }
+ }
+
+ @Test
+ fun observeSimImportResults_whenImportCountIsMissing_emitsNothing() = runTest {
+ observeResults {
+ sendResult(resultCode = SimImportService.RESULT_SUCCESS, count = null)
+
+ expectNoEvents()
+ }
+ }
+
+ @Test
+ fun observeSimImportResults_whenImportFailed_emitsFailure() = runTest {
+ observeResults {
+ sendResult(resultCode = SimImportService.RESULT_FAILURE)
+
+ assertEquals(SimImportResult.Failure, awaitItem())
+ }
+ }
+
+ @Test
+ fun observeSimImportResults_whenResultIsUnknown_emitsNothing() = runTest {
+ observeResults {
+ sendResult(resultCode = SimImportService.RESULT_UNKNOWN)
+
+ expectNoEvents()
+ }
+ }
+
+ @Test
+ fun observeSimImportResults_whenResultCodeIsMissing_emitsNothing() = runTest {
+ observeResults {
+ receiverSlot.captured.onReceive(null, Intent())
+
+ expectNoEvents()
+ }
+ }
+
+ @Test
+ fun observeSimImportResults_whenResultIsOlderThanThirtySeconds_emitsNothing() = runTest {
+ observeResults {
+ sendResult(
+ resultCode = SimImportService.RESULT_FAILURE,
+ requestedAtMillis = NOW_MILLIS - 30_001L,
+ )
+
+ expectNoEvents()
+ }
+ }
+
+ @Test
+ fun observeSimImportResults_whenResultIsExactlyThirtySecondsOld_emitsIt() = runTest {
+ observeResults {
+ sendResult(
+ resultCode = SimImportService.RESULT_FAILURE,
+ requestedAtMillis = NOW_MILLIS - 30_000L,
+ )
+
+ assertEquals(SimImportResult.Failure, awaitItem())
+ }
+ }
+
+ @Test
+ fun observeSimImportResults_whenRequestTimeIsMissing_emitsIt() = runTest {
+ observeResults {
+ sendResult(
+ resultCode = SimImportService.RESULT_FAILURE,
+ requestedAtMillis = null,
+ )
+
+ assertEquals(SimImportResult.Failure, awaitItem())
+ }
+ }
+
+ @Test
+ fun observeSimImportResults_whenCollected_registersReceiverForImportBroadcast() = runTest {
+ observeResults {}
+
+ assertEquals(
+ SimImportService.BROADCAST_SIM_IMPORT_COMPLETE,
+ intentFilterSlot.captured.getAction(0),
+ )
+ }
+
+ @Test
+ fun observeSimImportResults_whenCollectionStops_unregistersReceiver() = runTest {
+ observeResults {}
+
+ verify { localBroadcastManager.unregisterReceiver(receiverSlot.captured) }
+ }
+
+ private suspend fun TestScope.observeResults(
+ assertions: suspend TurbineTestContext.() -> Unit,
+ ) {
+ repository.observeSimImportResults().test {
+ runCurrent()
+ assertions()
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ private fun sendResult(
+ resultCode: Int,
+ count: Int? = 1,
+ requestedAtMillis: Long? = NOW_MILLIS,
+ ) {
+ val intent = Intent(SimImportService.BROADCAST_SIM_IMPORT_COMPLETE)
+ .putExtra(SimImportService.EXTRA_RESULT_CODE, resultCode)
+ count?.let {
+ intent.putExtra(SimImportService.EXTRA_RESULT_COUNT, it)
+ }
+ requestedAtMillis?.let {
+ intent.putExtra(SimImportService.EXTRA_OPERATION_REQUESTED_AT_TIME, it)
+ }
+
+ receiverSlot.captured.onReceive(null, intent)
+ }
+
+ private companion object {
+ const val NOW_MILLIS = 1_000_000L
+ }
+}
diff --git a/app/src/test/kotlin/com/android/contacts/domain/settings/usecase/GetSettingsDataImplTest.kt b/app/src/test/kotlin/com/android/contacts/domain/settings/usecase/GetSettingsDataImplTest.kt
new file mode 100644
index 0000000000..5928a21185
--- /dev/null
+++ b/app/src/test/kotlin/com/android/contacts/domain/settings/usecase/GetSettingsDataImplTest.kt
@@ -0,0 +1,150 @@
+package com.android.contacts.domain.settings.usecase
+
+import app.cash.turbine.test
+import com.android.contacts.data.accounts.repository.AccountsRepository
+import com.android.contacts.data.appinfo.repository.AppInfoRepository
+import com.android.contacts.data.contactsfilter.model.ContactsFilter
+import com.android.contacts.data.contactsfilter.repository.ContactsFilterRepository
+import com.android.contacts.data.permissions.repository.PermissionsRepository
+import com.android.contacts.data.settings.model.DisplayOrder
+import com.android.contacts.data.settings.model.DisplaySettings
+import com.android.contacts.data.settings.model.PhoneticNameDisplay
+import com.android.contacts.data.settings.model.SettingsAvailability
+import com.android.contacts.data.settings.model.SortOrder
+import com.android.contacts.data.settings.repository.DisplaySettingsRepository
+import com.android.contacts.data.settings.repository.SettingsAvailabilityRepository
+import com.android.contacts.domain.settings.model.SettingsData
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.async
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class GetSettingsDataImplTest {
+
+ private val settingsAvailabilityRepository = mockk()
+ private val displaySettingsRepository = mockk()
+ private val accountsRepository = mockk()
+ private val contactsFilterRepository = mockk()
+ private val appInfoRepository = mockk()
+ private val permissionsRepository = mockk()
+
+ private val useCase = GetSettingsDataImpl(
+ settingsAvailabilityRepository = settingsAvailabilityRepository,
+ displaySettingsRepository = displaySettingsRepository,
+ accountsRepository = accountsRepository,
+ contactsFilterRepository = contactsFilterRepository,
+ appInfoRepository = appInfoRepository,
+ permissionsRepository = permissionsRepository,
+ )
+
+ @Before
+ fun setUp() {
+ coEvery { settingsAvailabilityRepository.getSettingsAvailability() } returns AVAILABILITY
+ every { displaySettingsRepository.observeDisplaySettings() } returns
+ flowOf(DISPLAY_SETTINGS)
+ coEvery { accountsRepository.getDefaultAccountLabel() } returns "Device"
+ coEvery { contactsFilterRepository.getContactsFilter() } returns ContactsFilter.CUSTOM
+ coEvery { appInfoRepository.getBuildVersion() } returns BUILD_VERSION
+ coEvery { permissionsRepository.isCallLogGranted() } returns true
+ }
+
+ @Test
+ fun invoke_collectsEverySource() = runTest {
+ useCase().test {
+ assertEquals(
+ SettingsData(
+ availability = AVAILABILITY,
+ displaySettings = DISPLAY_SETTINGS,
+ defaultAccountLabel = "Device",
+ contactsFilter = ContactsFilter.CUSTOM,
+ buildVersion = BUILD_VERSION,
+ isCallLogPermissionGranted = true,
+ ),
+ awaitItem(),
+ )
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun invoke_whenThereIsNoDefaultAccountOrFilter_keepsThemNull() = runTest {
+ coEvery { accountsRepository.getDefaultAccountLabel() } returns null
+ coEvery { contactsFilterRepository.getContactsFilter() } returns null
+
+ useCase().test {
+ val settingsData = awaitItem()
+
+ assertNull(settingsData.defaultAccountLabel)
+ assertNull(settingsData.contactsFilter)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun invoke_readsSourcesInParallel() = runTest {
+ val slowAvailability = CompletableDeferred()
+ coEvery { settingsAvailabilityRepository.getSettingsAvailability() } coAnswers {
+ slowAvailability.await()
+ }
+
+ val settingsData = async { useCase().first() }
+ runCurrent()
+
+ coVerify(exactly = 1) { accountsRepository.getDefaultAccountLabel() }
+ coVerify(exactly = 1) { contactsFilterRepository.getContactsFilter() }
+ coVerify(exactly = 1) { appInfoRepository.getBuildVersion() }
+
+ slowAvailability.complete(AVAILABILITY)
+ assertEquals(AVAILABILITY, settingsData.await().availability)
+ }
+
+ @Test
+ fun invoke_whenDisplaySettingsChange_emitsTheDataAgain() = runTest {
+ val displaySettings = MutableSharedFlow(extraBufferCapacity = 1)
+ every { displaySettingsRepository.observeDisplaySettings() } returns displaySettings
+
+ useCase().test {
+ displaySettings.emit(DISPLAY_SETTINGS)
+
+ assertEquals(DISPLAY_SETTINGS, awaitItem().displaySettings)
+
+ val alternative = DISPLAY_SETTINGS.copy(sortOrder = SortOrder.FAMILY_NAME_FIRST)
+ displaySettings.emit(alternative)
+
+ assertEquals(alternative, awaitItem().displaySettings)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ private companion object {
+ const val BUILD_VERSION = "1.7.40"
+
+ val AVAILABILITY = SettingsAvailability(
+ areContactsAvailable = true,
+ areBlockedNumbersAvailable = true,
+ isAboutAvailable = true,
+ )
+
+ val DISPLAY_SETTINGS = DisplaySettings(
+ sortOrder = SortOrder.GIVEN_NAME_FIRST,
+ isSortOrderChangeable = true,
+ displayOrder = DisplayOrder.GIVEN_NAME_FIRST,
+ isDisplayOrderChangeable = true,
+ phoneticNameDisplay = PhoneticNameDisplay.SHOW_ALWAYS,
+ isPhoneticNameDisplayChangeable = true,
+ )
+ }
+}
diff --git a/app/src/test/kotlin/com/android/contacts/tests/MainDispatcherRule.kt b/app/src/test/kotlin/com/android/contacts/tests/MainDispatcherRule.kt
new file mode 100644
index 0000000000..d859d2caf7
--- /dev/null
+++ b/app/src/test/kotlin/com/android/contacts/tests/MainDispatcherRule.kt
@@ -0,0 +1,24 @@
+package com.android.contacts.tests
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.junit.rules.TestWatcher
+import org.junit.runner.Description
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MainDispatcherRule(
+ val testDispatcher: TestDispatcher = StandardTestDispatcher(),
+) : TestWatcher() {
+
+ override fun starting(description: Description) {
+ Dispatchers.setMain(testDispatcher)
+ }
+
+ override fun finished(description: Description) {
+ Dispatchers.resetMain()
+ }
+}
diff --git a/app/src/test/kotlin/com/android/contacts/ui/settings/about/AboutScreenTest.kt b/app/src/test/kotlin/com/android/contacts/ui/settings/about/AboutScreenTest.kt
new file mode 100644
index 0000000000..48b29bdeeb
--- /dev/null
+++ b/app/src/test/kotlin/com/android/contacts/ui/settings/about/AboutScreenTest.kt
@@ -0,0 +1,106 @@
+package com.android.contacts.ui.settings.about
+
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertHasClickAction
+import androidx.compose.ui.test.assertHasNoClickAction
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.longClick
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.v2.runComposeUiTest
+import com.android.contacts.ui.settings.screen.model.ABOUT_BUILD_VERSION_TEST_TAG
+import com.android.contacts.ui.settings.screen.model.ABOUT_LICENSES_TEST_TAG
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@OptIn(ExperimentalTestApi::class)
+@RunWith(RobolectricTestRunner::class)
+internal class AboutScreenTest {
+
+ @Test
+ fun showsTheBuildVersion() = runComposeUiTest {
+ setContent {
+ AboutScreen(
+ buildVersion = BUILD_VERSION,
+ onBuildVersionLongClick = {},
+ onLicensesClick = {},
+ onNavigateBack = {},
+ )
+ }
+
+ onNodeWithTag(ABOUT_BUILD_VERSION_TEST_TAG).assertIsDisplayed()
+ onNodeWithText(BUILD_VERSION).assertIsDisplayed()
+ }
+
+ @Test
+ fun whenBuildVersionIsUnknown_showsTheRowWithoutASummary() = runComposeUiTest {
+ setContent {
+ AboutScreen(
+ buildVersion = null,
+ onBuildVersionLongClick = {},
+ onLicensesClick = {},
+ onNavigateBack = {},
+ )
+ }
+
+ onNodeWithTag(ABOUT_BUILD_VERSION_TEST_TAG).assertTextEquals(BUILD_VERSION_TITLE)
+ }
+
+ @Test
+ fun whenBuildVersionIsLongPressed_reportsIt() = runComposeUiTest {
+ var buildVersionLongClicks = 0
+ setContent {
+ AboutScreen(
+ buildVersion = BUILD_VERSION,
+ onBuildVersionLongClick = { buildVersionLongClicks++ },
+ onLicensesClick = {},
+ onNavigateBack = {},
+ )
+ }
+
+ onNodeWithTag(ABOUT_BUILD_VERSION_TEST_TAG).performTouchInput { longClick() }
+
+ assertEquals(1, buildVersionLongClicks)
+ }
+
+ @Test
+ fun buildVersionRowIsNotClickable() = runComposeUiTest {
+ setContent {
+ AboutScreen(
+ buildVersion = BUILD_VERSION,
+ onBuildVersionLongClick = {},
+ onLicensesClick = {},
+ onNavigateBack = {},
+ )
+ }
+
+ onNodeWithTag(ABOUT_BUILD_VERSION_TEST_TAG).assertHasNoClickAction()
+ onNodeWithTag(ABOUT_LICENSES_TEST_TAG).assertHasClickAction()
+ }
+
+ @Test
+ fun whenBuildVersionIsUnknown_longPressReportsNothing() = runComposeUiTest {
+ var buildVersionLongClicks = 0
+ setContent {
+ AboutScreen(
+ buildVersion = null,
+ onBuildVersionLongClick = { buildVersionLongClicks++ },
+ onLicensesClick = {},
+ onNavigateBack = {},
+ )
+ }
+
+ onNodeWithTag(ABOUT_BUILD_VERSION_TEST_TAG).performTouchInput { longClick() }
+
+ assertEquals(0, buildVersionLongClicks)
+ }
+
+ private companion object {
+ const val BUILD_VERSION = "1.7.40"
+ const val BUILD_VERSION_TITLE = "Build version"
+ }
+}
diff --git a/app/src/test/kotlin/com/android/contacts/ui/settings/common/SettingsCellTest.kt b/app/src/test/kotlin/com/android/contacts/ui/settings/common/SettingsCellTest.kt
new file mode 100644
index 0000000000..a9c9db280f
--- /dev/null
+++ b/app/src/test/kotlin/com/android/contacts/ui/settings/common/SettingsCellTest.kt
@@ -0,0 +1,85 @@
+package com.android.contacts.ui.settings.common
+
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertHasNoClickAction
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.longClick
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.v2.runComposeUiTest
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@OptIn(ExperimentalTestApi::class)
+@RunWith(RobolectricTestRunner::class)
+internal class SettingsCellTest {
+
+ @Test
+ fun showsTitleAndSummary() = runComposeUiTest {
+ setContent {
+ SettingsCell(
+ title = "My info",
+ summary = "Anna Smith",
+ isFirst = true,
+ isLast = true,
+ onClick = {},
+ )
+ }
+
+ onNodeWithText("My info").assertIsDisplayed()
+ onNodeWithText("Anna Smith").assertIsDisplayed()
+ }
+
+ @Test
+ fun whenClicked_reportsTheClick() = runComposeUiTest {
+ var clicks = 0
+ setContent {
+ SettingsCell(
+ title = "Import",
+ isFirst = true,
+ isLast = true,
+ onClick = { clicks++ },
+ )
+ }
+
+ onNodeWithText("Import").performClick()
+
+ assertEquals(1, clicks)
+ }
+
+ @Test
+ fun whenLongPressed_reportsTheLongClick() = runComposeUiTest {
+ var longClicks = 0
+ setContent {
+ SettingsCell(
+ title = "Build version",
+ summary = "1.7.40",
+ isFirst = true,
+ isLast = true,
+ onLongClick = { longClicks++ },
+ onLongClickLabel = "Copy to clipboard",
+ )
+ }
+
+ onNodeWithText("Build version").performTouchInput { longClick() }
+
+ assertEquals(1, longClicks)
+ }
+
+ @Test
+ fun whenOnlyLongClickIsSet_theCellIsNotClickable() = runComposeUiTest {
+ setContent {
+ SettingsCell(
+ title = "Build version",
+ isFirst = true,
+ isLast = true,
+ onLongClick = {},
+ )
+ }
+
+ onNodeWithText("Build version").assertHasNoClickAction()
+ }
+}
diff --git a/app/src/test/kotlin/com/android/contacts/ui/settings/common/SettingsSingleChoiceDialogTest.kt b/app/src/test/kotlin/com/android/contacts/ui/settings/common/SettingsSingleChoiceDialogTest.kt
new file mode 100644
index 0000000000..7b2f9c6e42
--- /dev/null
+++ b/app/src/test/kotlin/com/android/contacts/ui/settings/common/SettingsSingleChoiceDialogTest.kt
@@ -0,0 +1,89 @@
+package com.android.contacts.ui.settings.common
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsNotSelected
+import androidx.compose.ui.test.assertIsSelected
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.v2.runComposeUiTest
+import com.android.contacts.ui.settings.screen.model.SettingsChoice
+import kotlinx.collections.immutable.persistentListOf
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@OptIn(ExperimentalTestApi::class)
+@RunWith(RobolectricTestRunner::class)
+internal class SettingsSingleChoiceDialogTest {
+
+ @Test
+ fun showsTitleAndEveryOption() = runComposeUiTest {
+ setContent {
+ SortOrderDialog()
+ }
+
+ onNodeWithText(TITLE).assertIsDisplayed()
+ onNodeWithText(GIVEN_NAME).assertIsDisplayed()
+ onNodeWithText(FAMILY_NAME).assertIsDisplayed()
+ }
+
+ @Test
+ fun marksTheSelectedOption() = runComposeUiTest {
+ setContent {
+ SortOrderDialog(selected = FAMILY_NAME_VALUE)
+ }
+
+ onNodeWithText(FAMILY_NAME).assertIsSelected()
+ onNodeWithText(GIVEN_NAME).assertIsNotSelected()
+ }
+
+ @Test
+ fun whenOptionIsClicked_reportsItsValue() = runComposeUiTest {
+ var selectedValue: String? = null
+ setContent {
+ SortOrderDialog(onSelect = { selectedValue = it })
+ }
+
+ onNodeWithText(FAMILY_NAME).performClick()
+
+ assertEquals(FAMILY_NAME_VALUE, selectedValue)
+ }
+
+ @Test
+ fun hasNoConfirmationButtons() = runComposeUiTest {
+ setContent {
+ SortOrderDialog()
+ }
+
+ onNodeWithText("OK").assertDoesNotExist()
+ onNodeWithText("Cancel").assertDoesNotExist()
+ }
+
+ @Composable
+ private fun SortOrderDialog(
+ selected: String = GIVEN_NAME_VALUE,
+ onSelect: (String) -> Unit = {},
+ ) {
+ SettingsSingleChoiceDialog(
+ title = TITLE,
+ options = persistentListOf(
+ SettingsChoice(value = GIVEN_NAME_VALUE, label = GIVEN_NAME),
+ SettingsChoice(value = FAMILY_NAME_VALUE, label = FAMILY_NAME),
+ ),
+ selected = selected,
+ onSelect = onSelect,
+ onDismissRequest = {},
+ )
+ }
+
+ private companion object {
+ const val TITLE = "Sort by"
+ const val GIVEN_NAME = "First name"
+ const val FAMILY_NAME = "Last name"
+ const val GIVEN_NAME_VALUE = "given"
+ const val FAMILY_NAME_VALUE = "family"
+ }
+}
diff --git a/app/src/test/kotlin/com/android/contacts/ui/settings/screen/SettingsEffectHandlerImplTest.kt b/app/src/test/kotlin/com/android/contacts/ui/settings/screen/SettingsEffectHandlerImplTest.kt
new file mode 100644
index 0000000000..0f22770aab
--- /dev/null
+++ b/app/src/test/kotlin/com/android/contacts/ui/settings/screen/SettingsEffectHandlerImplTest.kt
@@ -0,0 +1,206 @@
+package com.android.contacts.ui.settings.screen
+
+import android.app.Activity
+import android.app.FragmentManager
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.ContentUris
+import android.content.Intent
+import android.provider.ContactsContract.Contacts
+import android.provider.ContactsContract.Settings as ContactsContractSettings
+import android.provider.Settings
+import android.telecom.TelecomManager
+import androidx.activity.result.ActivityResultLauncher
+import com.android.contacts.activities.LicenseActivity
+import com.android.contacts.compat.TelecomManagerUtil
+import com.android.contacts.interactions.ExportDialogFragment
+import com.android.contacts.interactions.ImportDialogFragment
+import com.android.contacts.list.AccountFilterActivity
+import com.android.contacts.logging.ScreenEvent.ScreenType
+import com.android.contacts.ui.settings.SettingsActivity
+import com.android.contacts.ui.settings.screen.model.SettingsEffect as Effect
+import com.android.contacts.util.ImplicitIntentsUtil
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.slot
+import io.mockk.unmockkAll
+import io.mockk.verify
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+internal class SettingsEffectHandlerImplTest {
+
+ private val activity = mockk(relaxed = true)
+ private val telecomManager = mockk()
+ private val clipboardManager = mockk(relaxed = true)
+ private val contactsFilterLauncher = mockk>(relaxed = true)
+ private val fragmentManager = mockk(relaxed = true)
+
+ private val effectHandler = SettingsEffectHandlerImpl(
+ activity = activity,
+ newLocalProfileExtra = NEW_LOCAL_PROFILE_EXTRA,
+ telecomManager = telecomManager,
+ clipboardManager = clipboardManager,
+ contactsFilterLauncher = contactsFilterLauncher,
+ )
+
+ @Before
+ fun setUp() {
+ mockkStatic(ImplicitIntentsUtil::class)
+ mockkStatic(TelecomManagerUtil::class)
+ mockkStatic(ImportDialogFragment::class)
+ mockkStatic(ExportDialogFragment::class)
+ every { activity.fragmentManager } returns fragmentManager
+ }
+
+ @After
+ fun tearDown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun openProfile_opensQuickContactForTheProfile() {
+ effectHandler.handle(Effect.OpenProfile(contactId = 7L))
+
+ verify {
+ ImplicitIntentsUtil.startQuickContact(
+ activity,
+ ContentUris.withAppendedId(Contacts.CONTENT_URI, 7L),
+ ScreenType.ME_CONTACT,
+ )
+ }
+ }
+
+ @Test
+ fun copyBuildVersion_putsTheVersionOnTheClipboard() {
+ val clipSlot = slot()
+
+ effectHandler.handle(Effect.CopyBuildVersion(BUILD_VERSION))
+
+ verify { clipboardManager.setPrimaryClip(capture(clipSlot)) }
+ assertEquals(BUILD_VERSION, clipSlot.captured.getItemAt(0).text)
+ }
+
+ @Test
+ fun createProfile_startsContactInsertWithTheProfileExtra() {
+ val intentSlot = slot()
+
+ effectHandler.handle(Effect.CreateProfile)
+
+ verify { ImplicitIntentsUtil.startActivityInApp(activity, capture(intentSlot)) }
+ assertEquals(Intent.ACTION_INSERT, intentSlot.captured.action)
+ assertEquals(Contacts.CONTENT_URI, intentSlot.captured.data)
+ assertTrue(intentSlot.captured.getBooleanExtra(NEW_LOCAL_PROFILE_EXTRA, false))
+ }
+
+ @Test
+ fun openAddAccount_startsTheSystemAccountScreen() {
+ effectHandler.handle(Effect.OpenAddAccount)
+
+ verify { ImplicitIntentsUtil.startActivityOutsideApp(activity, any()) }
+ }
+
+ @Test
+ fun openDefaultAccountPicker_startsTheSystemPicker() {
+ effectHandler.handle(Effect.OpenDefaultAccountPicker)
+
+ assertEquals(
+ ContactsContractSettings.ACTION_SET_DEFAULT_ACCOUNT,
+ startedIntent().action,
+ )
+ }
+
+ @Test
+ fun openContactsFilter_launchesTheFilterForResult() {
+ val intentSlot = slot()
+
+ effectHandler.handle(Effect.OpenContactsFilter)
+
+ verify { contactsFilterLauncher.launch(capture(intentSlot)) }
+ assertEquals(
+ AccountFilterActivity::class.java.name,
+ intentSlot.captured.component?.className,
+ )
+ }
+
+ @Test
+ fun showImportDialog_showsTheLegacyDialog() {
+ effectHandler.handle(Effect.ShowImportDialog)
+
+ verify { ImportDialogFragment.show(fragmentManager) }
+ }
+
+ @Test
+ fun showExportDialog_showsTheLegacyDialogHostedByTheSettings() {
+ effectHandler.handle(Effect.ShowExportDialog)
+
+ verify {
+ ExportDialogFragment.show(
+ fragmentManager,
+ SettingsActivity::class.java,
+ ExportDialogFragment.EXPORT_MODE_ALL_CONTACTS,
+ )
+ }
+ }
+
+ @Test
+ fun openBlockedNumbers_startsTheTelecomScreen() {
+ val blockedNumbersIntent = Intent("blocked_numbers")
+ every { TelecomManagerUtil.createManageBlockedNumbersIntent(telecomManager) }
+ .returns(blockedNumbersIntent)
+
+ effectHandler.handle(Effect.OpenBlockedNumbers)
+
+ verify { activity.startActivity(blockedNumbersIntent) }
+ }
+
+ @Test
+ fun openBlockedNumbers_whenTelecomHasNoIntent_startsNothing() {
+ every { TelecomManagerUtil.createManageBlockedNumbersIntent(telecomManager) }
+ .returns(null)
+
+ effectHandler.handle(Effect.OpenBlockedNumbers)
+
+ verify(exactly = 0) { activity.startActivity(any()) }
+ }
+
+ @Test
+ fun openAppPermissions_startsTheSystemAppDetails() {
+ every { activity.packageName } returns "com.android.contacts"
+
+ effectHandler.handle(Effect.OpenAppPermissions)
+
+ val intent = startedIntent()
+ assertEquals(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, intent.action)
+ assertEquals("package:com.android.contacts", intent.data.toString())
+ }
+
+ @Test
+ fun openLicenses_startsTheLicenseScreen() {
+ effectHandler.handle(Effect.OpenLicenses)
+
+ assertEquals(
+ LicenseActivity::class.java.name,
+ startedIntent().component?.className,
+ )
+ }
+
+ private fun startedIntent(): Intent {
+ val intentSlot = slot()
+ verify { activity.startActivity(capture(intentSlot)) }
+
+ return intentSlot.captured
+ }
+
+ private companion object {
+ const val NEW_LOCAL_PROFILE_EXTRA = "newLocalProfile"
+ const val BUILD_VERSION = "1.7.40"
+ }
+}
diff --git a/app/src/test/kotlin/com/android/contacts/ui/settings/screen/SettingsMainScreenTest.kt b/app/src/test/kotlin/com/android/contacts/ui/settings/screen/SettingsMainScreenTest.kt
new file mode 100644
index 0000000000..44268cc040
--- /dev/null
+++ b/app/src/test/kotlin/com/android/contacts/ui/settings/screen/SettingsMainScreenTest.kt
@@ -0,0 +1,204 @@
+package com.android.contacts.ui.settings.screen
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.v2.runComposeUiTest
+import com.android.contacts.data.settings.model.DisplayOrder
+import com.android.contacts.data.settings.model.PhoneticNameDisplay
+import com.android.contacts.data.settings.model.SortOrder
+import com.android.contacts.ui.settings.screen.model.SETTINGS_GROUP_TEST_TAG_PREFIX
+import com.android.contacts.ui.settings.screen.model.SETTINGS_ITEM_TEST_TAG_PREFIX
+import com.android.contacts.ui.settings.screen.model.SETTINGS_SECTION_HEADER_TEST_TAG_PREFIX
+import com.android.contacts.ui.settings.screen.model.SETTINGS_SINGLE_CHOICE_DIALOG_TEST_TAG
+import com.android.contacts.ui.settings.screen.model.SettingsAction as Action
+import com.android.contacts.ui.settings.screen.model.SettingsGroupId
+import com.android.contacts.ui.settings.screen.model.SettingsGroupUiModel
+import com.android.contacts.ui.settings.screen.model.SettingsItemId
+import com.android.contacts.ui.settings.screen.model.SettingsItemUiModel
+import com.android.contacts.ui.settings.screen.model.SettingsUiState
+import kotlinx.collections.immutable.persistentListOf
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@OptIn(ExperimentalTestApi::class)
+@RunWith(RobolectricTestRunner::class)
+internal class SettingsMainScreenTest {
+
+ @Test
+ fun showsEveryGroupAndItem() = runComposeUiTest {
+ setContent {
+ MainScreen()
+ }
+
+ onNodeWithTag(SETTINGS_GROUP_TEST_TAG_PREFIX + SettingsGroupId.PROFILE.name)
+ .assertIsDisplayed()
+ onNodeWithTag(SETTINGS_ITEM_TEST_TAG_PREFIX + SettingsItemId.MY_INFO.name)
+ .assertIsDisplayed()
+ onNodeWithText(MY_INFO_SUMMARY).assertIsDisplayed()
+ onNodeWithTag(SETTINGS_ITEM_TEST_TAG_PREFIX + SettingsItemId.SORT_ORDER.name)
+ .assertIsDisplayed()
+ }
+
+ @Test
+ fun whenGroupHasATitle_showsItAsSectionHeading() = runComposeUiTest {
+ setContent {
+ MainScreen()
+ }
+
+ onNodeWithTag(SETTINGS_SECTION_HEADER_TEST_TAG_PREFIX + SettingsGroupId.DISPLAY.name)
+ .assertTextEquals(DISPLAY_SECTION_TITLE)
+ }
+
+ @Test
+ fun whenGroupHasNoTitle_showsNoSectionHeading() = runComposeUiTest {
+ setContent {
+ MainScreen()
+ }
+
+ onNodeWithTag(SETTINGS_SECTION_HEADER_TEST_TAG_PREFIX + SettingsGroupId.PROFILE.name)
+ .assertDoesNotExist()
+ }
+
+ @Test
+ fun whenThereAreNoGroups_showsNoItems() = runComposeUiTest {
+ setContent {
+ MainScreen(uiState = SettingsUiState())
+ }
+
+ onNodeWithTag(SETTINGS_ITEM_TEST_TAG_PREFIX + SettingsItemId.MY_INFO.name)
+ .assertDoesNotExist()
+ }
+
+ @Test
+ fun whenPlainItemIsClicked_reportsTheClick() = runComposeUiTest {
+ val actions = mutableListOf()
+ setContent {
+ MainScreen(onAction = actions::add)
+ }
+
+ onNodeWithTag(SETTINGS_ITEM_TEST_TAG_PREFIX + SettingsItemId.MY_INFO.name).performClick()
+
+ assertEquals(listOf(Action.ItemClicked(SettingsItemId.MY_INFO)), actions)
+ }
+
+ @Test
+ fun whenAboutIsClicked_navigatesToAbout() = runComposeUiTest {
+ val actions = mutableListOf()
+ var aboutClicks = 0
+ setContent {
+ MainScreen(onAction = actions::add, onNavigateToAbout = { aboutClicks++ })
+ }
+
+ onNodeWithTag(SETTINGS_ITEM_TEST_TAG_PREFIX + SettingsItemId.ABOUT.name).performClick()
+
+ assertEquals(1, aboutClicks)
+ assertEquals(emptyList(), actions)
+ }
+
+ @Test
+ fun whenDisplayOptionIsClicked_opensItsDialogWithoutReportingAnAction() = runComposeUiTest {
+ val actions = mutableListOf()
+ setContent {
+ MainScreen(onAction = actions::add)
+ }
+
+ onNodeWithTag(SETTINGS_ITEM_TEST_TAG_PREFIX + SettingsItemId.SORT_ORDER.name).performClick()
+
+ onNodeWithTag(SETTINGS_SINGLE_CHOICE_DIALOG_TEST_TAG).assertIsDisplayed()
+ assertEquals(emptyList(), actions)
+ }
+
+ @Test
+ fun whenSortOrderIsSelected_reportsItAndClosesTheDialog() = runComposeUiTest {
+ val actions = mutableListOf()
+ setContent {
+ MainScreen(onAction = actions::add)
+ }
+ onNodeWithTag(SETTINGS_ITEM_TEST_TAG_PREFIX + SettingsItemId.SORT_ORDER.name).performClick()
+
+ onNodeWithText(FAMILY_NAME_FIRST_LABEL).performClick()
+
+ assertEquals(listOf(Action.SortOrderSelected(SortOrder.FAMILY_NAME_FIRST)), actions)
+ onNodeWithTag(SETTINGS_SINGLE_CHOICE_DIALOG_TEST_TAG).assertDoesNotExist()
+ }
+
+ @Test
+ fun whenBackIsClicked_navigatesBack() = runComposeUiTest {
+ var backClicks = 0
+ setContent {
+ MainScreen(onNavigateBack = { backClicks++ })
+ }
+
+ onNodeWithContentDescription(BACK_DESCRIPTION).performClick()
+
+ assertEquals(1, backClicks)
+ }
+
+ @Composable
+ private fun MainScreen(
+ uiState: SettingsUiState = UI_STATE,
+ onAction: (Action) -> Unit = {},
+ onNavigateBack: () -> Unit = {},
+ onNavigateToAbout: () -> Unit = {},
+ ) {
+ SettingsMainScreen(
+ uiState = uiState,
+ onAction = onAction,
+ onNavigateBack = onNavigateBack,
+ onNavigateToAbout = onNavigateToAbout,
+ )
+ }
+
+ private companion object {
+ const val MY_INFO_SUMMARY = "Anna Smith"
+ const val FAMILY_NAME_FIRST_LABEL = "Last name"
+ const val BACK_DESCRIPTION = "Back"
+ const val DISPLAY_SECTION_TITLE = "Display options"
+
+ val UI_STATE = SettingsUiState(
+ groups = persistentListOf(
+ SettingsGroupUiModel(
+ id = SettingsGroupId.PROFILE,
+ items = persistentListOf(
+ SettingsItemUiModel(
+ id = SettingsItemId.MY_INFO,
+ title = "My info",
+ summary = MY_INFO_SUMMARY,
+ ),
+ ),
+ ),
+ SettingsGroupUiModel(
+ id = SettingsGroupId.DISPLAY,
+ title = DISPLAY_SECTION_TITLE,
+ items = persistentListOf(
+ SettingsItemUiModel(
+ id = SettingsItemId.SORT_ORDER,
+ title = "Sort by",
+ summary = "First name",
+ ),
+ ),
+ ),
+ SettingsGroupUiModel(
+ id = SettingsGroupId.ABOUT,
+ items = persistentListOf(
+ SettingsItemUiModel(
+ id = SettingsItemId.ABOUT,
+ title = "About Contacts",
+ ),
+ ),
+ ),
+ ),
+ sortOrder = SortOrder.GIVEN_NAME_FIRST,
+ displayOrder = DisplayOrder.GIVEN_NAME_FIRST,
+ phoneticNameDisplay = PhoneticNameDisplay.SHOW_ALWAYS,
+ )
+ }
+}
diff --git a/app/src/test/kotlin/com/android/contacts/ui/settings/screen/SettingsNavHostTest.kt b/app/src/test/kotlin/com/android/contacts/ui/settings/screen/SettingsNavHostTest.kt
new file mode 100644
index 0000000000..b68208a200
--- /dev/null
+++ b/app/src/test/kotlin/com/android/contacts/ui/settings/screen/SettingsNavHostTest.kt
@@ -0,0 +1,241 @@
+package com.android.contacts.ui.settings.screen
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.test.ComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.hasScrollToIndexAction
+import androidx.compose.ui.test.longClick
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performScrollToIndex
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.v2.runComposeUiTest
+import com.android.contacts.ui.settings.screen.model.ABOUT_BUILD_VERSION_TEST_TAG
+import com.android.contacts.ui.settings.screen.model.ABOUT_LICENSES_TEST_TAG
+import com.android.contacts.ui.settings.screen.model.SETTINGS_ITEM_TEST_TAG_PREFIX
+import com.android.contacts.ui.settings.screen.model.SettingsAction as Action
+import com.android.contacts.ui.settings.screen.model.SettingsGroupId
+import com.android.contacts.ui.settings.screen.model.SettingsGroupUiModel
+import com.android.contacts.ui.settings.screen.model.SettingsItemId
+import com.android.contacts.ui.settings.screen.model.SettingsItemUiModel
+import com.android.contacts.ui.settings.screen.model.SettingsUiState
+import kotlinx.collections.immutable.persistentListOf
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@OptIn(ExperimentalTestApi::class)
+@RunWith(RobolectricTestRunner::class)
+internal class SettingsNavHostTest {
+
+ private fun ComposeUiTest.openAbout() {
+ onNodeWithTag(SETTINGS_ITEM_TEST_TAG_PREFIX + SettingsItemId.ABOUT.name).performClick()
+ }
+
+ @Test
+ fun startsOnTheMainScreen() = runComposeUiTest {
+ setContent {
+ Content()
+ }
+
+ onNodeWithTag(SETTINGS_ITEM_TEST_TAG_PREFIX + SettingsItemId.MY_INFO.name)
+ .assertIsDisplayed()
+ onNodeWithTag(ABOUT_BUILD_VERSION_TEST_TAG).assertDoesNotExist()
+ }
+
+ @Test
+ fun whenAboutIsClicked_showsTheAboutScreen() = runComposeUiTest {
+ setContent {
+ Content()
+ }
+
+ openAbout()
+
+ onNodeWithTag(ABOUT_BUILD_VERSION_TEST_TAG).assertIsDisplayed()
+ onNodeWithTag(SETTINGS_ITEM_TEST_TAG_PREFIX + SettingsItemId.MY_INFO.name)
+ .assertDoesNotExist()
+ }
+
+ @Test
+ fun whenLicensesIsClickedOnAbout_reportsTheAction() = runComposeUiTest {
+ val actions = mutableListOf()
+ setContent {
+ Content(onAction = actions::add)
+ }
+ openAbout()
+
+ onNodeWithTag(ABOUT_LICENSES_TEST_TAG).performClick()
+
+ assertEquals(listOf(Action.LicensesClicked), actions)
+ }
+
+ @Test
+ fun whenBuildVersionIsLongPressedOnAbout_reportsTheAction() = runComposeUiTest {
+ val actions = mutableListOf()
+ setContent {
+ Content(onAction = actions::add)
+ }
+ openAbout()
+
+ onNodeWithTag(ABOUT_BUILD_VERSION_TEST_TAG).performTouchInput { longClick() }
+
+ assertEquals(listOf(Action.BuildVersionLongClicked), actions)
+ }
+
+ @Test
+ fun whenBackIsPressedOnAbout_returnsToTheMainScreen() = runComposeUiTest {
+ var backClicks = 0
+ setContent {
+ Content(onNavigateBack = { backClicks++ })
+ }
+ openAbout()
+
+ onNodeWithContentDescription(BACK_DESCRIPTION).performClick()
+
+ onNodeWithTag(SETTINGS_ITEM_TEST_TAG_PREFIX + SettingsItemId.MY_INFO.name)
+ .assertIsDisplayed()
+ assertEquals(0, backClicks)
+ }
+
+ @Test
+ fun whenBackIsPressedOnTheMainScreen_leavesTheSettings() = runComposeUiTest {
+ var backClicks = 0
+ setContent {
+ Content(onNavigateBack = { backClicks++ })
+ }
+
+ onNodeWithContentDescription(BACK_DESCRIPTION).performClick()
+
+ assertEquals(1, backClicks)
+ }
+
+ @Test
+ fun whenReturningFromAbout_keepsTheScrollPosition() = runComposeUiTest {
+ setContent {
+ Content(uiState = SCROLLABLE_UI_STATE)
+ }
+ onNode(hasScrollToIndexAction())
+ .performScrollToIndex(SCROLLABLE_UI_STATE.groups.lastIndex)
+ onNodeWithTag(SETTINGS_ITEM_TEST_TAG_PREFIX + SettingsItemId.MY_INFO.name)
+ .assertDoesNotExist()
+
+ openAbout()
+ onNodeWithContentDescription(BACK_DESCRIPTION).performClick()
+
+ onNodeWithTag(SETTINGS_ITEM_TEST_TAG_PREFIX + SettingsItemId.ABOUT.name)
+ .assertIsDisplayed()
+ onNodeWithTag(SETTINGS_ITEM_TEST_TAG_PREFIX + SettingsItemId.MY_INFO.name)
+ .assertDoesNotExist()
+ }
+
+ @Composable
+ private fun Content(
+ onAction: (Action) -> Unit = {},
+ onNavigateBack: () -> Unit = {},
+ uiState: SettingsUiState = UI_STATE,
+ ) {
+ SettingsNavHost(
+ uiState = uiState,
+ onAction = onAction,
+ onNavigateBack = onNavigateBack,
+ )
+ }
+
+ private companion object {
+ const val BACK_DESCRIPTION = "Back"
+
+ val UI_STATE = SettingsUiState(
+ groups = persistentListOf(
+ SettingsGroupUiModel(
+ id = SettingsGroupId.PROFILE,
+ items = persistentListOf(
+ SettingsItemUiModel(
+ id = SettingsItemId.MY_INFO,
+ title = "My info",
+ ),
+ ),
+ ),
+ SettingsGroupUiModel(
+ id = SettingsGroupId.ABOUT,
+ items = persistentListOf(
+ SettingsItemUiModel(
+ id = SettingsItemId.ABOUT,
+ title = "About Contacts",
+ ),
+ ),
+ ),
+ ),
+ buildVersion = "1.7.40",
+ )
+
+ val SCROLLABLE_UI_STATE = SettingsUiState(
+ groups = persistentListOf(
+ SettingsGroupUiModel(
+ id = SettingsGroupId.PROFILE,
+ items = persistentListOf(
+ SettingsItemUiModel(id = SettingsItemId.MY_INFO, title = "My info"),
+ ),
+ ),
+ SettingsGroupUiModel(
+ id = SettingsGroupId.ACCOUNTS,
+ items = persistentListOf(
+ SettingsItemUiModel(id = SettingsItemId.ACCOUNTS, title = "Accounts"),
+ SettingsItemUiModel(
+ id = SettingsItemId.DEFAULT_ACCOUNT,
+ title = "Default account for new contacts",
+ ),
+ SettingsItemUiModel(
+ id = SettingsItemId.CONTACTS_FILTER,
+ title = "Contacts to display",
+ ),
+ ),
+ ),
+ SettingsGroupUiModel(
+ id = SettingsGroupId.DISPLAY,
+ title = "Display options",
+ items = persistentListOf(
+ SettingsItemUiModel(id = SettingsItemId.SORT_ORDER, title = "Sort by"),
+ SettingsItemUiModel(
+ id = SettingsItemId.DISPLAY_ORDER,
+ title = "Name format",
+ ),
+ SettingsItemUiModel(
+ id = SettingsItemId.PHONETIC_NAME_DISPLAY,
+ title = "Phonetic name",
+ ),
+ ),
+ ),
+ SettingsGroupUiModel(
+ id = SettingsGroupId.DATA,
+ items = persistentListOf(
+ SettingsItemUiModel(id = SettingsItemId.IMPORT, title = "Import"),
+ SettingsItemUiModel(id = SettingsItemId.EXPORT, title = "Export"),
+ ),
+ ),
+ SettingsGroupUiModel(
+ id = SettingsGroupId.PERMISSIONS,
+ items = persistentListOf(
+ SettingsItemUiModel(
+ id = SettingsItemId.BLOCKED_NUMBERS,
+ title = "Blocked numbers",
+ ),
+ SettingsItemUiModel(
+ id = SettingsItemId.CALL_LOG_PERMISSION,
+ title = "Call log permission",
+ ),
+ ),
+ ),
+ SettingsGroupUiModel(
+ id = SettingsGroupId.ABOUT,
+ items = persistentListOf(
+ SettingsItemUiModel(id = SettingsItemId.ABOUT, title = "About Contacts"),
+ ),
+ ),
+ ),
+ buildVersion = "1.7.40",
+ )
+ }
+}
diff --git a/app/src/test/kotlin/com/android/contacts/ui/settings/screen/SettingsNavRouteSaverTest.kt b/app/src/test/kotlin/com/android/contacts/ui/settings/screen/SettingsNavRouteSaverTest.kt
new file mode 100644
index 0000000000..3044607b49
--- /dev/null
+++ b/app/src/test/kotlin/com/android/contacts/ui/settings/screen/SettingsNavRouteSaverTest.kt
@@ -0,0 +1,32 @@
+package com.android.contacts.ui.settings.screen
+
+import androidx.compose.runtime.saveable.SaverScope
+import com.android.contacts.ui.settings.screen.model.SettingsNavRoute
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+internal class SettingsNavRouteSaverTest {
+
+ private val saverScope = SaverScope { true }
+
+ @Test
+ fun mainRoute_survivesSaveAndRestore() {
+ assertEquals(SettingsNavRoute.Main, restore(SettingsNavRoute.Main))
+ }
+
+ @Test
+ fun aboutRoute_survivesSaveAndRestore() {
+ assertEquals(SettingsNavRoute.About, restore(SettingsNavRoute.About))
+ }
+
+ @Test
+ fun unknownSavedValue_restoresTheMainRoute() {
+ assertEquals(SettingsNavRoute.Main, SettingsNavRouteSaver.restore("nonsense"))
+ }
+
+ private fun restore(route: SettingsNavRoute): SettingsNavRoute? {
+ val saved = with(SettingsNavRouteSaver) { saverScope.save(route) }
+
+ return saved?.let(SettingsNavRouteSaver::restore)
+ }
+}
diff --git a/app/src/test/kotlin/com/android/contacts/ui/settings/screen/SettingsScreenTest.kt b/app/src/test/kotlin/com/android/contacts/ui/settings/screen/SettingsScreenTest.kt
new file mode 100644
index 0000000000..ee9a8095fb
--- /dev/null
+++ b/app/src/test/kotlin/com/android/contacts/ui/settings/screen/SettingsScreenTest.kt
@@ -0,0 +1,102 @@
+package com.android.contacts.ui.settings.screen
+
+import androidx.compose.ui.test.ComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.v2.runComposeUiTest
+import com.android.contacts.ui.settings.screen.model.SettingsEffect as Effect
+import com.android.contacts.ui.settings.screen.model.SettingsUiState
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@OptIn(ExperimentalTestApi::class)
+@RunWith(RobolectricTestRunner::class)
+internal class SettingsScreenTest {
+
+ private val effects = MutableSharedFlow(extraBufferCapacity = 1)
+ private val screenModel = mockk(relaxed = true)
+ private val effectHandler = mockk(relaxed = true)
+
+ @Before
+ fun setUp() {
+ every { screenModel.uiState } returns MutableStateFlow(SettingsUiState())
+ every { screenModel.effects } returns effects
+ }
+
+ @Test
+ fun whenSimImportSucceeds_showsHowManyContactsWereImported() = runComposeUiTest {
+ setScreenContent()
+
+ effects.tryEmit(Effect.ShowSimImportSuccess(importedCount = 3))
+ waitForIdle()
+
+ onNodeWithText("3 SIM contacts imported").assertIsDisplayed()
+ }
+
+ @Test
+ fun whenASingleContactIsImported_showsTheSingularMessage() = runComposeUiTest {
+ setScreenContent()
+
+ effects.tryEmit(Effect.ShowSimImportSuccess(importedCount = 1))
+ waitForIdle()
+
+ onNodeWithText("1 SIM contact imported").assertIsDisplayed()
+ }
+
+ @Test
+ fun whenSimImportFails_showsTheFailureMessage() = runComposeUiTest {
+ setScreenContent()
+
+ effects.tryEmit(Effect.ShowSimImportFailure)
+ waitForIdle()
+
+ onNodeWithText("Failed to import SIM contacts").assertIsDisplayed()
+ }
+
+ @Test
+ fun otherEffects_arePassedToTheEffectHandler() = runComposeUiTest {
+ setScreenContent()
+
+ effects.tryEmit(Effect.OpenLicenses)
+ waitForIdle()
+
+ verify(exactly = 1) { effectHandler.handle(Effect.OpenLicenses) }
+ }
+
+ @Test
+ fun whileASnackbarIsShown_laterEffectsAreStillHandled() = runComposeUiTest {
+ setScreenContent()
+
+ effects.tryEmit(Effect.ShowSimImportFailure)
+ waitForIdle()
+ effects.tryEmit(Effect.OpenLicenses)
+ waitForIdle()
+
+ verify(exactly = 1) { effectHandler.handle(Effect.OpenLicenses) }
+ }
+
+ @Test
+ fun onResume_refreshesTheState() = runComposeUiTest {
+ setScreenContent()
+
+ verify(atLeast = 1) { screenModel.refreshState() }
+ }
+
+ private fun ComposeUiTest.setScreenContent() {
+ setContent {
+ SettingsScreen(
+ effectHandler = effectHandler,
+ onNavigateBack = {},
+ screenModel = screenModel,
+ )
+ }
+ }
+}
diff --git a/app/src/test/kotlin/com/android/contacts/ui/settings/screen/mapper/settingsuistatemapper/BaseSettingsUiStateMapperTest.kt b/app/src/test/kotlin/com/android/contacts/ui/settings/screen/mapper/settingsuistatemapper/BaseSettingsUiStateMapperTest.kt
new file mode 100644
index 0000000000..c6302fc698
--- /dev/null
+++ b/app/src/test/kotlin/com/android/contacts/ui/settings/screen/mapper/settingsuistatemapper/BaseSettingsUiStateMapperTest.kt
@@ -0,0 +1,76 @@
+package com.android.contacts.ui.settings.screen.mapper.settingsuistatemapper
+
+import android.content.Context
+import com.android.contacts.data.contactsfilter.model.ContactsFilter
+import com.android.contacts.data.settings.model.DisplayOrder
+import com.android.contacts.data.settings.model.DisplaySettings
+import com.android.contacts.data.settings.model.PhoneticNameDisplay
+import com.android.contacts.data.settings.model.SettingsAvailability
+import com.android.contacts.data.settings.model.SortOrder
+import com.android.contacts.domain.settings.model.SettingsData
+import com.android.contacts.ui.settings.screen.mapper.SettingsUiStateMapperImpl
+import com.android.contacts.ui.settings.screen.model.SettingsItemId
+import com.android.contacts.ui.settings.screen.model.SettingsItemUiModel
+import com.android.contacts.ui.settings.screen.model.SettingsUiState
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.Before
+
+internal abstract class BaseSettingsUiStateMapperTest {
+
+ protected val context = mockk()
+
+ protected val mapper = SettingsUiStateMapperImpl(context)
+
+ @Before
+ fun setUpStrings() {
+ every { context.getString(any()) } answers { "string-${firstArg()}" }
+ }
+
+ protected fun settingsData(
+ availability: SettingsAvailability = AVAILABILITY,
+ displaySettings: DisplaySettings = DISPLAY_SETTINGS,
+ defaultAccountLabel: String? = null,
+ contactsFilter: ContactsFilter? = null,
+ buildVersion: String? = null,
+ isCallLogPermissionGranted: Boolean = true,
+ ): SettingsData {
+ return SettingsData(
+ availability = availability,
+ displaySettings = displaySettings,
+ defaultAccountLabel = defaultAccountLabel,
+ contactsFilter = contactsFilter,
+ buildVersion = buildVersion,
+ isCallLogPermissionGranted = isCallLogPermissionGranted,
+ )
+ }
+
+ protected fun itemIds(items: List): List {
+ return items.map { it.id }
+ }
+
+ protected fun allItems(uiState: SettingsUiState): List {
+ return uiState.groups.flatMap { it.items }
+ }
+
+ protected fun summaryOf(uiState: SettingsUiState, id: SettingsItemId): String? {
+ return allItems(uiState).first { it.id == id }.summary
+ }
+
+ protected companion object {
+ val AVAILABILITY = SettingsAvailability(
+ areContactsAvailable = true,
+ areBlockedNumbersAvailable = true,
+ isAboutAvailable = true,
+ )
+
+ val DISPLAY_SETTINGS = DisplaySettings(
+ sortOrder = SortOrder.GIVEN_NAME_FIRST,
+ isSortOrderChangeable = true,
+ displayOrder = DisplayOrder.GIVEN_NAME_FIRST,
+ isDisplayOrderChangeable = true,
+ phoneticNameDisplay = PhoneticNameDisplay.SHOW_ALWAYS,
+ isPhoneticNameDisplayChangeable = true,
+ )
+ }
+}
diff --git a/app/src/test/kotlin/com/android/contacts/ui/settings/screen/mapper/settingsuistatemapper/SettingsUiStateMapperStructureTest.kt b/app/src/test/kotlin/com/android/contacts/ui/settings/screen/mapper/settingsuistatemapper/SettingsUiStateMapperStructureTest.kt
new file mode 100644
index 0000000000..68b455a8b2
--- /dev/null
+++ b/app/src/test/kotlin/com/android/contacts/ui/settings/screen/mapper/settingsuistatemapper/SettingsUiStateMapperStructureTest.kt
@@ -0,0 +1,164 @@
+package com.android.contacts.ui.settings.screen.mapper.settingsuistatemapper
+
+import com.android.contacts.R
+import com.android.contacts.ui.settings.screen.model.SettingsGroupId
+import com.android.contacts.ui.settings.screen.model.SettingsItemId
+import com.android.contacts.ui.settings.screen.model.SettingsUiState
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+internal class SettingsUiStateMapperStructureTest : BaseSettingsUiStateMapperTest() {
+
+ private fun titleOf(uiState: SettingsUiState, id: SettingsGroupId): String? {
+ return uiState.groups.first { it.id == id }.title
+ }
+
+ @Test
+ fun map_buildsEveryGroupInScreenOrder() {
+ val uiState = mapper.map(settingsData = settingsData(), profile = null)
+
+ assertEquals(
+ listOf(
+ SettingsGroupId.PROFILE,
+ SettingsGroupId.ACCOUNTS,
+ SettingsGroupId.DISPLAY,
+ SettingsGroupId.DATA,
+ SettingsGroupId.PERMISSIONS,
+ SettingsGroupId.ABOUT,
+ ),
+ uiState.groups.map { it.id },
+ )
+ }
+
+ @Test
+ fun map_keepsItemsInScreenOrder() {
+ val uiState = mapper.map(settingsData = settingsData(), profile = null)
+
+ assertEquals(
+ listOf(
+ SettingsItemId.MY_INFO,
+ SettingsItemId.ACCOUNTS,
+ SettingsItemId.DEFAULT_ACCOUNT,
+ SettingsItemId.CONTACTS_FILTER,
+ SettingsItemId.SORT_ORDER,
+ SettingsItemId.DISPLAY_ORDER,
+ SettingsItemId.PHONETIC_NAME_DISPLAY,
+ SettingsItemId.IMPORT,
+ SettingsItemId.EXPORT,
+ SettingsItemId.BLOCKED_NUMBERS,
+ SettingsItemId.CALL_LOG_PERMISSION,
+ SettingsItemId.ABOUT,
+ ),
+ itemIds(allItems(uiState)),
+ )
+ }
+
+ @Test
+ fun map_headsThePermissionsSection() {
+ val uiState = mapper.map(settingsData = settingsData(), profile = null)
+
+ assertEquals(
+ "string-${R.string.settings_section_permissions}",
+ titleOf(uiState, SettingsGroupId.PERMISSIONS),
+ )
+ }
+
+ @Test
+ fun map_headsTheDisplayAndDataSections() {
+ val uiState = mapper.map(settingsData = settingsData(), profile = null)
+
+ assertEquals(
+ "string-${R.string.settings_section_display_options}",
+ titleOf(uiState, SettingsGroupId.DISPLAY),
+ )
+ assertEquals(
+ "string-${R.string.settings_section_manage_contacts}",
+ titleOf(uiState, SettingsGroupId.DATA),
+ )
+ }
+
+ @Test
+ fun map_leavesTheRemainingSectionsWithoutAHeading() {
+ val uiState = mapper.map(settingsData = settingsData(), profile = null)
+
+ assertNull(titleOf(uiState, SettingsGroupId.PROFILE))
+ assertNull(titleOf(uiState, SettingsGroupId.ACCOUNTS))
+ assertNull(titleOf(uiState, SettingsGroupId.ABOUT))
+ }
+
+ @Test
+ fun map_whenDisplayOptionsAreLocked_dropsTheWholeDisplayGroup() {
+ val settingsData = settingsData(
+ displaySettings = DISPLAY_SETTINGS.copy(
+ isSortOrderChangeable = false,
+ isDisplayOrderChangeable = false,
+ isPhoneticNameDisplayChangeable = false,
+ ),
+ )
+
+ val uiState = mapper.map(settingsData = settingsData, profile = null)
+
+ assertTrue(uiState.groups.none { it.id == SettingsGroupId.DISPLAY })
+ }
+
+ @Test
+ fun map_whenOnlySortOrderIsChangeable_keepsOnlyThatItem() {
+ val settingsData = settingsData(
+ displaySettings = DISPLAY_SETTINGS.copy(
+ isDisplayOrderChangeable = false,
+ isPhoneticNameDisplayChangeable = false,
+ ),
+ )
+
+ val uiState = mapper.map(settingsData = settingsData, profile = null)
+
+ assertEquals(
+ listOf(SettingsItemId.SORT_ORDER),
+ itemIds(uiState.groups.first { it.id == SettingsGroupId.DISPLAY }.items),
+ )
+ }
+
+ @Test
+ fun map_whenContactsAreUnavailable_dropsExport() {
+ val settingsData = settingsData(
+ availability = AVAILABILITY.copy(areContactsAvailable = false),
+ )
+
+ val uiState = mapper.map(settingsData = settingsData, profile = null)
+
+ assertEquals(
+ listOf(SettingsItemId.IMPORT, SettingsItemId.BLOCKED_NUMBERS),
+ itemIds(uiState.groups.first { it.id == SettingsGroupId.DATA }.items),
+ )
+ }
+
+ @Test
+ fun map_whenNumbersCannotBeBlocked_dropsBlockedNumbers() {
+ val settingsData = settingsData(
+ availability = AVAILABILITY.copy(areBlockedNumbersAvailable = false),
+ )
+
+ val uiState = mapper.map(settingsData = settingsData, profile = null)
+
+ assertEquals(
+ listOf(SettingsItemId.IMPORT, SettingsItemId.EXPORT),
+ itemIds(uiState.groups.first { it.id == SettingsGroupId.DATA }.items),
+ )
+ }
+
+ @Test
+ fun map_whenAboutIsUnavailable_dropsTheAboutGroup() {
+ val settingsData = settingsData(
+ availability = AVAILABILITY.copy(isAboutAvailable = false),
+ )
+
+ val uiState = mapper.map(settingsData = settingsData, profile = null)
+
+ assertTrue(uiState.groups.none { it.id == SettingsGroupId.ABOUT })
+ }
+}
diff --git a/app/src/test/kotlin/com/android/contacts/ui/settings/screen/mapper/settingsuistatemapper/SettingsUiStateMapperSummaryTest.kt b/app/src/test/kotlin/com/android/contacts/ui/settings/screen/mapper/settingsuistatemapper/SettingsUiStateMapperSummaryTest.kt
new file mode 100644
index 0000000000..c8eafdddac
--- /dev/null
+++ b/app/src/test/kotlin/com/android/contacts/ui/settings/screen/mapper/settingsuistatemapper/SettingsUiStateMapperSummaryTest.kt
@@ -0,0 +1,192 @@
+package com.android.contacts.ui.settings.screen.mapper.settingsuistatemapper
+
+import com.android.contacts.R
+import com.android.contacts.data.contactsfilter.model.ContactsFilter
+import com.android.contacts.data.profile.model.ProfileData
+import com.android.contacts.data.settings.model.DisplayOrder
+import com.android.contacts.data.settings.model.PhoneticNameDisplay
+import com.android.contacts.data.settings.model.SortOrder
+import com.android.contacts.ui.settings.screen.model.SettingsItemId
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+internal class SettingsUiStateMapperSummaryTest : BaseSettingsUiStateMapperTest() {
+
+ @Test
+ fun map_whenProfileIsNotLoaded_leavesMyInfoWithoutSummary() {
+ val uiState = mapper.map(settingsData = settingsData(), profile = null)
+
+ assertNull(summaryOf(uiState, SettingsItemId.MY_INFO))
+ }
+
+ @Test
+ fun map_whenThereIsNoProfile_offersToSetItUp() {
+ val uiState = mapper.map(
+ settingsData = settingsData(),
+ profile = ProfileData(hasProfile = false),
+ )
+
+ assertEquals(
+ "string-${R.string.set_up_profile}",
+ summaryOf(uiState, SettingsItemId.MY_INFO),
+ )
+ }
+
+ @Test
+ fun map_whenProfileHasName_showsIt() {
+ val uiState = mapper.map(
+ settingsData = settingsData(),
+ profile = ProfileData(hasProfile = true, displayName = "Anna Smith"),
+ )
+
+ assertEquals("Anna Smith", summaryOf(uiState, SettingsItemId.MY_INFO))
+ }
+
+ @Test
+ fun map_whenProfileHasNoName_showsMissingNamePlaceholder() {
+ val uiState = mapper.map(
+ settingsData = settingsData(),
+ profile = ProfileData(hasProfile = true, displayName = ""),
+ )
+
+ assertEquals(
+ "string-${R.string.missing_name}",
+ summaryOf(uiState, SettingsItemId.MY_INFO),
+ )
+ }
+
+ @Test
+ fun map_whenProfileNameIsAPhoneNumber_keepsTheNumberReadable() {
+ val uiState = mapper.map(
+ settingsData = settingsData(),
+ profile = ProfileData(
+ hasProfile = true,
+ displayName = "+31 6 1234 5678",
+ isDisplayNameFromPhoneNumber = true,
+ ),
+ )
+
+ val summary = summaryOf(uiState, SettingsItemId.MY_INFO)
+
+ assertTrue(summary.orEmpty().contains("+31 6 1234 5678"))
+ }
+
+ @Test
+ fun map_whenFilterIsAllAccounts_showsAllAccountsSummary() {
+ val uiState = mapper.map(
+ settingsData = settingsData(contactsFilter = ContactsFilter.ALL_ACCOUNTS),
+ profile = null,
+ )
+
+ assertEquals(
+ "string-${R.string.list_filter_all_accounts}",
+ summaryOf(uiState, SettingsItemId.CONTACTS_FILTER),
+ )
+ }
+
+ @Test
+ fun map_whenFilterIsCustom_showsCustomSummary() {
+ val uiState = mapper.map(
+ settingsData = settingsData(contactsFilter = ContactsFilter.CUSTOM),
+ profile = null,
+ )
+
+ assertEquals(
+ "string-${R.string.listCustomView}",
+ summaryOf(uiState, SettingsItemId.CONTACTS_FILTER),
+ )
+ }
+
+ @Test
+ fun map_whenThereIsNoFilter_leavesFilterWithoutSummary() {
+ val uiState = mapper.map(settingsData = settingsData(contactsFilter = null), profile = null)
+
+ assertNull(summaryOf(uiState, SettingsItemId.CONTACTS_FILTER))
+ }
+
+ @Test
+ fun map_whenCallLogPermissionIsGranted_showsItAsAllowed() {
+ val uiState = mapper.map(
+ settingsData = settingsData(isCallLogPermissionGranted = true),
+ profile = null,
+ )
+
+ assertEquals(
+ "string-${R.string.settings_permission_allowed}",
+ summaryOf(uiState, SettingsItemId.CALL_LOG_PERMISSION),
+ )
+ }
+
+ @Test
+ fun map_whenCallLogPermissionIsDenied_showsItAsNotAllowed() {
+ val uiState = mapper.map(
+ settingsData = settingsData(isCallLogPermissionGranted = false),
+ profile = null,
+ )
+
+ assertEquals(
+ "string-${R.string.settings_permission_not_allowed}",
+ summaryOf(uiState, SettingsItemId.CALL_LOG_PERMISSION),
+ )
+ }
+
+ @Test
+ fun map_showsDefaultAccountLabelAsSummary() {
+ val uiState = mapper.map(
+ settingsData = settingsData(defaultAccountLabel = "Device"),
+ profile = null,
+ )
+
+ assertEquals("Device", summaryOf(uiState, SettingsItemId.DEFAULT_ACCOUNT))
+ }
+
+ @Test
+ fun map_showsSelectedDisplayOptionsAsSummaries() {
+ val uiState = mapper.map(settingsData = alternativeDisplayOptions(), profile = null)
+
+ assertEquals(
+ "string-${R.string.display_options_sort_by_family_name}",
+ summaryOf(uiState, SettingsItemId.SORT_ORDER),
+ )
+ assertEquals(
+ "string-${R.string.display_options_view_family_name_first}",
+ summaryOf(uiState, SettingsItemId.DISPLAY_ORDER),
+ )
+ assertEquals(
+ "string-${R.string.editor_options_hide_phonetic_names_if_empty}",
+ summaryOf(uiState, SettingsItemId.PHONETIC_NAME_DISPLAY),
+ )
+ }
+
+ @Test
+ fun map_copiesBuildVersionForTheAboutScreen() {
+ val uiState = mapper.map(
+ settingsData = settingsData(buildVersion = "1.7.40"),
+ profile = null,
+ )
+
+ assertEquals("1.7.40", uiState.buildVersion)
+ }
+
+ @Test
+ fun map_copiesSelectedDisplayOptionsForTheDialogs() {
+ val uiState = mapper.map(settingsData = alternativeDisplayOptions(), profile = null)
+
+ assertEquals(SortOrder.FAMILY_NAME_FIRST, uiState.sortOrder)
+ assertEquals(DisplayOrder.FAMILY_NAME_FIRST, uiState.displayOrder)
+ assertEquals(PhoneticNameDisplay.HIDE_IF_EMPTY, uiState.phoneticNameDisplay)
+ }
+
+ private fun alternativeDisplayOptions() = settingsData(
+ displaySettings = DISPLAY_SETTINGS.copy(
+ sortOrder = SortOrder.FAMILY_NAME_FIRST,
+ displayOrder = DisplayOrder.FAMILY_NAME_FIRST,
+ phoneticNameDisplay = PhoneticNameDisplay.HIDE_IF_EMPTY,
+ ),
+ )
+}
diff --git a/app/src/test/kotlin/com/android/contacts/ui/settings/screen/settingsviewmodel/BaseSettingsViewModelTest.kt b/app/src/test/kotlin/com/android/contacts/ui/settings/screen/settingsviewmodel/BaseSettingsViewModelTest.kt
new file mode 100644
index 0000000000..0f56d5d1f3
--- /dev/null
+++ b/app/src/test/kotlin/com/android/contacts/ui/settings/screen/settingsviewmodel/BaseSettingsViewModelTest.kt
@@ -0,0 +1,68 @@
+package com.android.contacts.ui.settings.screen.settingsviewmodel
+
+import com.android.contacts.data.profile.model.ProfileData
+import com.android.contacts.data.profile.repository.ProfileRepository
+import com.android.contacts.data.settings.model.SortOrder
+import com.android.contacts.data.settings.repository.DisplaySettingsRepository
+import com.android.contacts.data.simimport.model.SimImportResult
+import com.android.contacts.data.simimport.repository.SimImportResultRepository
+import com.android.contacts.domain.settings.model.SettingsData
+import com.android.contacts.domain.settings.usecase.GetSettingsData
+import com.android.contacts.tests.MainDispatcherRule
+import com.android.contacts.ui.settings.screen.SettingsViewModel
+import com.android.contacts.ui.settings.screen.mapper.SettingsUiStateMapper
+import com.android.contacts.ui.settings.screen.model.SettingsUiState
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import org.junit.Before
+import org.junit.Rule
+
+@OptIn(ExperimentalCoroutinesApi::class)
+internal abstract class BaseSettingsViewModelTest {
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule()
+
+ protected val getSettingsData = mockk()
+ protected val displaySettingsRepository = mockk(relaxed = true)
+ protected val settingsUiStateMapper = mockk()
+
+ protected val profiles = MutableStateFlow(ProfileData())
+ protected val simImportResults = MutableSharedFlow(extraBufferCapacity = 1)
+
+ protected val settingsData = mockk()
+ protected val reloadedSettingsData = mockk()
+ protected var settingsDataSource: Flow = flowOf(settingsData)
+ protected val mappedState = SettingsUiState(sortOrder = SortOrder.GIVEN_NAME_FIRST)
+ protected val reloadedState = SettingsUiState(sortOrder = SortOrder.FAMILY_NAME_FIRST)
+
+ private val simImportResultRepository = mockk()
+ private val profileRepository = mockk()
+
+ @Before
+ fun setUpDefaultStubs() {
+ every { getSettingsData() } answers { settingsDataSource }
+ every { profileRepository.observeProfile() } returns profiles
+ every { simImportResultRepository.observeSimImportResults() } returns simImportResults
+ every { settingsUiStateMapper.map(settingsData = settingsData, profile = any()) } returns
+ mappedState
+ every {
+ settingsUiStateMapper.map(settingsData = reloadedSettingsData, profile = any())
+ } returns reloadedState
+ }
+
+ protected fun createViewModel(): SettingsViewModel {
+ return SettingsViewModel(
+ getSettingsData = getSettingsData,
+ displaySettingsRepository = displaySettingsRepository,
+ settingsUiStateMapper = settingsUiStateMapper,
+ simImportResultRepository = simImportResultRepository,
+ profileRepository = profileRepository,
+ )
+ }
+}
diff --git a/app/src/test/kotlin/com/android/contacts/ui/settings/screen/settingsviewmodel/SettingsViewModelActionTest.kt b/app/src/test/kotlin/com/android/contacts/ui/settings/screen/settingsviewmodel/SettingsViewModelActionTest.kt
new file mode 100644
index 0000000000..95e407c1e1
--- /dev/null
+++ b/app/src/test/kotlin/com/android/contacts/ui/settings/screen/settingsviewmodel/SettingsViewModelActionTest.kt
@@ -0,0 +1,203 @@
+package com.android.contacts.ui.settings.screen.settingsviewmodel
+
+import app.cash.turbine.test
+import com.android.contacts.data.profile.model.ProfileData
+import com.android.contacts.data.settings.model.DisplayOrder
+import com.android.contacts.data.settings.model.PhoneticNameDisplay
+import com.android.contacts.data.settings.model.SortOrder
+import com.android.contacts.ui.settings.screen.model.SettingsAction as Action
+import com.android.contacts.ui.settings.screen.model.SettingsEffect as Effect
+import com.android.contacts.ui.settings.screen.model.SettingsItemId
+import com.android.contacts.ui.settings.screen.model.SettingsUiState
+import io.mockk.coVerify
+import io.mockk.every
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+internal class SettingsViewModelActionTest : BaseSettingsViewModelTest() {
+
+ @Test
+ fun onAction_whenAccountsClicked_opensAddAccount() =
+ assertItemClickEmits(SettingsItemId.ACCOUNTS, Effect.OpenAddAccount)
+
+ @Test
+ fun onAction_whenDefaultAccountClicked_opensPicker() =
+ assertItemClickEmits(SettingsItemId.DEFAULT_ACCOUNT, Effect.OpenDefaultAccountPicker)
+
+ @Test
+ fun onAction_whenContactsFilterClicked_opensFilter() =
+ assertItemClickEmits(SettingsItemId.CONTACTS_FILTER, Effect.OpenContactsFilter)
+
+ @Test
+ fun onAction_whenImportClicked_showsImportDialog() =
+ assertItemClickEmits(SettingsItemId.IMPORT, Effect.ShowImportDialog)
+
+ @Test
+ fun onAction_whenExportClicked_showsExportDialog() =
+ assertItemClickEmits(SettingsItemId.EXPORT, Effect.ShowExportDialog)
+
+ @Test
+ fun onAction_whenBlockedNumbersClicked_opensBlockedNumbers() =
+ assertItemClickEmits(SettingsItemId.BLOCKED_NUMBERS, Effect.OpenBlockedNumbers)
+
+ @Test
+ fun onAction_whenCallLogPermissionClicked_opensAppPermissions() =
+ assertItemClickEmits(SettingsItemId.CALL_LOG_PERMISSION, Effect.OpenAppPermissions)
+
+ @Test
+ fun onAction_whenLicensesClicked_opensLicenses() =
+ runTest(context = mainDispatcherRule.testDispatcher) {
+ val viewModel = createViewModel()
+
+ viewModel.effects.test {
+ viewModel.onAction(Action.LicensesClicked)
+
+ assertEquals(Effect.OpenLicenses, awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun onAction_whenBuildVersionLongClicked_copiesIt() =
+ runTest(context = mainDispatcherRule.testDispatcher) {
+ every {
+ settingsUiStateMapper.map(settingsData = settingsData, profile = any())
+ } returns SettingsUiState(buildVersion = BUILD_VERSION)
+
+ val viewModel = createViewModel()
+ backgroundScope.launch { viewModel.uiState.collect { } }
+ advanceUntilIdle()
+
+ viewModel.effects.test {
+ viewModel.onAction(Action.BuildVersionLongClicked)
+
+ assertEquals(Effect.CopyBuildVersion(BUILD_VERSION), awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun onAction_whenBuildVersionIsUnknown_emitsNoEffect() =
+ runTest(context = mainDispatcherRule.testDispatcher) {
+ val viewModel = createViewModel()
+
+ viewModel.effects.test {
+ viewModel.onAction(Action.BuildVersionLongClicked)
+ advanceUntilIdle()
+
+ expectNoEvents()
+ }
+ }
+
+ @Test
+ fun onAction_whenMyInfoClickedWithProfile_opensIt() =
+ runTest(context = mainDispatcherRule.testDispatcher) {
+ profiles.value = ProfileData(hasProfile = true, contactId = 7L)
+
+ val viewModel = createViewModel()
+ advanceUntilIdle()
+
+ viewModel.effects.test {
+ viewModel.onAction(Action.ItemClicked(SettingsItemId.MY_INFO))
+
+ assertEquals(Effect.OpenProfile(contactId = 7L), awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun onAction_whenMyInfoClickedWithoutProfile_offersToCreateIt() =
+ runTest(context = mainDispatcherRule.testDispatcher) {
+ profiles.value = ProfileData(hasProfile = false, contactId = 7L)
+
+ val viewModel = createViewModel()
+ advanceUntilIdle()
+
+ viewModel.effects.test {
+ viewModel.onAction(Action.ItemClicked(SettingsItemId.MY_INFO))
+
+ assertEquals(Effect.CreateProfile, awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun onAction_whenDialogRowsAreClicked_emitsNoEffect() =
+ runTest(context = mainDispatcherRule.testDispatcher) {
+ val viewModel = createViewModel()
+
+ viewModel.effects.test {
+ viewModel.onAction(Action.ItemClicked(SettingsItemId.SORT_ORDER))
+ viewModel.onAction(Action.ItemClicked(SettingsItemId.DISPLAY_ORDER))
+ viewModel.onAction(Action.ItemClicked(SettingsItemId.PHONETIC_NAME_DISPLAY))
+ viewModel.onAction(Action.ItemClicked(SettingsItemId.ABOUT))
+ advanceUntilIdle()
+
+ expectNoEvents()
+ }
+ }
+
+ @Test
+ fun onAction_whenSortOrderSelected_storesIt() =
+ runTest(context = mainDispatcherRule.testDispatcher) {
+ val viewModel = createViewModel()
+
+ viewModel.onAction(Action.SortOrderSelected(SortOrder.FAMILY_NAME_FIRST))
+ advanceUntilIdle()
+
+ coVerify(exactly = 1) {
+ displaySettingsRepository.setSortOrder(SortOrder.FAMILY_NAME_FIRST)
+ }
+ }
+
+ @Test
+ fun onAction_whenDisplayOrderSelected_storesIt() =
+ runTest(context = mainDispatcherRule.testDispatcher) {
+ val viewModel = createViewModel()
+
+ viewModel.onAction(Action.DisplayOrderSelected(DisplayOrder.FAMILY_NAME_FIRST))
+ advanceUntilIdle()
+
+ coVerify(exactly = 1) {
+ displaySettingsRepository.setDisplayOrder(DisplayOrder.FAMILY_NAME_FIRST)
+ }
+ }
+
+ @Test
+ fun onAction_whenPhoneticNameDisplaySelected_storesIt() =
+ runTest(context = mainDispatcherRule.testDispatcher) {
+ val viewModel = createViewModel()
+
+ viewModel.onAction(
+ Action.PhoneticNameDisplaySelected(PhoneticNameDisplay.HIDE_IF_EMPTY),
+ )
+ advanceUntilIdle()
+
+ coVerify(exactly = 1) {
+ displaySettingsRepository.setPhoneticNameDisplay(PhoneticNameDisplay.HIDE_IF_EMPTY)
+ }
+ }
+
+ private fun assertItemClickEmits(
+ id: SettingsItemId,
+ expected: Effect,
+ ) = runTest(context = mainDispatcherRule.testDispatcher) {
+ val viewModel = createViewModel()
+
+ viewModel.effects.test {
+ viewModel.onAction(Action.ItemClicked(id))
+
+ assertEquals(expected, awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ private companion object {
+ const val BUILD_VERSION = "1.7.40"
+ }
+}
diff --git a/app/src/test/kotlin/com/android/contacts/ui/settings/screen/settingsviewmodel/SettingsViewModelSimImportTest.kt b/app/src/test/kotlin/com/android/contacts/ui/settings/screen/settingsviewmodel/SettingsViewModelSimImportTest.kt
new file mode 100644
index 0000000000..072e92ce70
--- /dev/null
+++ b/app/src/test/kotlin/com/android/contacts/ui/settings/screen/settingsviewmodel/SettingsViewModelSimImportTest.kt
@@ -0,0 +1,57 @@
+package com.android.contacts.ui.settings.screen.settingsviewmodel
+
+import app.cash.turbine.test
+import com.android.contacts.data.simimport.model.SimImportResult
+import com.android.contacts.ui.settings.screen.model.SettingsEffect as Effect
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+internal class SettingsViewModelSimImportTest : BaseSettingsViewModelTest() {
+
+ @Test
+ fun simImportSuccess_isReportedAsEffect() =
+ runTest(context = mainDispatcherRule.testDispatcher) {
+ val viewModel = createViewModel()
+ advanceUntilIdle()
+
+ viewModel.effects.test {
+ simImportResults.emit(SimImportResult.Success(importedCount = 3))
+
+ assertEquals(Effect.ShowSimImportSuccess(importedCount = 3), awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun simImportFailure_isReportedAsEffect() =
+ runTest(context = mainDispatcherRule.testDispatcher) {
+ val viewModel = createViewModel()
+ advanceUntilIdle()
+
+ viewModel.effects.test {
+ simImportResults.emit(SimImportResult.Failure)
+
+ assertEquals(Effect.ShowSimImportFailure, awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun simImportResult_arrivingBeforeTheScreenCollects_isNotLost() =
+ runTest(context = mainDispatcherRule.testDispatcher) {
+ val viewModel = createViewModel()
+ advanceUntilIdle()
+
+ simImportResults.emit(SimImportResult.Success(importedCount = 3))
+ advanceUntilIdle()
+
+ viewModel.effects.test {
+ assertEquals(Effect.ShowSimImportSuccess(importedCount = 3), awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+}
diff --git a/app/src/test/kotlin/com/android/contacts/ui/settings/screen/settingsviewmodel/SettingsViewModelStateTest.kt b/app/src/test/kotlin/com/android/contacts/ui/settings/screen/settingsviewmodel/SettingsViewModelStateTest.kt
new file mode 100644
index 0000000000..07f7088b79
--- /dev/null
+++ b/app/src/test/kotlin/com/android/contacts/ui/settings/screen/settingsviewmodel/SettingsViewModelStateTest.kt
@@ -0,0 +1,83 @@
+package com.android.contacts.ui.settings.screen.settingsviewmodel
+
+import app.cash.turbine.test
+import com.android.contacts.data.profile.model.ProfileData
+import com.android.contacts.ui.settings.screen.model.SettingsUiState
+import io.mockk.every
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+internal class SettingsViewModelStateTest : BaseSettingsViewModelTest() {
+
+ @Test
+ fun uiState_startsEmptyAndThenShowsMappedState() =
+ runTest(context = mainDispatcherRule.testDispatcher) {
+ val viewModel = createViewModel()
+
+ viewModel.uiState.test {
+ assertEquals(SettingsUiState(), awaitItem())
+ assertEquals(mappedState, awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun uiState_whenProfileChanges_isRemapped() =
+ runTest(context = mainDispatcherRule.testDispatcher) {
+ val profile = ProfileData(hasProfile = true, contactId = 7L, displayName = "Anna")
+ every {
+ settingsUiStateMapper.map(settingsData = settingsData, profile = profile)
+ } returns reloadedState
+
+ val viewModel = createViewModel()
+ viewModel.uiState.test {
+ assertEquals(SettingsUiState(), awaitItem())
+ assertEquals(mappedState, awaitItem())
+
+ profiles.value = profile
+
+ assertEquals(reloadedState, awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun uiState_whenSettingsDataEmitsAgain_isRemapped() =
+ runTest(context = mainDispatcherRule.testDispatcher) {
+ val settingsDataEmissions = MutableStateFlow(settingsData)
+ settingsDataSource = settingsDataEmissions
+
+ val viewModel = createViewModel()
+ viewModel.uiState.test {
+ assertEquals(SettingsUiState(), awaitItem())
+ assertEquals(mappedState, awaitItem())
+
+ settingsDataEmissions.value = reloadedSettingsData
+
+ assertEquals(reloadedState, awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun refreshState_reloadsSettingsData() =
+ runTest(context = mainDispatcherRule.testDispatcher) {
+ val viewModel = createViewModel()
+
+ viewModel.uiState.test {
+ assertEquals(SettingsUiState(), awaitItem())
+ assertEquals(mappedState, awaitItem())
+
+ settingsDataSource = flowOf(reloadedSettingsData)
+ viewModel.refreshState()
+
+ assertEquals(reloadedState, awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+}
diff --git a/app/src/test/resources/robolectric.properties b/app/src/test/resources/robolectric.properties
new file mode 100644
index 0000000000..16f02dc6a3
--- /dev/null
+++ b/app/src/test/resources/robolectric.properties
@@ -0,0 +1,2 @@
+sdk=36
+application=android.app.Application
diff --git a/build.gradle.kts b/build.gradle.kts
index 1e673e6fff..d0a58b4090 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,6 +1,5 @@
import com.android.build.api.dsl.LibraryExtension
import org.jlleitschuh.gradle.ktlint.KtlintExtension
-
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.hilt) apply false
diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml
index 0aff4317a8..0f643760ed 100644
--- a/config/detekt/detekt.yml
+++ b/config/detekt/detekt.yml
@@ -6,6 +6,9 @@ complexity:
LongParameterList:
ignoreDefaultParameters: true
TooManyFunctions:
+ allowedFunctionsPerClass: 60
+ allowedFunctionsPerFile: 15
+ allowedFunctionsPerInterface: 50
ignoreAnnotatedFunctions:
- Preview
@@ -15,11 +18,15 @@ naming:
- Composable
style:
+ ForbiddenComment:
+ active: false
+
MagicNumber:
ignoreCompanionObjectPropertyDeclaration: true
ignorePropertyDeclaration: true
ignoreAnnotated:
- Composable
+
UnusedPrivateFunction:
ignoreAnnotated:
- Preview
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 0aeeef89aa..c70997234d 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -10,6 +10,8 @@ ktlint-gradle = "14.2.0"
activity-compose = "1.13.0"
appcompat = "1.7.1"
compose-bom = "2026.06.01"
+lifecycle = "2.10.0"
+kotlinx-immutable = "0.5.0"
coroutines = "1.11.0"
guava = "33.6.0-android"
material = "1.14.0"
@@ -39,6 +41,7 @@ androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4
androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
+androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-palette = { module = "androidx.palette:palette", version.ref = "palette" }
androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "swiperefreshlayout" }
@@ -49,6 +52,7 @@ hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt"
hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" }
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
+kotlinx-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx-immutable" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml
index 39708e3663..36e6c14b26 100644
--- a/gradle/verification-metadata.xml
+++ b/gradle/verification-metadata.xml
@@ -1878,6 +1878,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -2061,6 +2078,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -2226,6 +2260,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -2403,6 +2451,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -2662,6 +2727,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -2941,6 +3023,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -3106,6 +3205,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -3833,6 +3946,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -3966,6 +4093,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -3988,33 +4129,24 @@
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
-
-
-
-
-
-
@@ -4024,23 +4156,32 @@
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -4087,32 +4228,32 @@
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -4126,25 +4267,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -4153,6 +4275,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -4247,18 +4383,40 @@
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
@@ -4295,21 +4453,40 @@
-
-
-
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
-
-
+
+
@@ -4334,32 +4511,18 @@
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
+
+
-
+
@@ -4370,6 +4533,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -4435,9 +4615,6 @@
-
-
-
@@ -4445,18 +4622,71 @@
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
@@ -4492,18 +4722,21 @@
-
-
-
+
+
+
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -4550,38 +4783,21 @@
-
-
-
-
-
+
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
+
+
-
-
+
+
-
-
-
-
-
-
-
-
+
+
@@ -4902,26 +5118,38 @@
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -4975,21 +5203,43 @@
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
@@ -5037,17 +5287,22 @@
-
+
+
+
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -5089,6 +5344,17 @@
+
+
+
+
+
+
+
+
+
+
+
@@ -5107,6 +5373,9 @@
+
+
+
@@ -5122,6 +5391,17 @@
+
+
+
+
+
+
+
+
+
+
+
@@ -5144,6 +5424,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -5162,6 +5456,9 @@
+
+
+
@@ -5206,6 +5503,9 @@
+
+
+
@@ -5221,6 +5521,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -6567,6 +6881,11 @@
+
+
+
+
+
@@ -8250,6 +8569,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -10149,6 +10482,14 @@
+
+
+
+
+
+
+
+
@@ -10675,6 +11016,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -12371,6 +12726,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -13041,6 +13413,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lint-baseline.xml b/lint-baseline.xml
index 5ab8a0b622..a01bdd34b0 100644
--- a/lint-baseline.xml
+++ b/lint-baseline.xml
@@ -23,17 +23,6 @@
column="17"/>
-
-
-
-
-
-
-
+
-
-
+
+
+
diff --git a/res/values/integers.xml b/res/values/integers.xml
index 718e4f6d2b..0d28cb7b00 100644
--- a/res/values/integers.xml
+++ b/res/values/integers.xml
@@ -47,9 +47,6 @@
250
-
- 100
-
190
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 0890232806..e1730ad7a6 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -1328,6 +1328,24 @@
About Contacts
+
+ Display options
+
+
+ Manage contacts
+
+
+ Permissions
+
+
+ Call logs
+
+
+ Allowed
+
+
+ Not allowed
+
Share favorite contacts
diff --git a/res/values/styles.xml b/res/values/styles.xml
index 385deb2d1c..ee027e00e5 100644
--- a/res/values/styles.xml
+++ b/res/values/styles.xml
@@ -16,7 +16,9 @@
-
+