From 6a0bb381a88081af30b7653bc997f1548523e182 Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Thu, 29 Sep 2022 11:21:11 +0300 Subject: [PATCH 1/4] Issue#41 Update Hilt Entry Point access in compose --- .../ComposeEntryPoint.kt} | 31 ++++++++++++++----- .../hilt/compose/screen/AppNavigation.kt | 9 ++++-- .../compose/screen/AppNavigationEntryPoint.kt | 25 --------------- .../compose/screen/auth/AuthEntryPoint.kt | 25 --------------- .../compose/screen/auth/AuthScreenState.kt | 16 +++++++++- .../compose/screen/home/HomeScreenState.kt | 4 ++- 6 files changed, 49 insertions(+), 61 deletions(-) rename hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/{screen/home/HomeEntryPoint.kt => di/ComposeEntryPoint.kt} (52%) delete mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/AppNavigationEntryPoint.kt delete mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/auth/AuthEntryPoint.kt diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/home/HomeEntryPoint.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/di/ComposeEntryPoint.kt similarity index 52% rename from hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/home/HomeEntryPoint.kt rename to hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/di/ComposeEntryPoint.kt index c608e36..67c0be7 100644 --- a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/home/HomeEntryPoint.kt +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/di/ComposeEntryPoint.kt @@ -1,4 +1,4 @@ -package org.fnives.test.showcase.hilt.compose.screen.home +package org.fnives.test.showcase.hilt.compose.di import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -11,13 +11,30 @@ import org.fnives.test.showcase.hilt.core.content.AddContentToFavouriteUseCase import org.fnives.test.showcase.hilt.core.content.FetchContentUseCase import org.fnives.test.showcase.hilt.core.content.GetAllContentUseCase import org.fnives.test.showcase.hilt.core.content.RemoveContentFromFavouritesUseCase +import org.fnives.test.showcase.hilt.core.login.IsUserLoggedInUseCase +import org.fnives.test.showcase.hilt.core.login.LoginUseCase import org.fnives.test.showcase.hilt.core.login.LogoutUseCase -object HomeEntryPoint { +object ComposeEntryPoint { + + /** + * Helper method to easily remember and access Hilt Dependencies in Compose. + */ + @Composable + inline fun rememberEntryPoint(component: Any = LocalContext.current.applicationContext): T = + remember(component) { EntryPoints.get(component, T::class.java) } + + sealed interface EntryPointDependencies @EntryPoint @InstallIn(SingletonComponent::class) - interface MainDependencies { + interface AuthDependencies : EntryPointDependencies { + val loginUseCase: LoginUseCase + } + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface MainDependencies : EntryPointDependencies { val getAllContentUseCase: GetAllContentUseCase val logoutUseCase: LogoutUseCase val fetchContentUseCase: FetchContentUseCase @@ -25,9 +42,9 @@ object HomeEntryPoint { val removeContentFromFavouritesUseCase: RemoveContentFromFavouritesUseCase } - @Composable - fun get(): MainDependencies { - val context = LocalContext.current.applicationContext - return remember { EntryPoints.get(context, MainDependencies::class.java) } + @EntryPoint + @InstallIn(SingletonComponent::class) + interface AppNavigationDependencies : EntryPointDependencies { + val isUserLoggedInUseCase: IsUserLoggedInUseCase } } diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/AppNavigation.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/AppNavigation.kt index a383d08..7d1b2c7 100644 --- a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/AppNavigation.kt +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/AppNavigation.kt @@ -11,6 +11,8 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import kotlinx.coroutines.delay +import org.fnives.test.showcase.hilt.compose.di.ComposeEntryPoint.AppNavigationDependencies +import org.fnives.test.showcase.hilt.compose.di.ComposeEntryPoint.rememberEntryPoint import org.fnives.test.showcase.hilt.compose.screen.auth.AuthScreen import org.fnives.test.showcase.hilt.compose.screen.auth.rememberAuthScreenState import org.fnives.test.showcase.hilt.compose.screen.home.HomeScreen @@ -20,8 +22,11 @@ import org.fnives.test.showcase.hilt.core.login.IsUserLoggedInUseCase @Composable fun AppNavigation( - isUserLogeInUseCase: IsUserLoggedInUseCase = AppNavigationEntryPoint.get().isUserLoggedInUseCase -) { + navigationDependencies: AppNavigationDependencies = rememberEntryPoint() +) = AppNavigation(navigationDependencies.isUserLoggedInUseCase) + +@Composable +fun AppNavigation(isUserLogeInUseCase: IsUserLoggedInUseCase) { val navController = rememberNavController() LaunchedEffect(isUserLogeInUseCase) { diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/AppNavigationEntryPoint.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/AppNavigationEntryPoint.kt deleted file mode 100644 index d04c7a1..0000000 --- a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/AppNavigationEntryPoint.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.fnives.test.showcase.hilt.compose.screen - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext -import dagger.hilt.EntryPoint -import dagger.hilt.EntryPoints -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import org.fnives.test.showcase.hilt.core.login.IsUserLoggedInUseCase - -object AppNavigationEntryPoint { - - @EntryPoint - @InstallIn(SingletonComponent::class) - interface AppNavigationDependencies { - val isUserLoggedInUseCase: IsUserLoggedInUseCase - } - - @Composable - fun get(): AppNavigationDependencies { - val context = LocalContext.current.applicationContext - return remember { EntryPoints.get(context, AppNavigationDependencies::class.java) } - } -} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/auth/AuthEntryPoint.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/auth/AuthEntryPoint.kt deleted file mode 100644 index 3193ef8..0000000 --- a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/auth/AuthEntryPoint.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.fnives.test.showcase.hilt.compose.screen.auth - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext -import dagger.hilt.EntryPoint -import dagger.hilt.EntryPoints -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import org.fnives.test.showcase.hilt.core.login.LoginUseCase - -object AuthEntryPoint { - - @EntryPoint - @InstallIn(SingletonComponent::class) - interface AuthDependencies { - val loginUseCase: LoginUseCase - } - - @Composable - fun get(): AuthDependencies { - val context = LocalContext.current.applicationContext - return remember { EntryPoints.get(context, AuthDependencies::class.java) } - } -} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/auth/AuthScreenState.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/auth/AuthScreenState.kt index 49454f6..9122ef9 100644 --- a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/auth/AuthScreenState.kt +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/auth/AuthScreenState.kt @@ -11,6 +11,8 @@ import androidx.compose.runtime.setValue import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import org.fnives.test.showcase.hilt.compose.di.ComposeEntryPoint.AuthDependencies +import org.fnives.test.showcase.hilt.compose.di.ComposeEntryPoint.rememberEntryPoint import org.fnives.test.showcase.hilt.core.login.LoginUseCase import org.fnives.test.showcase.model.auth.LoginCredentials import org.fnives.test.showcase.model.auth.LoginStatus @@ -19,7 +21,19 @@ import org.fnives.test.showcase.model.shared.Answer @Composable fun rememberAuthScreenState( stateScope: CoroutineScope = rememberCoroutineScope { Dispatchers.Main }, - loginUseCase: LoginUseCase = AuthEntryPoint.get().loginUseCase, + authDependencies: AuthDependencies = rememberEntryPoint(), + onLoginSuccess: () -> Unit = {}, +): AuthScreenState = + rememberAuthScreenState( + stateScope = stateScope, + loginUseCase = authDependencies.loginUseCase, + onLoginSuccess = onLoginSuccess + ) + +@Composable +fun rememberAuthScreenState( + stateScope: CoroutineScope = rememberCoroutineScope { Dispatchers.Main }, + loginUseCase: LoginUseCase, onLoginSuccess: () -> Unit = {}, ): AuthScreenState { return rememberSaveable(saver = AuthScreenState.getSaver(stateScope, loginUseCase, onLoginSuccess)) { diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/home/HomeScreenState.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/home/HomeScreenState.kt index a7e2643..d51a27a 100644 --- a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/home/HomeScreenState.kt +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/home/HomeScreenState.kt @@ -9,6 +9,8 @@ import androidx.compose.runtime.setValue import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch +import org.fnives.test.showcase.hilt.compose.di.ComposeEntryPoint.MainDependencies +import org.fnives.test.showcase.hilt.compose.di.ComposeEntryPoint.rememberEntryPoint import org.fnives.test.showcase.hilt.core.content.AddContentToFavouriteUseCase import org.fnives.test.showcase.hilt.core.content.FetchContentUseCase import org.fnives.test.showcase.hilt.core.content.GetAllContentUseCase @@ -21,7 +23,7 @@ import org.fnives.test.showcase.model.shared.Resource @Composable fun rememberHomeScreenState( stateScope: CoroutineScope = rememberCoroutineScope(), - mainDependencies: HomeEntryPoint.MainDependencies = HomeEntryPoint.get(), + mainDependencies: MainDependencies = rememberEntryPoint(), onLogout: () -> Unit = {}, ) = rememberHomeScreenState( From e3720ff3f67d40e5dfff1d7251aebd88cd04aa38 Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Thu, 29 Sep 2022 14:59:37 +0300 Subject: [PATCH 2/4] PR#128 Fix Compose UI sync issue Sometimes the emptyPasswordShowsProperErrorMessage failed because while waiting for Idling Resources/Coroutines to run the clock has been updated and the Snackbar got dismissed. autoAdvance=off, waitForIdle, autoAdvance=on pattern ensures this doesnt happen --- .../fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt | 6 ++++++ .../showcase/hilt/ui/compose/AuthComposeInstrumentedTest.kt | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt index 97287e5..19c259c 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt @@ -83,6 +83,9 @@ class AuthComposeInstrumentedTest : KoinTest { robot.setUsername("banan") .assertUsername("banan") .clickOnLogin() + composeTestRule.mainClock.autoAdvance = false + composeTestRule.waitForIdle() + composeTestRule.mainClock.autoAdvance = true robot.assertErrorIsShown(R.string.password_is_invalid) .assertNotLoading() @@ -99,6 +102,9 @@ class AuthComposeInstrumentedTest : KoinTest { .setPassword("banan") .assertPassword("banan") .clickOnLogin() + composeTestRule.mainClock.autoAdvance = false + composeTestRule.waitForIdle() + composeTestRule.mainClock.autoAdvance = true robot.assertErrorIsShown(R.string.username_is_invalid) .assertNotLoading() diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/AuthComposeInstrumentedTest.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/AuthComposeInstrumentedTest.kt index ec296b0..d0f2e03 100644 --- a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/AuthComposeInstrumentedTest.kt +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/AuthComposeInstrumentedTest.kt @@ -99,6 +99,9 @@ class AuthComposeInstrumentedTest { robot.setUsername("banan") .assertUsername("banan") .clickOnLogin() + composeTestRule.mainClock.autoAdvance = false + composeTestRule.waitForIdle() + composeTestRule.mainClock.autoAdvance = true robot.assertErrorIsShown(R.string.password_is_invalid) .assertNotLoading() @@ -115,6 +118,9 @@ class AuthComposeInstrumentedTest { .setPassword("banan") .assertPassword("banan") .clickOnLogin() + composeTestRule.mainClock.autoAdvance = false + composeTestRule.waitForIdle() + composeTestRule.mainClock.autoAdvance = true robot.assertErrorIsShown(R.string.username_is_invalid) .assertNotLoading() From 38d4414dec167eb7c969f8868e9ea5e047d43c64 Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Thu, 29 Sep 2022 15:01:35 +0300 Subject: [PATCH 3/4] PR#128 Fix typo in Compose Test setup --- .../org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt | 2 +- .../showcase/hilt/ui/compose/AuthComposeInstrumentedTest.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt index 19c259c..2d11ca2 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt @@ -115,7 +115,7 @@ class AuthComposeInstrumentedTest : KoinTest { @Test fun invalidCredentialsGivenShowsProperErrorMessage() { mockServerScenarioSetup.setScenario( - AuthScenario.InvalidCredentials(password = "alma", username = "banan") + AuthScenario.InvalidCredentials(username = "alma", password = "banan") ) composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY) diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/AuthComposeInstrumentedTest.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/AuthComposeInstrumentedTest.kt index d0f2e03..ba5685f 100644 --- a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/AuthComposeInstrumentedTest.kt +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/AuthComposeInstrumentedTest.kt @@ -131,7 +131,7 @@ class AuthComposeInstrumentedTest { @Test fun invalidCredentialsGivenShowsProperErrorMessage() { mockServerScenarioSetup.setScenario( - AuthScenario.InvalidCredentials(password = "alma", username = "banan") + AuthScenario.InvalidCredentials(username = "alma", password = "banan") ) composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY) From 595c35ca676ca9cd2f9f55bbb3ea167deb5dd7a3 Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Thu, 29 Sep 2022 15:13:52 +0300 Subject: [PATCH 4/4] PR#128 Update Compose instructionset with the changes --- codekata/compose.instructionset.md | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/codekata/compose.instructionset.md b/codekata/compose.instructionset.md index 8052c4f..9b34b3d 100644 --- a/codekata/compose.instructionset.md +++ b/codekata/compose.instructionset.md @@ -111,7 +111,6 @@ Put a tag on it and use the same finders and assertions. The setup is the mostly the same as for View so for the sake of simplicity let's focus on the differences. - ##### Initializing the UI We don't need an activity scenario. We will use instead `createComposeRule()` which will handle the host activity. If you need a specific activity, use `createAndroidComposeRule()`. @@ -224,9 +223,7 @@ navigationRobot.assertHomeScreen() ``` > Note: Any node interactions call waitForIdle which waits for the Coroutine then the Network Call to finish. The Network call is running on OkHttps's own thread, so we use IdlingResources to synchronize with it. This is done in the ComposeNetworkSynchronizationTestRule. -> waitForIdle blocks the current thread while the Resources are busy. There is an alternative awaitIdle() which can be useful in runTest suspendable tests, feel free to look inside the Interface of ComposeTestRule. -> If you don't interact with a node but want to synchronize, then you will need waitForIdle. For example to verify something was called or written into like FakeLocalStorage in this example -> Basically since we have OkHttpIdlingResource as an EspressoIdlingResource we adapt that to Compose's IdlingResource class and register it with the ComposeTestRule and unregister it at the end. +> In ComposeNetworkSynchronizationTestRuleBasically since we have OkHttpIdlingResource as an EspressoIdlingResource we adapt that to Compose's IdlingResource class and register it with the ComposeTestRule and unregister it at the end. ### 2. `emptyPasswordShowsProperErrorMessage` @@ -245,7 +242,18 @@ robot.setUsername("banan") .clickOnLogin() ``` -Finally we let coroutines go and verify the error is shown and we have not navigated: +Now, we will let the coroutine go and await the network call: +```kotlin +composeTestRule.mainClock.autoAdvance = false +composeTestRule.waitForIdle() +composeTestRule.mainClock.autoAdvance = true +``` + +This may seem a bit odd, but what we want is to be sure that while waiting for the request or any idling resource we do not advance the Compose Clock. That's because Snackbar has a LaunchedEffect to dismiss, if we let the clock run, it could sometime dismiss the Snackbar which could result in a Flaky test. + +> autoAdvance=off, waitForIdle(), autoAdvance=on pattern can be used to await external resources without affecting Compose Side Effects. + +Finally we verify the error is shown and we have not navigated: ```kotlin robot.assertErrorIsShown(R.string.password_is_invalid) .assertNotLoading() @@ -265,6 +273,9 @@ robot .setPassword("banan") .assertPassword("banan") .clickOnLogin() +composeTestRule.mainClock.autoAdvance = false +composeTestRule.waitForIdle() +composeTestRule.mainClock.autoAdvance = true robot.assertErrorIsShown(R.string.username_is_invalid) .assertNotLoading() @@ -296,6 +307,9 @@ robot.assertLoading() composeTestRule.mainClock.autoAdvance = true ``` +> Note: `robot.assertLoading` since it is a node interaction already calls waitForIdle. +> We only advance the time by one frame to be able to verify the Loading, otherwise it would already disappear. + Now at the end verify the error is shown properly: ```kotlin robot.assertErrorIsShown(R.string.credentials_invalid)