From 2d22146d410a8e43929ed593a3c2bd86609722ee Mon Sep 17 00:00:00 2001 From: Mario Noll <8122102+MarioNoll@users.noreply.github.com> Date: Sat, 16 May 2026 12:29:50 +0200 Subject: [PATCH 1/3] Overwrite photo file provider authority; Fixes conflicts with release builds --- app/build.gradle.kts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2e870968d..8956da70d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -52,8 +52,12 @@ android { applicationIdSuffix = ".debug" val selfPkgName = android.namespace + applicationIdSuffix resValue("string", "applicationLabel", "Contacts d") - resValue("string", "contacts_file_provider_authority", "$selfPkgName.files") resValue("string", "contacts_sdn_provider_authority", "$selfPkgName.sdn") + + "$selfPkgName.files".also { value -> + resValue("string", "contacts_file_provider_authority", value) + resValue("string", "photo_file_provider_authority", value) + } } } From cb27de6ee6584cca81d56c2f1255000388ab81b9 Mon Sep 17 00:00:00 2001 From: Mario Noll <8122102+MarioNoll@users.noreply.github.com> Date: Sat, 16 May 2026 12:29:52 +0200 Subject: [PATCH 2/3] Add first draft of contact editor rewrite --- AndroidManifest.xml | 4 +- app/build.gradle.kts | 18 +- .../com/android/contacts/di/HiltTestRunner.kt | 13 + .../contacts/editor/ContactEditorRobot.kt | 72 + .../contacts/editor/ContactEditorTest.kt | 47 + app/src/debug/AndroidManifest.xml | 10 + .../android/contacts/di/HiltTestActivity.kt | 7 + .../viewmodel/ContactEditorViewModelTest.kt | 108 + .../contacts/util/MainDispatcherRule.kt | 22 + gradle/libs.versions.toml | 10 + gradle/verification-metadata.xml | 2511 +++++++++-------- res/values/strings.xml | 6 +- .../ContactEditorSpringBoardActivity.java | 3 +- .../editor/ContactEditorFragment.java | 3 +- .../contacts/editor/EditorIntents.java | 5 +- .../contacts/editornew/ContactEditor.kt | 74 + .../editornew/ContactEditorActivityNew.kt | 53 + .../contacts/editornew/ContactEditorEffect.kt | 11 + .../contacts/editornew/ContactEditorEvent.kt | 21 + .../editornew/ContactEditorUiState.kt | 23 + .../editornew/ContactEditorViewModel.kt | 109 + .../editornew/contact/ContactDelegate.kt | 86 + .../editornew/contact/ContactState.kt | 12 + .../editornew/di/ContactEditorBindsModule.kt | 25 + .../editornew/di/ContactEditorModule.kt | 34 + .../editornew/photo/ContactEditorPhoto.kt | 203 ++ .../contacts/editornew/photo/PhotoType.kt | 6 + .../editornew/photo/picker/PhotoDelegate.kt | 123 + .../photo/picker/PhotoDelegateHelper.kt | 68 + .../editornew/photo/picker/PhotoEffect.kt | 9 + .../editornew/photo/picker/PhotoPicker.kt | 64 + .../photo/picker/PhotoPickerState.kt | 15 + .../photo/picker/PhotoSourceChooserDialog.kt | 129 + .../editornew/ui/ContactEditorScreen.kt | 66 + .../editornew/ui/ContactEditorTopAppBar.kt | 52 + 35 files changed, 2869 insertions(+), 1153 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/android/contacts/di/HiltTestRunner.kt create mode 100644 app/src/androidTest/kotlin/com/android/contacts/editor/ContactEditorRobot.kt create mode 100644 app/src/androidTest/kotlin/com/android/contacts/editor/ContactEditorTest.kt create mode 100644 app/src/debug/AndroidManifest.xml create mode 100644 app/src/debug/kotlin/com/android/contacts/di/HiltTestActivity.kt create mode 100644 app/src/test/kotlin/com/android/contacts/editornew/viewmodel/ContactEditorViewModelTest.kt create mode 100644 app/src/test/kotlin/com/android/contacts/util/MainDispatcherRule.kt create mode 100644 src/com/android/contacts/editornew/ContactEditor.kt create mode 100644 src/com/android/contacts/editornew/ContactEditorActivityNew.kt create mode 100644 src/com/android/contacts/editornew/ContactEditorEffect.kt create mode 100644 src/com/android/contacts/editornew/ContactEditorEvent.kt create mode 100644 src/com/android/contacts/editornew/ContactEditorUiState.kt create mode 100644 src/com/android/contacts/editornew/ContactEditorViewModel.kt create mode 100644 src/com/android/contacts/editornew/contact/ContactDelegate.kt create mode 100644 src/com/android/contacts/editornew/contact/ContactState.kt create mode 100644 src/com/android/contacts/editornew/di/ContactEditorBindsModule.kt create mode 100644 src/com/android/contacts/editornew/di/ContactEditorModule.kt create mode 100644 src/com/android/contacts/editornew/photo/ContactEditorPhoto.kt create mode 100644 src/com/android/contacts/editornew/photo/PhotoType.kt create mode 100644 src/com/android/contacts/editornew/photo/picker/PhotoDelegate.kt create mode 100644 src/com/android/contacts/editornew/photo/picker/PhotoDelegateHelper.kt create mode 100644 src/com/android/contacts/editornew/photo/picker/PhotoEffect.kt create mode 100644 src/com/android/contacts/editornew/photo/picker/PhotoPicker.kt create mode 100644 src/com/android/contacts/editornew/photo/picker/PhotoPickerState.kt create mode 100644 src/com/android/contacts/editornew/photo/picker/PhotoSourceChooserDialog.kt create mode 100644 src/com/android/contacts/editornew/ui/ContactEditorScreen.kt create mode 100644 src/com/android/contacts/editornew/ui/ContactEditorTopAppBar.kt diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 75ceec99e..15514776d 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -390,7 +390,7 @@ @@ -408,7 +408,7 @@ + android:targetActivity=".editornew.ContactEditorActivityNew"> diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8956da70d..0dfd35c9f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,4 +1,5 @@ import dev.detekt.gradle.Detekt +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { alias(libs.plugins.android.application) @@ -44,7 +45,7 @@ android { defaultConfig { minSdk = 36 targetSdk = 36 - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunner = "com.android.contacts.di.HiltTestRunner" } buildTypes { @@ -89,13 +90,16 @@ 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.collections.immutable) implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.coroutines.guava) implementation(libs.material) @@ -107,6 +111,7 @@ dependencies { debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.tooling) + testImplementation(libs.androidx.test.core) testImplementation(libs.junit4) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockk) @@ -119,6 +124,7 @@ dependencies { androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.test.espresso.core) androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.rules) androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.hilt.android.testing) @@ -129,3 +135,13 @@ dependencies { androidTestImplementation(libs.mockk.android) androidTestImplementation(libs.turbine) } + + +tasks.withType { + compilerOptions { + freeCompilerArgs.addAll( + "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api", + "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", + ) + } +} diff --git a/app/src/androidTest/kotlin/com/android/contacts/di/HiltTestRunner.kt b/app/src/androidTest/kotlin/com/android/contacts/di/HiltTestRunner.kt new file mode 100644 index 000000000..cbbc4762d --- /dev/null +++ b/app/src/androidTest/kotlin/com/android/contacts/di/HiltTestRunner.kt @@ -0,0 +1,13 @@ +package com.android.contacts.di + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +@Suppress("unused") +internal class HiltTestRunner : AndroidJUnitRunner() { + override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { + return super.newApplication(cl, HiltTestApplication::class.java.name, context) + } +} diff --git a/app/src/androidTest/kotlin/com/android/contacts/editor/ContactEditorRobot.kt b/app/src/androidTest/kotlin/com/android/contacts/editor/ContactEditorRobot.kt new file mode 100644 index 000000000..a23031239 --- /dev/null +++ b/app/src/androidTest/kotlin/com/android/contacts/editor/ContactEditorRobot.kt @@ -0,0 +1,72 @@ +package com.android.contacts.editor + +import androidx.annotation.StringRes +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.rules.ActivityScenarioRule +import com.android.contacts.R +import com.android.contacts.di.HiltTestActivity +import com.android.contacts.editornew.ContactEditor +import com.android.contacts.ui.core.AppTheme + +internal class ContactEditorRobot( + private val composeTestRule: AndroidComposeTestRule, HiltTestActivity>, +) { + init { + composeTestRule.setContent { + AppTheme { + ContactEditor( + onNavigateBack = {}, + ) + } + } + } + + fun photoPlaceholderIsShown(): ContactEditorRobot = also { + assertIsDisplayed(testTag = "contact_editor_photo_placeholder") + } + + fun choosePhotoSourceDialogIsShown(): ContactEditorRobot = also { + assertIsDisplayed(testTag = "contact_editor_photo_source_chooser_dialog_content") + } + + fun clickPhotoPlaceholder(): ContactEditorRobot = also { + performClick(testTag = "contact_editor_photo_placeholder") + } + + fun clickAddPhoto(): ContactEditorRobot = also { + performClick(resId = R.string.contact_editor_photo_add) + } + + private fun assertIsDisplayed(testTag: String) { + onNodeWithTag(testTag = testTag) + .assertIsDisplayed() + } + + private fun performClick(testTag: String) { + onNodeWithTag(testTag = testTag) + .performClick() + } + + private fun performClick(@StringRes resId: Int) { + composeTestRule + .onNodeWithText(resId = resId) + .performClick() + } + + private fun onNodeWithTag(testTag: String): SemanticsNodeInteraction { + return composeTestRule + .onNodeWithTag(testTag = testTag) + } + + private fun SemanticsNodeInteractionsProvider.onNodeWithText( + @StringRes resId: Int, + ): SemanticsNodeInteraction = onNodeWithText( + text = composeTestRule.activity.getString(resId), + ) +} diff --git a/app/src/androidTest/kotlin/com/android/contacts/editor/ContactEditorTest.kt b/app/src/androidTest/kotlin/com/android/contacts/editor/ContactEditorTest.kt new file mode 100644 index 000000000..cb766e2fa --- /dev/null +++ b/app/src/androidTest/kotlin/com/android/contacts/editor/ContactEditorTest.kt @@ -0,0 +1,47 @@ +package com.android.contacts.editor + +import android.Manifest +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.rule.GrantPermissionRule +import com.android.contacts.di.HiltTestActivity +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Rule +import org.junit.Test + +@HiltAndroidTest +internal class ContactEditorTest { + + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + var permissionRule: GrantPermissionRule = GrantPermissionRule.grant( + Manifest.permission.GET_ACCOUNTS, + Manifest.permission.READ_CONTACTS, + Manifest.permission.WRITE_CONTACTS, + ) + + @get:Rule(order = 2) + val composeTestRule = createAndroidComposeRule() + + @Test + fun defaultPhotoPlaceHolderIsShown() { + ContactEditorRobot(composeTestRule) + .photoPlaceholderIsShown() + } + + @Test + fun clickAddPhotoOpensChooserDialog() { + ContactEditorRobot(composeTestRule) + .clickAddPhoto() + .choosePhotoSourceDialogIsShown() + } + + @Test + fun clickPlaceholderOpensChooserDialog() { + ContactEditorRobot(composeTestRule) + .clickPhotoPlaceholder() + .choosePhotoSourceDialogIsShown() + } +} diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..5fe28af40 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/app/src/debug/kotlin/com/android/contacts/di/HiltTestActivity.kt b/app/src/debug/kotlin/com/android/contacts/di/HiltTestActivity.kt new file mode 100644 index 000000000..70e3b2ac6 --- /dev/null +++ b/app/src/debug/kotlin/com/android/contacts/di/HiltTestActivity.kt @@ -0,0 +1,7 @@ +package com.android.contacts.di + +import androidx.activity.ComponentActivity +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class HiltTestActivity : ComponentActivity() diff --git a/app/src/test/kotlin/com/android/contacts/editornew/viewmodel/ContactEditorViewModelTest.kt b/app/src/test/kotlin/com/android/contacts/editornew/viewmodel/ContactEditorViewModelTest.kt new file mode 100644 index 000000000..fd53aae9e --- /dev/null +++ b/app/src/test/kotlin/com/android/contacts/editornew/viewmodel/ContactEditorViewModelTest.kt @@ -0,0 +1,108 @@ +package com.android.contacts.editornew.viewmodel + +import androidx.core.net.toUri +import androidx.test.core.app.ApplicationProvider +import app.cash.turbine.TurbineTestContext +import app.cash.turbine.test +import com.android.contacts.editornew.ContactEditorEvent +import com.android.contacts.editornew.ContactEditorUiState +import com.android.contacts.editornew.ContactEditorUiState.PhotoUiState +import com.android.contacts.editornew.ContactEditorViewModel +import com.android.contacts.editornew.contact.ContactDelegate +import com.android.contacts.editornew.photo.PhotoType +import com.android.contacts.editornew.photo.picker.PhotoDelegate +import com.android.contacts.editornew.photo.picker.PhotoDelegateImpl +import com.android.contacts.util.MainDispatcherRule +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ContactEditorViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `On add photo click, show photo source choose dialog`() = runTest { + val viewModel = viewModel() + + viewModel.uiState.test { + awaitItem() + viewModel.onEvent(ContactEditorEvent.Photo.AddOrChangeClick) + assertPhotoSourceDialogPhotoType(expectedType = PhotoType.New) + } + } + + @Test + fun `If photo exists, add photo click shows photo source dialog with type replace`() = runTest { + val viewModel = viewModel() + + viewModel.uiState.test { + awaitItem() + setCropResultAndAssert(viewModel) + viewModel.onEvent(ContactEditorEvent.Photo.AddOrChangeClick) + assertPhotoSourceDialogPhotoType(expectedType = PhotoType.Replace) + } + } + + @Test + fun `On cropped photo result, will display photo`() = runTest { + val viewModel = viewModel() + + viewModel.uiState.test { + awaitItem() + setCropResultAndAssert(viewModel) + } + } + + @Test + fun `On remove photo, will display photo placeholder`() = runTest { + val viewModel = viewModel() + + viewModel.uiState.test { + awaitItem() + setCropResultAndAssert(viewModel) + viewModel.onEvent(ContactEditorEvent.Photo.RemoveClick) + awaitItem().apply { + assertEquals(photoUiState, PhotoUiState.Placeholder) + } + } + } + + private suspend fun TurbineTestContext.setCropResultAndAssert( + viewModel: ContactEditorViewModel, + ) { + viewModel.onEvent(ContactEditorEvent.Photo.CropResult("test".toUri())) + awaitItem().apply { + assertEquals(photoUiState, PhotoUiState.Photo("test".toUri())) + } + } + + private suspend fun TurbineTestContext.assertPhotoSourceDialogPhotoType( + expectedType: PhotoType, + ) { + awaitItem().apply { + assertNotNull(photoSourceDialogUiState) + assertEquals(photoSourceDialogUiState!!.type, expectedType) + } + } + + private fun viewModel( + photoDelegate: PhotoDelegate = PhotoDelegateImpl( + helper = mockk(relaxed = true), + ), + contactDelegate: ContactDelegate = mockk(relaxed = true), + ): ContactEditorViewModel { + return ContactEditorViewModel( + context = ApplicationProvider.getApplicationContext(), + photoDelegate = photoDelegate, + contactDelegate = contactDelegate, + ) + } +} diff --git a/app/src/test/kotlin/com/android/contacts/util/MainDispatcherRule.kt b/app/src/test/kotlin/com/android/contacts/util/MainDispatcherRule.kt new file mode 100644 index 000000000..2b274ad70 --- /dev/null +++ b/app/src/test/kotlin/com/android/contacts/util/MainDispatcherRule.kt @@ -0,0 +1,22 @@ +package com.android.contacts.util + +import kotlinx.coroutines.Dispatchers +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 + +internal 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index c9754de41..372cb1b43 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,8 @@ appcompat = "1.7.1" compose-bom = "2026.03.01" coroutines = "1.10.2" guava = "33.5.0-android" +kotlinx-collections-immutable = "0.4.0" +lifecycle = "2.10.0" material = "1.13.0" palette = "1.0.0" swiperefreshlayout = "1.2.0" @@ -21,8 +23,10 @@ mockk = "1.14.9" robolectric = "4.16.1" turbine = "1.2.1" +androidx-test-core = "1.7.0" androidx-test-espresso = "3.7.0" androidx-test-ext-junit = "1.3.0" +androidx-test-rules = "1.7.0" androidx-test-runner = "1.7.0" [libraries] @@ -40,6 +44,8 @@ androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-mani 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,7 +55,9 @@ 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-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx-collections-immutable" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } material = { module = "com.google.android.material:material", version.ref = "material" } @@ -65,8 +73,10 @@ hilt-gradle-plugin = { module = "com.google.dagger:hilt-android-gradle-plugin", kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } ksp-gradle-plugin = { module = "com.google.devtools.ksp:symbol-processing-gradle-plugin", version.ref = "ksp" } +androidx-test-core = { module = "androidx.test:core-ktx", version.ref = "androidx-test-core" } androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso" } androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test-rules" } androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } [plugins] diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index d88942143..b3705d4b3 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -3,48 +3,56 @@ true false + + + + + + - - - - - - - - - + + + + + + + + + + + + + + - - - @@ -76,16 +84,21 @@ + + + + + + + + - - - @@ -93,37 +106,37 @@ + + + - - - + + + - - - + + + - - - @@ -131,15 +144,15 @@ + + + - - - @@ -147,37 +160,37 @@ + + + - - - + + + - - - + + + - - - @@ -200,26 +213,26 @@ + + + - - - + + + - - - @@ -232,18 +245,18 @@ - - - - - - + + + + + + @@ -251,18 +264,18 @@ - - - - - - + + + + + + @@ -270,18 +283,18 @@ - - - - - - + + + + + + @@ -289,18 +302,18 @@ - - - - - - + + + + + + @@ -308,18 +321,18 @@ - - - - - - + + + + + + @@ -327,18 +340,18 @@ - - - - - - + + + + + + @@ -354,15 +367,15 @@ + + + - - - @@ -378,15 +391,15 @@ + + + - - - @@ -394,18 +407,18 @@ - - - - - - + + + + + + @@ -413,18 +426,18 @@ - - - - - - + + + + + + @@ -432,18 +445,18 @@ - - - - - - + + + + + + @@ -451,18 +464,18 @@ - - - - - - + + + + + + @@ -470,18 +483,18 @@ - - - - - - + + + + + + @@ -489,18 +502,18 @@ - - - - - - + + + + + + @@ -508,15 +521,15 @@ + + + - - - @@ -524,18 +537,18 @@ - - - - - - + + + + + + @@ -543,18 +556,18 @@ - - - - - - + + + + + + @@ -562,26 +575,26 @@ + + + - - - + + + - - - @@ -589,18 +602,18 @@ - - - - - - + + + + + + @@ -608,18 +621,18 @@ - - - - - - + + + + + + @@ -627,15 +640,15 @@ + + + - - - @@ -643,15 +656,15 @@ + + + - - - @@ -659,18 +672,18 @@ - - - - - - + + + + + + @@ -678,48 +691,53 @@ + + + - - + + + + + + + - - - + + + - - - + + + - - - @@ -730,29 +748,29 @@ - - - - - - + + + + + + + + + - - - @@ -760,95 +778,100 @@ + + + + + + - - - - - + + + + + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - @@ -864,26 +887,26 @@ + + + - - - + + + - - - @@ -891,15 +914,15 @@ + + + - - - @@ -912,15 +935,15 @@ + + + - - - @@ -928,26 +951,26 @@ + + + - - - + + + - - - @@ -955,15 +978,15 @@ + + + - - - @@ -974,15 +997,15 @@ + + + - - - @@ -992,6 +1015,16 @@ + + + + + + + + + + @@ -1012,34 +1045,73 @@ + + + + + + + + + + + - - + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + @@ -1047,6 +1119,14 @@ + + + + + + + + @@ -1057,26 +1137,47 @@ + + + + + + + + - - + + + + + + + + + + - - + + + + + + + @@ -1090,21 +1191,26 @@ + + + - - - + + + + + @@ -1120,15 +1226,28 @@ + + + + + + + + + + + - - + + + + @@ -1141,34 +1260,63 @@ - - - + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + @@ -1176,6 +1324,11 @@ + + + + + @@ -1184,15 +1337,44 @@ + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + @@ -1201,14 +1383,19 @@ + + + - - + + + + @@ -1221,35 +1408,48 @@ + + + + + - - - + + + - - + + + + + + + + + + + + + - - - @@ -1260,15 +1460,15 @@ + + + - - - @@ -1276,18 +1476,18 @@ - - - - - - + + + + + + @@ -1295,29 +1495,29 @@ - - - - - - + + + + + + + + + - - - @@ -1333,37 +1533,37 @@ + + + - - - + + + - - - + + + - - - @@ -1380,23 +1580,36 @@ + + + + + + + + + + + - - + + + + - - + + @@ -1409,18 +1622,31 @@ + + + + + + + + + + + - - + + + + - - + + @@ -1429,14 +1655,22 @@ + + + - - + + + + + + + @@ -1445,103 +1679,119 @@ + + + - - - + + + - - - + + + - - + + + + + + + + + + - - + + + + + + + + + + - - - - - + + + + + - - - + + + - - - + + + - - - + + + - - - @@ -1549,117 +1799,117 @@ + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - - - - - - - + + + + + + @@ -1667,18 +1917,18 @@ - - - - - - + + + + + + @@ -1686,15 +1936,15 @@ + + + - - - @@ -1718,40 +1968,40 @@ - - - - - - + + + + + + + + + - - - + + + - - - @@ -1759,15 +2009,15 @@ + + + - - - @@ -1775,191 +2025,207 @@ + + + - - - + + + - - - + + + - - + + + + + + + + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - + + + + + + + + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - @@ -1970,92 +2236,92 @@ - - + + + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - @@ -2065,38 +2331,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + - - - @@ -2235,15 +2557,15 @@ + + + - - - @@ -2333,29 +2655,29 @@ - - - - - - + + + + + + + + + - - - @@ -2368,18 +2690,18 @@ + + + + + + - - - - - - @@ -2390,15 +2712,15 @@ + + + - - - @@ -2432,26 +2754,26 @@ + + + - - - + + + - - - @@ -2462,18 +2784,18 @@ - - - - - - + + + + + + @@ -2500,43 +2822,43 @@ - - - - - - + + + + + + + + + - - - - - - - - - + + + + + + @@ -2547,18 +2869,18 @@ - - - - - - + + + + + + @@ -2575,6 +2897,14 @@ + + + + + + + + @@ -2584,51 +2914,51 @@ + + + - - - + + + - - - + + + - - - - - - - - - + + + + + + @@ -2649,15 +2979,15 @@ + + + - - - @@ -2668,18 +2998,18 @@ - - - - - - + + + + + + @@ -2712,15 +3042,15 @@ + + + - - - @@ -2744,29 +3074,29 @@ + + + - - - - - - - - - + + + + + + @@ -2785,15 +3115,15 @@ + + + - - - @@ -2813,11 +3143,26 @@ + + + + + + + + + + + + + + + @@ -2840,40 +3185,40 @@ + + + - - - - - - - - - + + + + + + + + + - - - @@ -2901,15 +3246,15 @@ + + + - - - @@ -2933,15 +3278,15 @@ + + + - - - @@ -3041,57 +3386,57 @@ + + + - - - - - - - - - - - - - + + - - + + + + - - - - + + - - + + + + + + + + + + @@ -3214,26 +3559,26 @@ + + + - - - + + + - - - @@ -3254,15 +3599,15 @@ + + + - - - @@ -3285,15 +3630,15 @@ + + + - - - @@ -3301,37 +3646,37 @@ + + + - - - + + + - - - + + + - - - @@ -3358,15 +3703,15 @@ + + + - - - @@ -3570,6 +3915,11 @@ + + + + + @@ -3774,26 +4124,26 @@ + + + - - - + + + - - - @@ -3809,15 +4159,15 @@ + + + - - - @@ -3825,26 +4175,26 @@ + + + - - - + + + - - - @@ -4065,40 +4415,40 @@ + + + - - - - - - - - - + + + + + + - - + + + + + - - - @@ -4106,74 +4456,74 @@ - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - - - - - + + - - + + + + - - - - + + - - + + + + + + + + + + @@ -4186,26 +4536,26 @@ + + + - - - + + + - - - @@ -4216,26 +4566,26 @@ + + + - - - + + + - - - @@ -4268,26 +4618,26 @@ + + + - - - + + + - - - @@ -4310,15 +4660,15 @@ + + + - - - @@ -4354,84 +4704,84 @@ + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - - - - - - - + + + + + + + + + - - - @@ -4463,14 +4813,22 @@ + + + - - + + + + + + + @@ -4510,18 +4868,18 @@ - - - - - - + + + + + + @@ -4557,54 +4915,54 @@ + + + - - - + + + - - - - - - - - - - - - - + + - - + + + + + + + + + + @@ -4657,67 +5015,80 @@ + + + - - - + + + + + + - - - - - - - - - - - - - - - - - - - + + - - + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + + @@ -4729,17 +5100,17 @@ + + + - - - - - + + @@ -4753,26 +5124,26 @@ + + + - - - + + + - - - @@ -4823,15 +5194,15 @@ + + + - - - @@ -4842,15 +5213,15 @@ + + + - - - @@ -4869,65 +5240,65 @@ + + + - - - - - + + + + + - - - + + + - - - - - + + + + + - - - + + + - - - @@ -4938,15 +5309,15 @@ + + + - - - @@ -4957,15 +5328,15 @@ + + + - - - @@ -4992,29 +5363,29 @@ - - - - - - + + + + + + + + + - - - @@ -5089,26 +5460,26 @@ + + + - - - + + + - - - @@ -5152,15 +5523,15 @@ + + + - - - @@ -5171,15 +5542,15 @@ + + + - - - @@ -5190,15 +5561,15 @@ + + + - - - @@ -5209,15 +5580,15 @@ + + + - - - @@ -5228,48 +5599,48 @@ + + + - - - + + + - - - + + + - - - + + + - - - @@ -5289,16 +5660,29 @@ + + + + + + + + + + + + + + + + - - - @@ -5315,6 +5699,11 @@ + + + + + @@ -5341,15 +5730,15 @@ + + + - - - @@ -5381,14 +5770,22 @@ + + + - - + + + + + + + @@ -5397,15 +5794,15 @@ + + + - - - @@ -5431,26 +5828,26 @@ + + + - - - + + + - - - @@ -5458,26 +5855,26 @@ + + + - - - + + + - - - @@ -5485,34 +5882,34 @@ - - - - - - + + + + + + - - - + + + - - - + + + @@ -5520,29 +5917,29 @@ + + + - - - - - - - - - + + + + + + @@ -5555,29 +5952,29 @@ - - - - - - + + + + + + + + + - - - @@ -5588,15 +5985,15 @@ + + + - - - @@ -5604,54 +6001,54 @@ - - - - - - + + + + + + + + + - - - - - - - - - + + + + + + + + + - - - @@ -5662,15 +6059,15 @@ + + + - - - @@ -5713,18 +6110,18 @@ - - - - - - + + + + + + @@ -5767,18 +6164,18 @@ - - - - - - + + + + + + @@ -5789,15 +6186,15 @@ + + + - - - @@ -5841,204 +6238,28 @@ + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - + + diff --git a/res/values/strings.xml b/res/values/strings.xml index 089023280..8e5a7bae2 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -1557,4 +1557,8 @@ Select - \ No newline at end of file + Remove + Change + Add picture + Create contact + diff --git a/src/com/android/contacts/activities/ContactEditorSpringBoardActivity.java b/src/com/android/contacts/activities/ContactEditorSpringBoardActivity.java index 64283d2fa..a5365c8bc 100644 --- a/src/com/android/contacts/activities/ContactEditorSpringBoardActivity.java +++ b/src/com/android/contacts/activities/ContactEditorSpringBoardActivity.java @@ -22,6 +22,7 @@ import com.android.contacts.editor.PickRawContactLoader; import com.android.contacts.editor.PickRawContactLoader.RawContactsMetadata; import com.android.contacts.editor.SplitContactConfirmationDialogFragment; +import com.android.contacts.editornew.ContactEditorActivityNew; import com.android.contacts.logging.EditorEvent; import com.android.contacts.logging.Logger; import com.android.contacts.model.AccountTypeManager; @@ -197,7 +198,7 @@ private void loadEditor() { // If the contact has only read-only raw contacts, we'll want to let the editor create // the writable raw contact for it. intent = EditorIntents.createEditContactIntent(this, mUri, mMaterialPalette, -1); - intent.setClass(this, ContactEditorActivity.class); + intent.setClass(this, ContactEditorActivityNew.class); } startEditorAndForwardExtras(intent); } diff --git a/src/com/android/contacts/editor/ContactEditorFragment.java b/src/com/android/contacts/editor/ContactEditorFragment.java index f13777eb8..98c61673b 100755 --- a/src/com/android/contacts/editor/ContactEditorFragment.java +++ b/src/com/android/contacts/editor/ContactEditorFragment.java @@ -64,6 +64,7 @@ import com.android.contacts.ContactSaveService; import com.android.contacts.GroupMetaDataLoader; import com.android.contacts.R; +import com.android.contacts.editornew.ContactEditorActivityNew; import com.android.contacts.activities.ContactEditorActivity; import com.android.contacts.activities.ContactEditorActivity.ContactEditor; import com.android.contacts.activities.ContactSelectionActivity; @@ -1756,7 +1757,7 @@ protected void joinAggregate(final long contactId) { mContext, mContactIdForJoin, contactId, - ContactEditorActivity.class, + ContactEditorActivityNew.class, ContactEditorActivity.ACTION_JOIN_COMPLETED); mContext.startService(intent); } diff --git a/src/com/android/contacts/editor/EditorIntents.java b/src/com/android/contacts/editor/EditorIntents.java index 91648d008..c4bebba33 100644 --- a/src/com/android/contacts/editor/EditorIntents.java +++ b/src/com/android/contacts/editor/EditorIntents.java @@ -25,6 +25,7 @@ import com.android.contacts.activities.ContactEditorActivity; import com.android.contacts.activities.ContactEditorSpringBoardActivity; +import com.android.contacts.editornew.ContactEditorActivityNew; import com.android.contacts.model.RawContactDeltaList; import com.android.contacts.util.MaterialColorMapUtils.MaterialPalette; @@ -74,14 +75,14 @@ public static Intent createEditContactIntentForRawContact(Context context, } /** - * Returns an Intent to start the {@link ContactEditorActivity} for a new contact with + * Returns an Intent to start the {@link ContactEditorActivityNew} for a new contact with * the field values specified by rawContactDeltaList pre-populate in the form. */ public static Intent createInsertContactIntent(Context context, RawContactDeltaList rawContactDeltaList, String displayName, String phoneticName, /* Bundle updatedPhotos, */ boolean isNewLocalProfile) { final Intent intent = new Intent(Intent.ACTION_INSERT, Contacts.CONTENT_URI, - context, ContactEditorActivity.class); + context, ContactEditorActivityNew.class); intent.putExtra( ContactEditorFragment.INTENT_EXTRA_NEW_LOCAL_PROFILE, isNewLocalProfile); putRawContactDeltaValues(intent, rawContactDeltaList, displayName, phoneticName); diff --git a/src/com/android/contacts/editornew/ContactEditor.kt b/src/com/android/contacts/editornew/ContactEditor.kt new file mode 100644 index 000000000..daa801632 --- /dev/null +++ b/src/com/android/contacts/editornew/ContactEditor.kt @@ -0,0 +1,74 @@ +package com.android.contacts.editornew + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.android.contacts.ContactSaveService +import com.android.contacts.activities.ContactEditorActivity +import com.android.contacts.activities.ContactEditorActivity.ContactEditor.SaveMode +import com.android.contacts.editor.ContactEditorFragment.JOIN_CONTACT_ID_EXTRA_KEY +import com.android.contacts.editor.ContactEditorFragment.SAVE_MODE_EXTRA_KEY +import com.android.contacts.editornew.photo.picker.PhotoPicker +import com.android.contacts.editornew.ui.ContactEditorScreen +import com.android.contacts.model.RawContactDeltaList + +@Composable +internal fun ContactEditor( + onNavigateBack: (() -> Unit), + viewModel: ContactEditorViewModel = viewModel(), +) { + val viewState by viewModel.uiState.collectAsStateWithLifecycle() + + val context = LocalContext.current + val activity = LocalActivity.current + + LaunchedEffect(viewModel) { + viewModel.contactEditorEffects.collect { effect -> + when (effect) { + is ContactEditorEffect.Save -> effect.saveContact(context, activity) + } + } + } + + PhotoPicker(viewModel, viewState) + + ContactEditorScreen( + onEvent = viewModel::onEvent, + onBack = onNavigateBack, + uiState = viewState, + ) +} + +private fun ContactEditorEffect.Save.saveContact( + context: Context, + activity: Activity?, +) { + ContactSaveService.startService( + context, + this.toCreateSaveContactIntent(context, activity), + SaveMode.CLOSE, + ) +} + +private fun ContactEditorEffect.Save.toCreateSaveContactIntent( + context: Context, + activity: Activity?, +): Intent = ContactSaveService.createSaveContactIntent( + context, + RawContactDeltaList().apply { add(rawContactDelta) }, + SAVE_MODE_EXTRA_KEY, + SaveMode.CLOSE, + false, + activity?.javaClass, + ContactEditorActivity.ACTION_SAVE_COMPLETED, + updatedPhotos, + JOIN_CONTACT_ID_EXTRA_KEY, + null, +) diff --git a/src/com/android/contacts/editornew/ContactEditorActivityNew.kt b/src/com/android/contacts/editornew/ContactEditorActivityNew.kt new file mode 100644 index 000000000..d1879dae0 --- /dev/null +++ b/src/com/android/contacts/editornew/ContactEditorActivityNew.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.contacts.editornew + +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import com.android.contacts.AppCompatContactsActivity +import com.android.contacts.activities.ContactEditorActivity +import com.android.contacts.ui.core.AppTheme +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class ContactEditorActivityNew : AppCompatContactsActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + enableEdgeToEdge() + + setContent { + AppTheme { + ContactEditor( + onNavigateBack = ::finish, + ) + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + + if (ContactEditorActivity.ACTION_SAVE_COMPLETED == intent.action) { + finish() + return + } + } +} diff --git a/src/com/android/contacts/editornew/ContactEditorEffect.kt b/src/com/android/contacts/editornew/ContactEditorEffect.kt new file mode 100644 index 000000000..07e20ebe7 --- /dev/null +++ b/src/com/android/contacts/editornew/ContactEditorEffect.kt @@ -0,0 +1,11 @@ +package com.android.contacts.editornew + +import android.os.Bundle +import com.android.contacts.model.RawContactDelta + +internal sealed interface ContactEditorEffect { + data class Save( + val updatedPhotos: Bundle, + val rawContactDelta: RawContactDelta, + ) : ContactEditorEffect +} diff --git a/src/com/android/contacts/editornew/ContactEditorEvent.kt b/src/com/android/contacts/editornew/ContactEditorEvent.kt new file mode 100644 index 000000000..ada71bb78 --- /dev/null +++ b/src/com/android/contacts/editornew/ContactEditorEvent.kt @@ -0,0 +1,21 @@ +package com.android.contacts.editornew + +import android.net.Uri + +internal sealed interface ContactEditorEvent { + data object Save : ContactEditorEvent + + sealed interface Photo : ContactEditorEvent { + data object AddOrChangeClick : Photo + + sealed interface Choose : Photo { + data object Dismiss : Choose + data object FromCameraClick : Choose + data object FromGalleryClick : Choose + data class Result(val uri: Uri) : Choose + } + + data object RemoveClick : Photo + data class CropResult(val uri: Uri?) : Photo + } +} diff --git a/src/com/android/contacts/editornew/ContactEditorUiState.kt b/src/com/android/contacts/editornew/ContactEditorUiState.kt new file mode 100644 index 000000000..68b7d2083 --- /dev/null +++ b/src/com/android/contacts/editornew/ContactEditorUiState.kt @@ -0,0 +1,23 @@ +package com.android.contacts.editornew + +import android.net.Uri +import com.android.contacts.editornew.photo.PhotoType + +internal data class ContactEditorUiState( + val photoUiState: PhotoUiState, + val photoSourceDialogUiState: PhotoSourceDialogUiState?, +) { + companion object { + val DEFAULT = ContactEditorUiState( + photoUiState = PhotoUiState.Placeholder, + photoSourceDialogUiState = null, + ) + } + + sealed interface PhotoUiState { + data class Photo(val uri: Uri) : PhotoUiState + data object Placeholder : PhotoUiState + } + + data class PhotoSourceDialogUiState(val type: PhotoType) +} diff --git a/src/com/android/contacts/editornew/ContactEditorViewModel.kt b/src/com/android/contacts/editornew/ContactEditorViewModel.kt new file mode 100644 index 000000000..488e66455 --- /dev/null +++ b/src/com/android/contacts/editornew/ContactEditorViewModel.kt @@ -0,0 +1,109 @@ +package com.android.contacts.editornew + +import android.content.Context +import android.os.Bundle +import android.provider.ContactsContract.CommonDataKinds.Photo +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.contacts.editor.EditorUiUtils +import com.android.contacts.editornew.contact.ContactDelegate +import com.android.contacts.editornew.contact.ContactState +import com.android.contacts.editornew.photo.PhotoType +import com.android.contacts.editornew.photo.picker.PhotoDelegate +import com.android.contacts.editornew.photo.picker.PhotoPickerState +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +internal class ContactEditorViewModel +@Inject constructor( + @param:ApplicationContext + private val context: Context, + private val photoDelegate: PhotoDelegate, + private val contactDelegate: ContactDelegate, +) : ViewModel(), PhotoDelegate by photoDelegate { + + private val _effects = MutableSharedFlow(extraBufferCapacity = 1) + val contactEditorEffects: Flow = _effects.asSharedFlow() + + val uiState: StateFlow = photoDelegate.state.map { photoState -> + val photoSourceDialogUiState = if (photoState.showPhotoActionChooserDialog) { + ContactEditorUiState.PhotoSourceDialogUiState( + type = if (photoState.photoUri != null) PhotoType.Replace else PhotoType.New, + ) + } else { + null + } + + ContactEditorUiState( + photoUiState = photoState.toUiState(), + photoSourceDialogUiState = photoSourceDialogUiState, + ) + } + .onStart { contactDelegate.init() } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(0), + initialValue = ContactEditorUiState.DEFAULT, + ) + + fun onEvent(event: ContactEditorEvent) { + when (event) { + ContactEditorEvent.Save -> save() + is ContactEditorEvent.Photo -> photoDelegate.onEvent(viewModelScope, event) + } + } + + /** + * Save new contact; Right now this is very simplified, only the picture will be saved. + */ + private fun save() { + val photoUri = photoDelegate.state.value.photoUri ?: return + val contactState = contactDelegate.state.value + + when (contactState) { + ContactState.Loading -> return + is ContactState.Data -> Unit + } + + val rawContactDelta = contactState.rawContactDelta + + // Save thumbnail, this simplifies code, because we don't have to test whether + // there is a change in either the delta-list or a changed photo, this way, + // there is always a change in the delta-list. + rawContactDelta + .getSuperPrimaryEntry(Photo.CONTENT_ITEM_TYPE) + .photo = EditorUiUtils.getCompressedThumbnailBitmapBytes(context, photoUri) + + val updatedPhotos = Bundle().apply { + putParcelable(rawContactDelta.rawContactId.toString(), photoUri) + } + + emitEffect( + ContactEditorEffect.Save( + rawContactDelta = rawContactDelta, + updatedPhotos = updatedPhotos, + ), + ) + } + + private fun emitEffect(effect: ContactEditorEffect) { + viewModelScope.launch { + _effects.emit(effect) + } + } + + private fun PhotoPickerState.toUiState() = photoUri + ?.let(ContactEditorUiState.PhotoUiState::Photo) + ?: ContactEditorUiState.PhotoUiState.Placeholder +} diff --git a/src/com/android/contacts/editornew/contact/ContactDelegate.kt b/src/com/android/contacts/editornew/contact/ContactDelegate.kt new file mode 100644 index 000000000..beeaebed0 --- /dev/null +++ b/src/com/android/contacts/editornew/contact/ContactDelegate.kt @@ -0,0 +1,86 @@ +package com.android.contacts.editornew.contact + +import android.content.Context +import android.provider.ContactsContract.CommonDataKinds.Email +import android.provider.ContactsContract.CommonDataKinds.Event +import android.provider.ContactsContract.CommonDataKinds.Organization +import android.provider.ContactsContract.CommonDataKinds.Phone +import android.provider.ContactsContract.CommonDataKinds.Photo +import android.provider.ContactsContract.CommonDataKinds.StructuredName +import com.android.contacts.editor.ContactEditorUtils +import com.android.contacts.model.AccountTypeManager +import com.android.contacts.model.RawContact +import com.android.contacts.model.RawContactDelta +import com.android.contacts.model.RawContactModifier +import com.android.contacts.model.ValuesDelta +import com.android.contacts.model.account.AccountInfo +import com.android.contacts.model.account.AccountType +import com.android.contacts.model.account.AccountWithDataSet +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.guava.await +import javax.inject.Inject + +internal interface ContactDelegate { + val state: StateFlow + suspend fun init() +} + +internal class ContactDelegateImpl +@Inject constructor( + @param:ApplicationContext + private val context: Context, + private val editorUtils: ContactEditorUtils, + private val accountTypeManager: AccountTypeManager, +) : ContactDelegate { + + private val _state = MutableStateFlow(ContactState.Loading) + override val state: StateFlow = _state.asStateFlow() + + override suspend fun init() { + val accountFilter = AccountTypeManager.insertableFilter(context) + + val accounts = accountTypeManager + .filterAccountsAsync(accountFilter) + .await() + + val accountsWithDataSet = AccountInfo.extractAccounts(accounts) + // TODO: Correctly handle account selection; Prompt for account creation if none available + val defaultAccount = accountsWithDataSet + .let(editorUtils::getOnlyOrDefaultAccount) + ?: accountsWithDataSet.first() + + val defaultAccountType = accountTypeManager.getAccountTypeForAccount(defaultAccount) + + _state.value = ContactState.Data( + accounts = accounts, + rawContactDelta = createNewRawContactDelta(defaultAccount, defaultAccountType), + ) + } + + private fun createNewRawContactDelta( + account: AccountWithDataSet, + accountType: AccountType, + ): RawContactDelta { + val rawContact = RawContact() + .apply { setAccount(account) } + + return RawContactDelta(ValuesDelta.fromAfter(rawContact.values)) + .apply { ensureDefaultMimeTypes(accountType) } + } + + private fun RawContactDelta.ensureDefaultMimeTypes(accountType: AccountType) { + listOf( + Photo.CONTENT_ITEM_TYPE, + StructuredName.CONTENT_ITEM_TYPE, + Phone.CONTENT_ITEM_TYPE, + Email.CONTENT_ITEM_TYPE, + Organization.CONTENT_ITEM_TYPE, + Event.CONTENT_ITEM_TYPE, + ).forEach { mimeType -> + RawContactModifier.ensureKindExists(this, accountType, mimeType) + } + } +} diff --git a/src/com/android/contacts/editornew/contact/ContactState.kt b/src/com/android/contacts/editornew/contact/ContactState.kt new file mode 100644 index 000000000..9aefebc74 --- /dev/null +++ b/src/com/android/contacts/editornew/contact/ContactState.kt @@ -0,0 +1,12 @@ +package com.android.contacts.editornew.contact + +import com.android.contacts.model.RawContactDelta +import com.android.contacts.model.account.AccountInfo + +internal sealed interface ContactState { + data object Loading : ContactState + data class Data( + val accounts: List, + val rawContactDelta: RawContactDelta, + ) : ContactState +} diff --git a/src/com/android/contacts/editornew/di/ContactEditorBindsModule.kt b/src/com/android/contacts/editornew/di/ContactEditorBindsModule.kt new file mode 100644 index 000000000..cfaec98d3 --- /dev/null +++ b/src/com/android/contacts/editornew/di/ContactEditorBindsModule.kt @@ -0,0 +1,25 @@ +package com.android.contacts.editornew.di + +import com.android.contacts.editornew.contact.ContactDelegate +import com.android.contacts.editornew.contact.ContactDelegateImpl +import com.android.contacts.editornew.photo.picker.PhotoDelegate +import com.android.contacts.editornew.photo.picker.PhotoDelegateImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent + +@Module +@InstallIn(ViewModelComponent::class) +internal abstract class ContactEditorBindsModule { + + @Binds + abstract fun bindPhotoDelegate( + impl: PhotoDelegateImpl, + ): PhotoDelegate + + @Binds + abstract fun bindContactDelegate( + impl: ContactDelegateImpl, + ): ContactDelegate +} diff --git a/src/com/android/contacts/editornew/di/ContactEditorModule.kt b/src/com/android/contacts/editornew/di/ContactEditorModule.kt new file mode 100644 index 000000000..cb67c89e5 --- /dev/null +++ b/src/com/android/contacts/editornew/di/ContactEditorModule.kt @@ -0,0 +1,34 @@ +package com.android.contacts.editornew.di + +import android.content.Context +import com.android.contacts.editor.ContactEditorUtils +import com.android.contacts.editornew.photo.picker.PhotoDelegateHelper +import com.android.contacts.model.AccountTypeManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.android.qualifiers.ApplicationContext + +@Module +@InstallIn(ViewModelComponent::class) +internal object ContactEditorModule { + + @Provides + fun provideAccountTypeManager( + @ApplicationContext + context: Context, + ): AccountTypeManager = AccountTypeManager.getInstance(context) + + @Provides + fun provideContactEditorUtils( + @ApplicationContext + context: Context, + ): ContactEditorUtils = ContactEditorUtils.create(context) + + @Provides + fun providePhotoDelegateHelper( + @ApplicationContext + context: Context, + ): PhotoDelegateHelper = PhotoDelegateHelper(context) +} diff --git a/src/com/android/contacts/editornew/photo/ContactEditorPhoto.kt b/src/com/android/contacts/editornew/photo/ContactEditorPhoto.kt new file mode 100644 index 000000000..833f8415b --- /dev/null +++ b/src/com/android/contacts/editornew/photo/ContactEditorPhoto.kt @@ -0,0 +1,203 @@ +package com.android.contacts.editornew.photo + +import android.graphics.BitmapFactory +import android.net.Uri +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddPhotoAlternate +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import com.android.contacts.R +import com.android.contacts.editornew.ContactEditorUiState +import com.android.contacts.ui.core.AppTheme +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +internal fun ContactEditorPhoto( + viewState: ContactEditorUiState.PhotoUiState, + onAddOrChangeClick: () -> Unit, + onRemoveClick: () -> Unit, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AnimatedContent( + modifier = Modifier + .size(128.dp) + .clip(CircleShape), + targetState = viewState, + ) { targetViewState -> + when (targetViewState) { + is ContactEditorUiState.PhotoUiState.Photo -> { + Photo( + uri = targetViewState.uri, + onClick = onAddOrChangeClick, + ) + } + ContactEditorUiState.PhotoUiState.Placeholder -> { + Placeholder( + onClick = onAddOrChangeClick, + ) + } + } + } + + AnimatedContent( + targetState = viewState, + ) { targetViewState -> + when (targetViewState) { + is ContactEditorUiState.PhotoUiState.Photo -> { + Row { + ActionButton( + onClick = onAddOrChangeClick, + icon = Icons.Default.Edit, + text = stringResource(R.string.contact_editor_photo_change), + ) + + ActionButton( + onClick = onRemoveClick, + icon = Icons.Default.Delete, + text = stringResource(R.string.contact_editor_photo_remove), + ) + } + } + ContactEditorUiState.PhotoUiState.Placeholder -> { + ActionButton( + onClick = onAddOrChangeClick, + icon = null, + text = stringResource(R.string.contact_editor_photo_add), + ) + } + } + } + } +} + +@Composable +private fun Placeholder( + onClick: () -> Unit, +) { + IconButton( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.inverseOnSurface) + .testTag("contact_editor_photo_placeholder"), + onClick = onClick, + ) { + Icon( + modifier = Modifier.size(56.dp), + imageVector = Icons.Default.AddPhotoAlternate, + contentDescription = stringResource(R.string.editor_add_photo_content_description), + ) + } +} + +@Composable +private fun ActionButton( + onClick: () -> Unit, + icon: ImageVector?, + text: String, +) { + TextButton(onClick = onClick) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, // Decorative purpose. + ) + + Spacer(modifier = Modifier.width(4.dp)) + } + + Text(text = text) + } +} + +@Composable +private fun Photo( + onClick: () -> Unit, + uri: Uri, +) { + val context = LocalContext.current + var imageBitmap by remember { mutableStateOf(null) } + + LaunchedEffect(uri) { + imageBitmap = withContext(Dispatchers.IO) { + context.contentResolver.openInputStream(uri).use { inputStream -> + BitmapFactory + .decodeStream(inputStream) + ?.asImageBitmap() + } + } + } + + imageBitmap?.let { + Image( + bitmap = it, + contentDescription = stringResource(R.string.editor_contact_photo_content_description), + modifier = Modifier + .fillMaxSize() + .clickable(onClick = onClick), + contentScale = ContentScale.Crop, + ) + } +} + +@Preview +@Composable +internal fun ContactEditorPhotoPreview( + @PreviewParameter(ContactEditorPhotoPreviewProvider::class) + viewState: ContactEditorUiState.PhotoUiState, +) { + AppTheme { + ContactEditorPhoto( + viewState = viewState, + onAddOrChangeClick = {}, + onRemoveClick = {}, + ) + } +} + +internal class ContactEditorPhotoPreviewProvider : + PreviewParameterProvider { + override val values: Sequence = sequenceOf( + ContactEditorUiState.PhotoUiState.Photo(uri = "test".toUri()), + ContactEditorUiState.PhotoUiState.Placeholder, + ) +} diff --git a/src/com/android/contacts/editornew/photo/PhotoType.kt b/src/com/android/contacts/editornew/photo/PhotoType.kt new file mode 100644 index 000000000..94e26d808 --- /dev/null +++ b/src/com/android/contacts/editornew/photo/PhotoType.kt @@ -0,0 +1,6 @@ +package com.android.contacts.editornew.photo + +internal enum class PhotoType { + New, + Replace, +} diff --git a/src/com/android/contacts/editornew/photo/picker/PhotoDelegate.kt b/src/com/android/contacts/editornew/photo/picker/PhotoDelegate.kt new file mode 100644 index 000000000..d73d7ebed --- /dev/null +++ b/src/com/android/contacts/editornew/photo/picker/PhotoDelegate.kt @@ -0,0 +1,123 @@ +package com.android.contacts.editornew.photo.picker + +import android.content.Intent +import android.net.Uri +import com.android.contacts.editornew.ContactEditorEvent +import com.android.contacts.util.ContactPhotoUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +internal interface PhotoDelegate { + val photoEffects: Flow + val state: StateFlow + fun onEvent(scope: CoroutineScope, event: ContactEditorEvent.Photo) +} + +internal class PhotoDelegateImpl +@Inject constructor( + private val helper: PhotoDelegateHelper, +) : PhotoDelegate { + + private val _effects = MutableSharedFlow(extraBufferCapacity = 1) + override val photoEffects: Flow = _effects.asSharedFlow() + + private val _state = MutableStateFlow(PhotoPickerState.DEFAULT) + override val state: StateFlow = _state.asStateFlow() + + private val photoPickDimension = lazy { + helper.photoPickDimension() + } + + private val tmpPhotoUri = helper.tmpPhotoUri() + + override fun onEvent(scope: CoroutineScope, event: ContactEditorEvent.Photo) { + when (event) { + ContactEditorEvent.Photo.RemoveClick -> setPhotoUri(uri = null) + ContactEditorEvent.Photo.AddOrChangeClick -> { + setShowPhotoSourceChooserDialog(show = true) + } + is ContactEditorEvent.Photo.Choose -> { + setShowPhotoSourceChooserDialog(show = false) + + when (event) { + ContactEditorEvent.Photo.Choose.Dismiss -> Unit + ContactEditorEvent.Photo.Choose.FromCameraClick -> Unit // TODO + ContactEditorEvent.Photo.Choose.FromGalleryClick -> { + emitEffect(scope, PhotoEffect.OpenPhotoPicker) + } + is ContactEditorEvent.Photo.Choose.Result -> { + handlePhotoResult(scope = scope, uri = event.uri) + } + } + } + + is ContactEditorEvent.Photo.CropResult -> handleCropResult(uri = event.uri) + } + } + + private fun handleCropResult(uri: Uri?) { + helper.deleteTemporaryPhoto(tmpPhotoUri) + if (uri != null) { + setPhotoUri(uri = uri) + } + } + + private fun handlePhotoResult(scope: CoroutineScope, uri: Uri) { + val inputUri = helper.uriToWriteableTmpImageUri(uri) ?: return + + val cropIntent = cropImageIntent( + inputUri = inputUri, + outputUri = helper.tmpCroppedPhotoUri(), + ) + + val intentHandler = helper.intentHandlerOrNull(cropIntent) + if (intentHandler == null) { + setPhotoUri(uri = inputUri) + } else { + val effect = cropIntent + .apply { setPackage(intentHandler.activityInfo.packageName) } + .let(PhotoEffect::CropPhoto) + + emitEffect(scope, effect) + } + } + + private fun emitEffect(scope: CoroutineScope, effect: PhotoEffect) { + scope.launch { + _effects.emit(effect) + } + } + + private fun setShowPhotoSourceChooserDialog(show: Boolean) { + _state.update { state -> + state.copy( + showPhotoActionChooserDialog = show, + ) + } + } + + private fun setPhotoUri(uri: Uri?) { + _state.update { state -> + state.copy( + photoUri = uri, + ) + } + } + + private fun cropImageIntent(inputUri: Uri, outputUri: Uri): Intent { + return Intent("com.android.camera.action.CROP") + .apply { setDataAndType(inputUri, "image/*") } + .also { intent -> + ContactPhotoUtils.addPhotoPickerExtras(intent, outputUri) + ContactPhotoUtils.addCropExtras(intent, photoPickDimension.value) + } + } +} diff --git a/src/com/android/contacts/editornew/photo/picker/PhotoDelegateHelper.kt b/src/com/android/contacts/editornew/photo/picker/PhotoDelegateHelper.kt new file mode 100644 index 000000000..25580b55f --- /dev/null +++ b/src/com/android/contacts/editornew/photo/picker/PhotoDelegateHelper.kt @@ -0,0 +1,68 @@ +package com.android.contacts.editornew.photo.picker + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import android.net.Uri +import android.provider.ContactsContract +import com.android.contacts.util.ContactPhotoUtils +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +private const val DEFAULT_PHOTO_DIMENSION = 720 + +internal class PhotoDelegateHelper +@Inject constructor( + @param:ApplicationContext + private val context: Context, +) { + fun tmpPhotoUri(): Uri = ContactPhotoUtils.generateTempImageUri(context) + + fun tmpCroppedPhotoUri(): Uri = ContactPhotoUtils.generateTempCroppedImageUri(context) + + fun deleteTemporaryPhoto(uri: Uri) { + context.contentResolver.delete(uri, null, null) + } + + fun intentHandlerOrNull(intent: Intent): ResolveInfo? { + return context.packageManager.queryIntentActivities( + intent, + PackageManager.MATCH_DEFAULT_ONLY or PackageManager.MATCH_SYSTEM_ONLY, + ) + .takeIf { it.isNotEmpty() } + ?.get(0) + } + + fun uriToWriteableTmpImageUri(uri: Uri): Uri? { + val tmpUri = tmpPhotoUri() + return try { + val success = ContactPhotoUtils.savePhotoFromUriToUri(context, uri, tmpUri, false) + if (success) tmpUri else null + } catch (_: SecurityException) { + null + } + } + + fun photoPickDimension(): Int { + return queryPhotoDimension() + ?.takeIf { it != 0 } + ?: DEFAULT_PHOTO_DIMENSION + } + + private fun queryPhotoDimension(): Int? { + return context.contentResolver.query( + ContactsContract.DisplayPhoto.CONTENT_MAX_DIMENSIONS_URI, + arrayOf(ContactsContract.DisplayPhoto.DISPLAY_MAX_DIM), + null, + null, + null, + )?.use { cursor -> + if (cursor.moveToFirst()) { + cursor.getInt(0) + } else { + null + } + } + } +} diff --git a/src/com/android/contacts/editornew/photo/picker/PhotoEffect.kt b/src/com/android/contacts/editornew/photo/picker/PhotoEffect.kt new file mode 100644 index 000000000..5dc10bb54 --- /dev/null +++ b/src/com/android/contacts/editornew/photo/picker/PhotoEffect.kt @@ -0,0 +1,9 @@ +package com.android.contacts.editornew.photo.picker + +import android.content.Intent + +internal sealed interface PhotoEffect { + data object OpenPhotoPicker : PhotoEffect + data object OpenCamera : PhotoEffect + data class CropPhoto(val intent: Intent) : PhotoEffect +} diff --git a/src/com/android/contacts/editornew/photo/picker/PhotoPicker.kt b/src/com/android/contacts/editornew/photo/picker/PhotoPicker.kt new file mode 100644 index 000000000..cdf071ebd --- /dev/null +++ b/src/com/android/contacts/editornew/photo/picker/PhotoPicker.kt @@ -0,0 +1,64 @@ +package com.android.contacts.editornew.photo.picker + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import com.android.contacts.editornew.ContactEditorEvent.Photo +import com.android.contacts.editornew.ContactEditorUiState +import com.android.contacts.editornew.ContactEditorViewModel + +@Composable +internal fun PhotoPicker( + viewModel: ContactEditorViewModel, + viewState: ContactEditorUiState, +) { + val pickMediaLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.PickVisualMedia(), + ) { uri -> + uri?.let(Photo.Choose::Result) + ?.run(viewModel::onEvent) + } + + val cropPhotoLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + ) { result -> + viewModel.onEvent( + Photo.CropResult( + uri = result.data?.data, + ), + ) + } + + LaunchedEffect(viewModel.photoEffects) { + viewModel.photoEffects.collect { effect -> + when (effect) { + PhotoEffect.OpenCamera -> { + // TODO + } + + PhotoEffect.OpenPhotoPicker -> { + pickMediaLauncher.launch( + PickVisualMediaRequest( + ActivityResultContracts.PickVisualMedia.ImageOnly, + ), + ) + } + + is PhotoEffect.CropPhoto -> { + cropPhotoLauncher.launch(effect.intent) + } + } + } + } + + viewState.photoSourceDialogUiState?.let { photoSourceDialog -> + PhotoSourceChooserDialog( + type = photoSourceDialog.type, + onDismiss = { viewModel.onEvent(Photo.Choose.Dismiss) }, + onCameraClick = { viewModel.onEvent(Photo.Choose.FromCameraClick) }, + onGalleryClick = { viewModel.onEvent(Photo.Choose.FromGalleryClick) }, + ) + } +} diff --git a/src/com/android/contacts/editornew/photo/picker/PhotoPickerState.kt b/src/com/android/contacts/editornew/photo/picker/PhotoPickerState.kt new file mode 100644 index 000000000..330864d12 --- /dev/null +++ b/src/com/android/contacts/editornew/photo/picker/PhotoPickerState.kt @@ -0,0 +1,15 @@ +package com.android.contacts.editornew.photo.picker + +import android.net.Uri + +internal data class PhotoPickerState( + val photoUri: Uri?, + val showPhotoActionChooserDialog: Boolean, +) { + companion object { + val DEFAULT = PhotoPickerState( + photoUri = null, + showPhotoActionChooserDialog = false, + ) + } +} diff --git a/src/com/android/contacts/editornew/photo/picker/PhotoSourceChooserDialog.kt b/src/com/android/contacts/editornew/photo/picker/PhotoSourceChooserDialog.kt new file mode 100644 index 000000000..d2b784776 --- /dev/null +++ b/src/com/android/contacts/editornew/photo/picker/PhotoSourceChooserDialog.kt @@ -0,0 +1,129 @@ +package com.android.contacts.editornew.photo.picker + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.android.contacts.R +import com.android.contacts.editornew.photo.PhotoType +import com.android.contacts.ui.core.AppTheme + +@Composable +internal fun PhotoSourceChooserDialog( + type: PhotoType, + onDismiss: () -> Unit, + onCameraClick: () -> Unit, + onGalleryClick: () -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + ) { + PhotoSourceChooserDialogContent( + type = type, + onCameraClick = onCameraClick, + onGalleryClick = onGalleryClick, + ) + } +} + +@Composable +private fun PhotoSourceChooserDialogContent( + type: PhotoType, + onCameraClick: () -> Unit, + onGalleryClick: () -> Unit, +) { + Card( + modifier = Modifier + .testTag("contact_editor_photo_source_chooser_dialog_content") + .fillMaxWidth(), + shape = MaterialTheme.shapes.extraSmall, + ) { + Column(modifier = Modifier.padding(16.dp)) { + Spacer(modifier = Modifier.height(16.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.headlineMedium, + text = stringResource(R.string.menu_change_photo), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + SourceItem( + text = stringResource(type.cameraStringRes), + onClick = onCameraClick, + ) + + SourceItem( + text = stringResource(type.galleryStringRes), + onClick = onGalleryClick, + ) + } + } +} + +@Composable +private fun SourceItem( + text: String, + onClick: () -> Unit, +) { + TextButton( + modifier = Modifier.fillMaxWidth(), + onClick = onClick, + ) { + Text( + text = text, + ) + } +} + +private val PhotoType.cameraStringRes + @StringRes + get() = when (this) { + PhotoType.New -> R.string.take_photo + PhotoType.Replace -> R.string.take_new_photo + } + +private val PhotoType.galleryStringRes + @StringRes + get() = when (this) { + PhotoType.New -> R.string.pick_photo + PhotoType.Replace -> R.string.pick_new_photo + } + +@Preview +@Composable +private fun PhotoSourceDialogContentPreview( + @PreviewParameter(PhotoTypePreviewProvider::class) + type: PhotoType, +) { + AppTheme { + PhotoSourceChooserDialogContent( + type = type, + onCameraClick = {}, + onGalleryClick = {}, + ) + } +} + +private class PhotoTypePreviewProvider : PreviewParameterProvider { + override val values: Sequence = + PhotoType.entries.asSequence() +} diff --git a/src/com/android/contacts/editornew/ui/ContactEditorScreen.kt b/src/com/android/contacts/editornew/ui/ContactEditorScreen.kt new file mode 100644 index 000000000..f0826d287 --- /dev/null +++ b/src/com/android/contacts/editornew/ui/ContactEditorScreen.kt @@ -0,0 +1,66 @@ +package com.android.contacts.editornew.ui + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.android.contacts.R +import com.android.contacts.editornew.ContactEditorEvent +import com.android.contacts.editornew.ContactEditorUiState +import com.android.contacts.editornew.photo.ContactEditorPhoto +import com.android.contacts.ui.core.AppTheme + +@Composable +internal fun ContactEditorScreen( + onEvent: (ContactEditorEvent) -> Unit, + onBack: () -> Unit, + uiState: ContactEditorUiState, +) { + Scaffold( + topBar = { + ContactEditorTopAppBar( + title = stringResource(R.string.contact_editor_create_contact), + onNavigateBack = onBack, + onSave = { onEvent(ContactEditorEvent.Save) }, + ) + }, + ) { contentPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(top = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + contentPadding = contentPadding, + ) { + item(key = "contact_editor_photo") { + ContactEditorPhoto( + viewState = uiState.photoUiState, + onAddOrChangeClick = { + onEvent(ContactEditorEvent.Photo.AddOrChangeClick) + }, + onRemoveClick = { + onEvent(ContactEditorEvent.Photo.RemoveClick) + }, + ) + } + } + } +} + +@Preview +@Composable +private fun ContactEditorScreenPreview() { + AppTheme { + ContactEditorScreen( + uiState = ContactEditorUiState.DEFAULT, + onEvent = {}, + onBack = {}, + ) + } +} diff --git a/src/com/android/contacts/editornew/ui/ContactEditorTopAppBar.kt b/src/com/android/contacts/editornew/ui/ContactEditorTopAppBar.kt new file mode 100644 index 000000000..b626e5712 --- /dev/null +++ b/src/com/android/contacts/editornew/ui/ContactEditorTopAppBar.kt @@ -0,0 +1,52 @@ +package com.android.contacts.editornew.ui + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.android.contacts.R + +@Composable +internal fun ContactEditorTopAppBar( + title: String, + onNavigateBack: () -> Unit, + onSave: () -> Unit, +) { + TopAppBar( + title = { + Text( + text = title, + ) + }, + navigationIcon = { + IconButton( + onClick = onNavigateBack, + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cancel_button_content_description), + ) + } + }, + actions = { + Button( + onClick = onSave, + ) { + Text( + text = stringResource(R.string.menu_save), + ) + } + + Spacer(modifier = Modifier.width(4.dp)) + }, + ) +} From 3a3f550ad1c37e73359371db0bbf7e37815d9ad4 Mon Sep 17 00:00:00 2001 From: Mario Noll <8122102+MarioNoll@users.noreply.github.com> Date: Sat, 16 May 2026 14:11:40 +0200 Subject: [PATCH 3/3] Apply ktlint formatting --- app/build.gradle.kts | 1 - .../android/contacts/editor/ContactEditorRobot.kt | 3 ++- .../com/android/contacts/util/MainDispatcherRule.kt | 5 ++--- .../contacts/editornew/ContactEditorEffect.kt | 6 ++---- .../contacts/editornew/ContactEditorViewModel.kt | 12 +++++++----- .../contacts/editornew/contact/ContactDelegate.kt | 4 ++-- .../contacts/editornew/contact/ContactState.kt | 6 ++---- .../editornew/photo/picker/PhotoDelegate.kt | 13 ++++++------- 8 files changed, 23 insertions(+), 27 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0dfd35c9f..ae83a77d6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -136,7 +136,6 @@ dependencies { androidTestImplementation(libs.turbine) } - tasks.withType { compilerOptions { freeCompilerArgs.addAll( diff --git a/app/src/androidTest/kotlin/com/android/contacts/editor/ContactEditorRobot.kt b/app/src/androidTest/kotlin/com/android/contacts/editor/ContactEditorRobot.kt index a23031239..ee26118c1 100644 --- a/app/src/androidTest/kotlin/com/android/contacts/editor/ContactEditorRobot.kt +++ b/app/src/androidTest/kotlin/com/android/contacts/editor/ContactEditorRobot.kt @@ -15,7 +15,8 @@ import com.android.contacts.editornew.ContactEditor import com.android.contacts.ui.core.AppTheme internal class ContactEditorRobot( - private val composeTestRule: AndroidComposeTestRule, HiltTestActivity>, + private val composeTestRule: + AndroidComposeTestRule, HiltTestActivity>, ) { init { composeTestRule.setContent { diff --git a/app/src/test/kotlin/com/android/contacts/util/MainDispatcherRule.kt b/app/src/test/kotlin/com/android/contacts/util/MainDispatcherRule.kt index 2b274ad70..4733388d0 100644 --- a/app/src/test/kotlin/com/android/contacts/util/MainDispatcherRule.kt +++ b/app/src/test/kotlin/com/android/contacts/util/MainDispatcherRule.kt @@ -8,9 +8,8 @@ import kotlinx.coroutines.test.setMain import org.junit.rules.TestWatcher import org.junit.runner.Description -internal class MainDispatcherRule( - val testDispatcher: TestDispatcher = StandardTestDispatcher(), -) : TestWatcher() { +internal class MainDispatcherRule(val testDispatcher: TestDispatcher = StandardTestDispatcher()) : + TestWatcher() { override fun starting(description: Description) { Dispatchers.setMain(testDispatcher) diff --git a/src/com/android/contacts/editornew/ContactEditorEffect.kt b/src/com/android/contacts/editornew/ContactEditorEffect.kt index 07e20ebe7..e30e1e5ac 100644 --- a/src/com/android/contacts/editornew/ContactEditorEffect.kt +++ b/src/com/android/contacts/editornew/ContactEditorEffect.kt @@ -4,8 +4,6 @@ import android.os.Bundle import com.android.contacts.model.RawContactDelta internal sealed interface ContactEditorEffect { - data class Save( - val updatedPhotos: Bundle, - val rawContactDelta: RawContactDelta, - ) : ContactEditorEffect + data class Save(val updatedPhotos: Bundle, val rawContactDelta: RawContactDelta) : + ContactEditorEffect } diff --git a/src/com/android/contacts/editornew/ContactEditorViewModel.kt b/src/com/android/contacts/editornew/ContactEditorViewModel.kt index 488e66455..19bc89ced 100644 --- a/src/com/android/contacts/editornew/ContactEditorViewModel.kt +++ b/src/com/android/contacts/editornew/ContactEditorViewModel.kt @@ -13,6 +13,7 @@ import com.android.contacts.editornew.photo.picker.PhotoDelegate import com.android.contacts.editornew.photo.picker.PhotoPickerState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted @@ -22,7 +23,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel internal class ContactEditorViewModel @@ -31,10 +31,12 @@ internal class ContactEditorViewModel private val context: Context, private val photoDelegate: PhotoDelegate, private val contactDelegate: ContactDelegate, -) : ViewModel(), PhotoDelegate by photoDelegate { +) : ViewModel(), + PhotoDelegate by photoDelegate { - private val _effects = MutableSharedFlow(extraBufferCapacity = 1) - val contactEditorEffects: Flow = _effects.asSharedFlow() + private val _contactEditorEffects = + MutableSharedFlow(extraBufferCapacity = 1) + val contactEditorEffects: Flow = _contactEditorEffects.asSharedFlow() val uiState: StateFlow = photoDelegate.state.map { photoState -> val photoSourceDialogUiState = if (photoState.showPhotoActionChooserDialog) { @@ -99,7 +101,7 @@ internal class ContactEditorViewModel private fun emitEffect(effect: ContactEditorEffect) { viewModelScope.launch { - _effects.emit(effect) + _contactEditorEffects.emit(effect) } } diff --git a/src/com/android/contacts/editornew/contact/ContactDelegate.kt b/src/com/android/contacts/editornew/contact/ContactDelegate.kt index beeaebed0..e563163cc 100644 --- a/src/com/android/contacts/editornew/contact/ContactDelegate.kt +++ b/src/com/android/contacts/editornew/contact/ContactDelegate.kt @@ -17,11 +17,11 @@ import com.android.contacts.model.account.AccountInfo import com.android.contacts.model.account.AccountType import com.android.contacts.model.account.AccountWithDataSet import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.guava.await -import javax.inject.Inject internal interface ContactDelegate { val state: StateFlow @@ -47,7 +47,7 @@ internal class ContactDelegateImpl .await() val accountsWithDataSet = AccountInfo.extractAccounts(accounts) - // TODO: Correctly handle account selection; Prompt for account creation if none available + // TODO Correctly handle account selection; Prompt for account creation if none available val defaultAccount = accountsWithDataSet .let(editorUtils::getOnlyOrDefaultAccount) ?: accountsWithDataSet.first() diff --git a/src/com/android/contacts/editornew/contact/ContactState.kt b/src/com/android/contacts/editornew/contact/ContactState.kt index 9aefebc74..0f55a378c 100644 --- a/src/com/android/contacts/editornew/contact/ContactState.kt +++ b/src/com/android/contacts/editornew/contact/ContactState.kt @@ -5,8 +5,6 @@ import com.android.contacts.model.account.AccountInfo internal sealed interface ContactState { data object Loading : ContactState - data class Data( - val accounts: List, - val rawContactDelta: RawContactDelta, - ) : ContactState + data class Data(val accounts: List, val rawContactDelta: RawContactDelta) : + ContactState } diff --git a/src/com/android/contacts/editornew/photo/picker/PhotoDelegate.kt b/src/com/android/contacts/editornew/photo/picker/PhotoDelegate.kt index d73d7ebed..2a957f9c4 100644 --- a/src/com/android/contacts/editornew/photo/picker/PhotoDelegate.kt +++ b/src/com/android/contacts/editornew/photo/picker/PhotoDelegate.kt @@ -4,6 +4,7 @@ import android.content.Intent import android.net.Uri import com.android.contacts.editornew.ContactEditorEvent import com.android.contacts.util.ContactPhotoUtils +import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -13,7 +14,6 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import javax.inject.Inject internal interface PhotoDelegate { val photoEffects: Flow @@ -22,12 +22,11 @@ internal interface PhotoDelegate { } internal class PhotoDelegateImpl -@Inject constructor( - private val helper: PhotoDelegateHelper, -) : PhotoDelegate { +@Inject constructor(private val helper: PhotoDelegateHelper) : + PhotoDelegate { - private val _effects = MutableSharedFlow(extraBufferCapacity = 1) - override val photoEffects: Flow = _effects.asSharedFlow() + private val _photoEffects = MutableSharedFlow(extraBufferCapacity = 1) + override val photoEffects: Flow = _photoEffects.asSharedFlow() private val _state = MutableStateFlow(PhotoPickerState.DEFAULT) override val state: StateFlow = _state.asStateFlow() @@ -92,7 +91,7 @@ internal class PhotoDelegateImpl private fun emitEffect(scope: CoroutineScope, effect: PhotoEffect) { scope.launch { - _effects.emit(effect) + _photoEffects.emit(effect) } }