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