Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@

<!-- Edit or create a contact with only the most important fields displayed initially. -->
<activity
android:name=".activities.ContactEditorActivity"
android:name=".editornew.ContactEditorActivityNew"
android:exported="true"
android:theme="@style/EditorActivityTheme">

Expand All @@ -408,7 +408,7 @@
<activity-alias
android:name="com.android.contacts.activities.CompactContactEditorActivity"
android:exported="true"
android:targetActivity=".activities.ContactEditorActivity">
android:targetActivity=".editornew.ContactEditorActivityNew">
<intent-filter android:priority="-1">
<action android:name="android.intent.action.INSERT"/>
<category android:name="android.intent.category.DEFAULT"/>
Expand Down
23 changes: 21 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import dev.detekt.gradle.Detekt
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
alias(libs.plugins.android.application)
Expand Down Expand Up @@ -44,16 +45,20 @@ android {
defaultConfig {
minSdk = 36
targetSdk = 36
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunner = "com.android.contacts.di.HiltTestRunner"
}

buildTypes {
getByName("debug") {
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)
}
}
}

Expand Down Expand Up @@ -85,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)

Expand All @@ -103,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)
Expand All @@ -115,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)
Expand All @@ -125,3 +135,12 @@ dependencies {
androidTestImplementation(libs.mockk.android)
androidTestImplementation(libs.turbine)
}

tasks.withType<KotlinCompile> {
compilerOptions {
freeCompilerArgs.addAll(
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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<ActivityScenarioRule<HiltTestActivity>, 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),
)
}
Original file line number Diff line number Diff line change
@@ -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<HiltTestActivity>()

@Test
fun defaultPhotoPlaceHolderIsShown() {
ContactEditorRobot(composeTestRule)
.photoPlaceholderIsShown()
}

@Test
fun clickAddPhotoOpensChooserDialog() {
ContactEditorRobot(composeTestRule)
.clickAddPhoto()
.choosePhotoSourceDialogIsShown()
}

@Test
fun clickPlaceholderOpensChooserDialog() {
ContactEditorRobot(composeTestRule)
.clickPhotoPlaceholder()
.choosePhotoSourceDialogIsShown()
}
}
10 changes: 10 additions & 0 deletions app/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.android.contacts">

<application tools:ignore="MissingApplicationIcon">
<activity
android:name=".di.HiltTestActivity"
android:exported="false" />
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.android.contacts.di

import androidx.activity.ComponentActivity
import dagger.hilt.android.AndroidEntryPoint

@AndroidEntryPoint
class HiltTestActivity : ComponentActivity()
Original file line number Diff line number Diff line change
@@ -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<ContactEditorUiState>.setCropResultAndAssert(
viewModel: ContactEditorViewModel,
) {
viewModel.onEvent(ContactEditorEvent.Photo.CropResult("test".toUri()))
awaitItem().apply {
assertEquals(photoUiState, PhotoUiState.Photo("test".toUri()))
}
}

private suspend fun TurbineTestContext<ContactEditorUiState>.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,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
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()
}
}
Loading