Merge pull request #127 from fknives/issue#41-add-hilt-setup
Issue#41 Copy full example into separate module with Hilt Integration
This commit is contained in:
commit
e9028c0e86
242 changed files with 8532 additions and 71 deletions
8
.github/workflows/pull-request-jobs.yml
vendored
8
.github/workflows/pull-request-jobs.yml
vendored
|
|
@ -75,7 +75,9 @@ jobs:
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: JVM Test Results
|
name: JVM Test Results
|
||||||
path: ./**/build/reports/tests/**/*.html
|
path: |
|
||||||
|
./**/build/reports/tests/**/*.html
|
||||||
|
./**/**/build/reports/tests/**/*.html
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
run-tests-on-emulator:
|
run-tests-on-emulator:
|
||||||
|
|
@ -126,7 +128,9 @@ jobs:
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: Emulator-Test-Results-${{ matrix.api-level }}
|
name: Emulator-Test-Results-${{ matrix.api-level }}
|
||||||
path: ./**/build/reports/androidTests/**/*.html
|
path: |
|
||||||
|
./**/build/reports/androidTests/**/*.html
|
||||||
|
./**/**/build/reports/androidTests/**/*.html
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
- name: Upload Test Screenshots
|
- name: Upload Test Screenshots
|
||||||
uses: actions/upload-artifact@v2
|
uses: actions/upload-artifact@v2
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.koin.core.context.stopKoin
|
import org.koin.core.context.stopKoin
|
||||||
|
import org.koin.test.KoinTest
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -23,7 +24,7 @@ import java.io.IOException
|
||||||
* https://developer.android.com/training/data-storage/room/migrating-db-versions
|
* https://developer.android.com/training/data-storage/room/migrating-db-versions
|
||||||
*/
|
*/
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
open class MigrationToLatestInstrumentedSharedTest {
|
abstract class MigrationToLatestInstrumentedSharedTest : KoinTest {
|
||||||
|
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val helper = SharedMigrationTestRule<LocalDatabase>(instrumentation = InstrumentationRegistry.getInstrumentation())
|
val helper = SharedMigrationTestRule<LocalDatabase>(instrumentation = InstrumentationRegistry.getInstrumentation())
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package org.fnives.test.showcase.ui.home
|
||||||
|
|
||||||
import androidx.test.core.app.ActivityScenario
|
import androidx.test.core.app.ActivityScenario
|
||||||
import androidx.test.espresso.intent.Intents
|
import androidx.test.espresso.intent.Intents
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import org.fnives.test.showcase.android.testutil.activity.SafeCloseActivityRule
|
import org.fnives.test.showcase.android.testutil.activity.SafeCloseActivityRule
|
||||||
import org.fnives.test.showcase.android.testutil.activity.safeClose
|
import org.fnives.test.showcase.android.testutil.activity.safeClose
|
||||||
import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule
|
import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule
|
||||||
|
|
@ -21,12 +20,10 @@ import org.junit.Before
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.rules.RuleChain
|
import org.junit.rules.RuleChain
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.koin.test.KoinTest
|
import org.koin.test.KoinTest
|
||||||
|
|
||||||
@Suppress("TestFunctionName")
|
@Suppress("TestFunctionName")
|
||||||
@RunWith(AndroidJUnit4::class)
|
abstract class MainActivityInstrumentedSharedTest : KoinTest {
|
||||||
open class MainActivityInstrumentedSharedTest : KoinTest {
|
|
||||||
|
|
||||||
private lateinit var activityScenario: ActivityScenario<MainActivity>
|
private lateinit var activityScenario: ActivityScenario<MainActivity>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package org.fnives.test.showcase.ui.login
|
||||||
|
|
||||||
import androidx.test.core.app.ActivityScenario
|
import androidx.test.core.app.ActivityScenario
|
||||||
import androidx.test.espresso.intent.Intents
|
import androidx.test.espresso.intent.Intents
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import org.fnives.test.showcase.R
|
import org.fnives.test.showcase.R
|
||||||
import org.fnives.test.showcase.android.testutil.activity.SafeCloseActivityRule
|
import org.fnives.test.showcase.android.testutil.activity.SafeCloseActivityRule
|
||||||
import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule
|
import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule
|
||||||
|
|
@ -16,12 +15,10 @@ import org.junit.Before
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.rules.RuleChain
|
import org.junit.rules.RuleChain
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.koin.test.KoinTest
|
import org.koin.test.KoinTest
|
||||||
|
|
||||||
@Suppress("TestFunctionName")
|
@Suppress("TestFunctionName")
|
||||||
@RunWith(AndroidJUnit4::class)
|
abstract class AuthActivityInstrumentedSharedTest : KoinTest {
|
||||||
open class AuthActivityInstrumentedSharedTest : KoinTest {
|
|
||||||
|
|
||||||
private lateinit var activityScenario: ActivityScenario<AuthActivity>
|
private lateinit var activityScenario: ActivityScenario<AuthActivity>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import org.koin.test.KoinTest
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@Ignore("CodeKata")
|
@Ignore("CodeKata")
|
||||||
@Suppress("EmptyFunctionBlock")
|
@Suppress("EmptyFunctionBlock")
|
||||||
open class CodeKataAuthActivitySharedTest : KoinTest {
|
abstract class CodeKataAuthActivitySharedTest : KoinTest {
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setup() {
|
fun setup() {
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import org.junit.rules.RuleChain
|
||||||
import org.koin.test.KoinTest
|
import org.koin.test.KoinTest
|
||||||
|
|
||||||
@Suppress("TestFunctionName")
|
@Suppress("TestFunctionName")
|
||||||
open class SplashActivityInstrumentedSharedTest : KoinTest {
|
abstract class SplashActivityInstrumentedSharedTest : KoinTest {
|
||||||
|
|
||||||
private lateinit var activityScenario: ActivityScenario<SplashActivity>
|
private lateinit var activityScenario: ActivityScenario<SplashActivity>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,12 +41,8 @@ android {
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
androidTest {
|
|
||||||
// assets.srcDirs += files("$projectDir/schemas".toString())
|
|
||||||
}
|
|
||||||
test {
|
test {
|
||||||
java.srcDirs += "src/robolectricTest/java"
|
java.srcDirs += "src/robolectricTest/java"
|
||||||
// resources.srcDirs += files("$projectDir/schemas".toString())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,18 @@
|
||||||
package org.fnives.test.showcase.ui
|
package org.fnives.test.showcase.ui
|
||||||
|
|
||||||
import androidx.compose.ui.test.MainTestClock
|
|
||||||
import androidx.compose.ui.test.junit4.StateRestorationTester
|
import androidx.compose.ui.test.junit4.StateRestorationTester
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
import androidx.test.espresso.Espresso
|
|
||||||
import androidx.test.espresso.matcher.ViewMatchers
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import org.fnives.test.showcase.R
|
import org.fnives.test.showcase.R
|
||||||
import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule
|
import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule
|
||||||
import org.fnives.test.showcase.android.testutil.screenshot.ScreenshotRule
|
import org.fnives.test.showcase.android.testutil.screenshot.ScreenshotRule
|
||||||
import org.fnives.test.showcase.android.testutil.viewaction.LoopMainThreadFor
|
|
||||||
import org.fnives.test.showcase.compose.screen.AppNavigation
|
import org.fnives.test.showcase.compose.screen.AppNavigation
|
||||||
import org.fnives.test.showcase.core.integration.fake.FakeUserDataLocalStorage
|
import org.fnives.test.showcase.core.integration.fake.FakeUserDataLocalStorage
|
||||||
import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase
|
import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase
|
||||||
import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario
|
import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario
|
||||||
import org.fnives.test.showcase.testutils.MockServerScenarioSetupResetingTestRule
|
import org.fnives.test.showcase.testutils.MockServerScenarioSetupResetingTestRule
|
||||||
import org.fnives.test.showcase.testutils.idling.DatabaseDispatcherTestRule
|
import org.fnives.test.showcase.testutils.idling.DatabaseDispatcherTestRule
|
||||||
|
import org.fnives.test.showcase.ui.compose.idle.ComposeNetworkSynchronizationTestRule
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -29,7 +26,9 @@ class AuthComposeInstrumentedTest : KoinTest {
|
||||||
private val composeTestRule = createComposeRule()
|
private val composeTestRule = createComposeRule()
|
||||||
private val stateRestorationTester = StateRestorationTester(composeTestRule)
|
private val stateRestorationTester = StateRestorationTester(composeTestRule)
|
||||||
|
|
||||||
private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule()
|
private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule(
|
||||||
|
networkSynchronizationTestRule = ComposeNetworkSynchronizationTestRule(composeTestRule)
|
||||||
|
)
|
||||||
private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup
|
private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup
|
||||||
private val dispatcherTestRule = DatabaseDispatcherTestRule()
|
private val dispatcherTestRule = DatabaseDispatcherTestRule()
|
||||||
private lateinit var robot: ComposeLoginRobot
|
private lateinit var robot: ComposeLoginRobot
|
||||||
|
|
@ -72,7 +71,6 @@ class AuthComposeInstrumentedTest : KoinTest {
|
||||||
robot.assertLoading()
|
robot.assertLoading()
|
||||||
composeTestRule.mainClock.autoAdvance = true
|
composeTestRule.mainClock.autoAdvance = true
|
||||||
|
|
||||||
composeTestRule.mainClock.awaitIdlingResources()
|
|
||||||
navigationRobot.assertHomeScreen()
|
navigationRobot.assertHomeScreen()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,7 +84,6 @@ class AuthComposeInstrumentedTest : KoinTest {
|
||||||
.assertUsername("banan")
|
.assertUsername("banan")
|
||||||
.clickOnLogin()
|
.clickOnLogin()
|
||||||
|
|
||||||
composeTestRule.mainClock.awaitIdlingResources()
|
|
||||||
robot.assertErrorIsShown(R.string.password_is_invalid)
|
robot.assertErrorIsShown(R.string.password_is_invalid)
|
||||||
.assertNotLoading()
|
.assertNotLoading()
|
||||||
navigationRobot.assertAuthScreen()
|
navigationRobot.assertAuthScreen()
|
||||||
|
|
@ -103,7 +100,6 @@ class AuthComposeInstrumentedTest : KoinTest {
|
||||||
.assertPassword("banan")
|
.assertPassword("banan")
|
||||||
.clickOnLogin()
|
.clickOnLogin()
|
||||||
|
|
||||||
composeTestRule.mainClock.awaitIdlingResources()
|
|
||||||
robot.assertErrorIsShown(R.string.username_is_invalid)
|
robot.assertErrorIsShown(R.string.username_is_invalid)
|
||||||
.assertNotLoading()
|
.assertNotLoading()
|
||||||
navigationRobot.assertAuthScreen()
|
navigationRobot.assertAuthScreen()
|
||||||
|
|
@ -129,7 +125,6 @@ class AuthComposeInstrumentedTest : KoinTest {
|
||||||
robot.assertLoading()
|
robot.assertLoading()
|
||||||
composeTestRule.mainClock.autoAdvance = true
|
composeTestRule.mainClock.autoAdvance = true
|
||||||
|
|
||||||
composeTestRule.mainClock.awaitIdlingResources()
|
|
||||||
robot.assertErrorIsShown(R.string.credentials_invalid)
|
robot.assertErrorIsShown(R.string.credentials_invalid)
|
||||||
.assertNotLoading()
|
.assertNotLoading()
|
||||||
navigationRobot.assertAuthScreen()
|
navigationRobot.assertAuthScreen()
|
||||||
|
|
@ -155,7 +150,6 @@ class AuthComposeInstrumentedTest : KoinTest {
|
||||||
robot.assertLoading()
|
robot.assertLoading()
|
||||||
composeTestRule.mainClock.autoAdvance = true
|
composeTestRule.mainClock.autoAdvance = true
|
||||||
|
|
||||||
composeTestRule.mainClock.awaitIdlingResources()
|
|
||||||
robot.assertErrorIsShown(R.string.something_went_wrong)
|
robot.assertErrorIsShown(R.string.something_went_wrong)
|
||||||
.assertNotLoading()
|
.assertNotLoading()
|
||||||
navigationRobot.assertAuthScreen()
|
navigationRobot.assertAuthScreen()
|
||||||
|
|
@ -181,15 +175,5 @@ class AuthComposeInstrumentedTest : KoinTest {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val SPLASH_DELAY = 600L
|
private const val SPLASH_DELAY = 600L
|
||||||
|
|
||||||
// workaround, issue with idlingResources is tracked here https://github.com/robolectric/robolectric/issues/4807
|
|
||||||
/**
|
|
||||||
* Await the idling resource on a different thread while looping main.
|
|
||||||
*/
|
|
||||||
fun MainTestClock.awaitIdlingResources() {
|
|
||||||
Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainThreadFor(100L))
|
|
||||||
|
|
||||||
advanceTimeByFrame()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
package org.fnives.test.showcase.ui
|
package org.fnives.test.showcase.ui
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
|
||||||
import androidx.compose.ui.test.assertCountEquals
|
import androidx.compose.ui.test.assertCountEquals
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.assertTextContains
|
import androidx.compose.ui.test.assertTextContains
|
||||||
import androidx.compose.ui.test.junit4.ComposeTestRule
|
|
||||||
import androidx.compose.ui.test.onAllNodesWithTag
|
import androidx.compose.ui.test.onAllNodesWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
|
|
@ -13,8 +13,8 @@ import androidx.test.core.app.ApplicationProvider
|
||||||
import org.fnives.test.showcase.compose.screen.auth.AuthScreenTag
|
import org.fnives.test.showcase.compose.screen.auth.AuthScreenTag
|
||||||
|
|
||||||
class ComposeLoginRobot(
|
class ComposeLoginRobot(
|
||||||
composeTestRule: ComposeTestRule,
|
semanticsNodeInteractionsProvider: SemanticsNodeInteractionsProvider,
|
||||||
) : ComposeTestRule by composeTestRule {
|
) : SemanticsNodeInteractionsProvider by semanticsNodeInteractionsProvider {
|
||||||
|
|
||||||
fun setUsername(username: String): ComposeLoginRobot = apply {
|
fun setUsername(username: String): ComposeLoginRobot = apply {
|
||||||
onNodeWithTag(AuthScreenTag.UsernameInput).performTextInput(username)
|
onNodeWithTag(AuthScreenTag.UsernameInput).performTextInput(username)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
package org.fnives.test.showcase.ui.compose.idle
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.IdlingResource
|
||||||
|
import androidx.compose.ui.test.junit4.ComposeTestRule
|
||||||
|
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.Disposable
|
||||||
|
|
||||||
|
class ComposeIdlingDisposable(
|
||||||
|
private val idlingResource: IdlingResource,
|
||||||
|
private val testRule: ComposeTestRule,
|
||||||
|
) : Disposable {
|
||||||
|
override var isDisposed: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
init {
|
||||||
|
testRule.registerIdlingResource(idlingResource)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun dispose() {
|
||||||
|
isDisposed = true
|
||||||
|
testRule.unregisterIdlingResource(idlingResource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
package org.fnives.test.showcase.ui.compose.idle
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.annotation.CheckResult
|
||||||
|
import androidx.compose.ui.test.junit4.ComposeTestRule
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.CompositeDisposable
|
||||||
|
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.Disposable
|
||||||
|
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.OkHttp3IdlingResource
|
||||||
|
import org.fnives.test.showcase.network.testutil.NetworkTestConfigurationHelper
|
||||||
|
import org.junit.rules.TestRule
|
||||||
|
import org.junit.runner.Description
|
||||||
|
import org.junit.runners.model.Statement
|
||||||
|
import org.koin.test.KoinTest
|
||||||
|
|
||||||
|
class ComposeNetworkSynchronizationTestRule(private val composeTestRule: ComposeTestRule) : TestRule, KoinTest {
|
||||||
|
|
||||||
|
private var disposable: Disposable? = null
|
||||||
|
|
||||||
|
override fun apply(base: Statement, description: Description): Statement {
|
||||||
|
return object : Statement() {
|
||||||
|
override fun evaluate() {
|
||||||
|
disposable = registerIdlingResources()
|
||||||
|
try {
|
||||||
|
base.evaluate()
|
||||||
|
} finally {
|
||||||
|
dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dispose() {
|
||||||
|
if (disposable == null) {
|
||||||
|
Log.w("ComposeNetworkSynchronizationTestRule", "Was disposed, but registerIdlingResources was not called!")
|
||||||
|
}
|
||||||
|
disposable?.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
@CheckResult
|
||||||
|
private fun registerIdlingResources(): Disposable = getOkHttpClients()
|
||||||
|
.associateBy(keySelector = { it.toString() })
|
||||||
|
.map { (key, client) -> OkHttp3IdlingResource.create(key, client) }
|
||||||
|
.map(::EspressoToComposeIdlingResourceAdapter)
|
||||||
|
.map { ComposeIdlingDisposable(it, composeTestRule) }
|
||||||
|
.let(::CompositeDisposable)
|
||||||
|
|
||||||
|
private fun getOkHttpClients(): List<OkHttpClient> =
|
||||||
|
NetworkTestConfigurationHelper.getOkHttpClients()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package org.fnives.test.showcase.ui.compose.idle
|
||||||
|
|
||||||
|
import androidx.test.espresso.IdlingResource
|
||||||
|
|
||||||
|
class EspressoToComposeIdlingResourceAdapter(private val idlingResource: IdlingResource) : androidx.compose.ui.test.IdlingResource {
|
||||||
|
override val isIdleNow: Boolean get() = idlingResource.isIdleNow
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ buildscript {
|
||||||
ext.kotlin_version = "1.6.10"
|
ext.kotlin_version = "1.6.10"
|
||||||
ext.detekt_version = "1.19.0"
|
ext.detekt_version = "1.19.0"
|
||||||
ext.navigation_version = "2.4.2"
|
ext.navigation_version = "2.4.2"
|
||||||
|
ext.hilt_version = "2.40.5"
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
google()
|
google()
|
||||||
|
|
@ -13,6 +14,7 @@ buildscript {
|
||||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
classpath "org.jlleitschuh.gradle:ktlint-gradle:10.2.1"
|
classpath "org.jlleitschuh.gradle:ktlint-gradle:10.2.1"
|
||||||
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navigation_version"
|
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navigation_version"
|
||||||
|
classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,8 @@ Here is a list of actions we want to do:
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
class ComposeLoginRobot(
|
class ComposeLoginRobot(
|
||||||
composeTestRule: ComposeTestRule,
|
semanticsNodeInteractionsProvider: SemanticsNodeInteractionsProvider,
|
||||||
) : ComposeTestRule by composeTestRule {
|
) : SemanticsNodeInteractionsProvider by semanticsNodeInteractionsProvider {
|
||||||
|
|
||||||
fun setUsername(username: String): ComposeLoginRobot = apply {
|
fun setUsername(username: String): ComposeLoginRobot = apply {
|
||||||
onNodeWithTag(AuthScreenTag.UsernameInput).performTextInput(username)
|
onNodeWithTag(AuthScreenTag.UsernameInput).performTextInput(username)
|
||||||
|
|
@ -61,9 +61,11 @@ class ComposeLoginRobot(
|
||||||
```
|
```
|
||||||
|
|
||||||
While in the View system we're using Espresso to interact with views,
|
While in the View system we're using Espresso to interact with views,
|
||||||
in Compose we need a reference to the `ComposeTestRule` that contains our UI,
|
in Compose we need a reference to the `SemanticsNodeInteractionsProvider` that contains our UI,
|
||||||
which we will pass as a constructor parameter to the robot.
|
which we will pass as a constructor parameter to the robot.
|
||||||
|
|
||||||
|
> SemanticsNodeInteractionsProvider gives access to `onNode` actions. ComposeTestRule extends it.
|
||||||
|
|
||||||
To create a `ComposeTestRule` you simply need to:
|
To create a `ComposeTestRule` you simply need to:
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
|
|
@ -152,10 +154,14 @@ fun setup() {
|
||||||
Network synchronization and mocking is the same as for View.
|
Network synchronization and mocking is the same as for View.
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule()
|
private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule(
|
||||||
|
networkSynchronizationTestRule = ComposeNetworkSynchronizationTestRule(composeTestRule)
|
||||||
|
)
|
||||||
private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup
|
private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> ComposeNetworkSynchronizationTestRule is an equivalent to NetworkSynchronizationTestRule just registering the IdlingResource to ComposeTestRule instead of Espresso
|
||||||
|
|
||||||
Coroutine setup is the same, except for `Dispatchers.setMain(dispatcher)`, which we don't need.
|
Coroutine setup is the same, except for `Dispatchers.setMain(dispatcher)`, which we don't need.
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
|
|
@ -214,12 +220,13 @@ composeTestRule.mainClock.autoAdvance = true // Let clock auto advance again
|
||||||
|
|
||||||
Lastly we check the navigation was correct, meaning we should be on the home screen:
|
Lastly we check the navigation was correct, meaning we should be on the home screen:
|
||||||
```kotlin
|
```kotlin
|
||||||
composeTestRule.mainClock.awaitIdlingResources() // wait for login network call idling resource
|
|
||||||
navigationRobot.assertHomeScreen()
|
navigationRobot.assertHomeScreen()
|
||||||
```
|
```
|
||||||
|
|
||||||
> `awaitIdlingResources` is an extension function to await all idling resources.
|
> 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.
|
||||||
> Note: Considering what the docs say this shouldn't be necessarily if the idling resources are setup in Espresso, since the compose test rule is aware of espresso and it waits for idle before every finder. In practice it only works with the line above. Could be a bug somewhere.
|
> 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.
|
||||||
|
|
||||||
### 2. `emptyPasswordShowsProperErrorMessage`
|
### 2. `emptyPasswordShowsProperErrorMessage`
|
||||||
|
|
||||||
|
|
@ -240,7 +247,6 @@ robot.setUsername("banan")
|
||||||
|
|
||||||
Finally we let coroutines go and verify the error is shown and we have not navigated:
|
Finally we let coroutines go and verify the error is shown and we have not navigated:
|
||||||
```kotlin
|
```kotlin
|
||||||
composeTestRule.mainClock.awaitIdlingResources()
|
|
||||||
robot.assertErrorIsShown(R.string.password_is_invalid)
|
robot.assertErrorIsShown(R.string.password_is_invalid)
|
||||||
.assertNotLoading()
|
.assertNotLoading()
|
||||||
navigationRobot.assertAuthScreen()
|
navigationRobot.assertAuthScreen()
|
||||||
|
|
@ -260,7 +266,6 @@ robot
|
||||||
.assertPassword("banan")
|
.assertPassword("banan")
|
||||||
.clickOnLogin()
|
.clickOnLogin()
|
||||||
|
|
||||||
composeTestRule.mainClock.awaitIdlingResources()
|
|
||||||
robot.assertErrorIsShown(R.string.username_is_invalid)
|
robot.assertErrorIsShown(R.string.username_is_invalid)
|
||||||
.assertNotLoading()
|
.assertNotLoading()
|
||||||
navigationRobot.assertAuthScreen()
|
navigationRobot.assertAuthScreen()
|
||||||
|
|
@ -293,7 +298,6 @@ composeTestRule.mainClock.autoAdvance = true
|
||||||
|
|
||||||
Now at the end verify the error is shown properly:
|
Now at the end verify the error is shown properly:
|
||||||
```kotlin
|
```kotlin
|
||||||
composeTestRule.mainClock.awaitIdlingResources()
|
|
||||||
robot.assertErrorIsShown(R.string.credentials_invalid)
|
robot.assertErrorIsShown(R.string.credentials_invalid)
|
||||||
.assertNotLoading()
|
.assertNotLoading()
|
||||||
navigationRobot.assertAuthScreen()
|
navigationRobot.assertAuthScreen()
|
||||||
|
|
@ -323,7 +327,6 @@ composeTestRule.mainClock.advanceTimeByFrame()
|
||||||
robot.assertLoading()
|
robot.assertLoading()
|
||||||
composeTestRule.mainClock.autoAdvance = true
|
composeTestRule.mainClock.autoAdvance = true
|
||||||
|
|
||||||
composeTestRule.mainClock.awaitIdlingResources()
|
|
||||||
robot.assertErrorIsShown(R.string.something_went_wrong)
|
robot.assertErrorIsShown(R.string.something_went_wrong)
|
||||||
.assertNotLoading()
|
.assertNotLoading()
|
||||||
navigationRobot.assertAuthScreen()
|
navigationRobot.assertAuthScreen()
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ Let's open `org.fnives.test.showcase.ui.login.codekata.CodeKataAuthActivityShare
|
||||||
We can see it's identical as our original `org.fnives.test.showcase.ui.codekata.CodeKataAuthActivityInstrumentedTest`.
|
We can see it's identical as our original `org.fnives.test.showcase.ui.codekata.CodeKataAuthActivityInstrumentedTest`.
|
||||||
So let's copy our existing code from the Robolectric test here. For that we can use the body of `org.fnives.test.showcase.ui.RobolectricAuthActivityInstrumentedTest`.
|
So let's copy our existing code from the Robolectric test here. For that we can use the body of `org.fnives.test.showcase.ui.RobolectricAuthActivityInstrumentedTest`.
|
||||||
|
|
||||||
Of course keep the `open` and the `CodeKataAuthActivitySharedTest` class name and package.
|
Of course keep the `abstract`, the `CodeKataAuthActivitySharedTest` class name and package.
|
||||||
We need to modify our robot:
|
We need to modify our robot:
|
||||||
```kotlin
|
```kotlin
|
||||||
// Instead of this:
|
// Instead of this:
|
||||||
|
|
|
||||||
1
hilt/hilt-app-shared-test/.gitignore
vendored
Normal file
1
hilt/hilt-app-shared-test/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/build
|
||||||
49
hilt/hilt-app-shared-test/build.gradle
Normal file
49
hilt/hilt-app-shared-test/build.gradle
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
plugins {
|
||||||
|
id 'com.android.library'
|
||||||
|
id 'org.jetbrains.kotlin.android'
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdk 31
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk 21
|
||||||
|
targetSdk 31
|
||||||
|
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
consumerProguardFiles "consumer-rules.pro"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sourceSets {
|
||||||
|
main {
|
||||||
|
assets.srcDirs += files("$projectDir/../hilt-app/schemas".toString())
|
||||||
|
resources.srcDirs += files("$projectDir/../hilt-app/schemas".toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '1.8'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// since it itself contains the Test it doesn't have tests of it's own
|
||||||
|
disableTestTasks(this)
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation project(":hilt:hilt-app")
|
||||||
|
implementation project(':test-util-android')
|
||||||
|
implementation testFixtures(project(':hilt:hilt-core'))
|
||||||
|
implementation "com.google.dagger:hilt-android-testing:$hilt_version"
|
||||||
|
implementation project(':test-util-shared-robolectric')
|
||||||
|
api project(':hilt:hilt-network-di-test-util')
|
||||||
|
applyAppSharedTestDependenciesTo(this)
|
||||||
|
}
|
||||||
0
hilt/hilt-app-shared-test/consumer-rules.pro
Normal file
0
hilt/hilt-app-shared-test/consumer-rules.pro
Normal file
21
hilt/hilt-app-shared-test/proguard-rules.pro
vendored
Normal file
21
hilt/hilt-app-shared-test/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
5
hilt/hilt-app-shared-test/src/main/AndroidManifest.xml
Normal file
5
hilt/hilt-app-shared-test/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="org.fnives.test.showcase.hilt.test.shared">
|
||||||
|
|
||||||
|
</manifest>
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package org.fnives.test.showcase.hilt.test.shared.di
|
||||||
|
|
||||||
|
import org.fnives.test.showcase.hilt.BuildConfig
|
||||||
|
|
||||||
|
object TestBaseUrlHolder {
|
||||||
|
|
||||||
|
var url = BuildConfig.BASE_URL
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
package org.fnives.test.showcase.hilt.test.shared.storage.migration
|
||||||
|
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.fnives.test.showcase.android.testutil.SharedMigrationTestRule
|
||||||
|
import org.fnives.test.showcase.hilt.storage.LocalDatabase
|
||||||
|
import org.fnives.test.showcase.hilt.storage.favourite.FavouriteEntity
|
||||||
|
import org.fnives.test.showcase.hilt.storage.migation.Migration1To2
|
||||||
|
import org.junit.Assert
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* reference:
|
||||||
|
* https://medium.com/androiddevelopers/testing-room-migrations-be93cdb0d975
|
||||||
|
* https://developer.android.com/training/data-storage/room/migrating-db-versions
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
@Suppress("UnnecessaryAbstractClass")
|
||||||
|
abstract class MigrationToLatestInstrumentedSharedTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val helper = SharedMigrationTestRule<LocalDatabase>(instrumentation = InstrumentationRegistry.getInstrumentation())
|
||||||
|
|
||||||
|
private fun getMigratedRoomDatabase(): LocalDatabase {
|
||||||
|
val database: LocalDatabase = Room.databaseBuilder(
|
||||||
|
InstrumentationRegistry.getInstrumentation().targetContext,
|
||||||
|
LocalDatabase::class.java,
|
||||||
|
TEST_DB
|
||||||
|
)
|
||||||
|
.addMigrations(Migration1To2())
|
||||||
|
.build()
|
||||||
|
// close the database and release any stream resources when the test finishes
|
||||||
|
helper.closeWhenFinished(database)
|
||||||
|
return database
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Throws(IOException::class)
|
||||||
|
open fun migrate1To2() {
|
||||||
|
val expectedEntities = setOf(
|
||||||
|
FavouriteEntity("123"),
|
||||||
|
FavouriteEntity("124"),
|
||||||
|
FavouriteEntity("125")
|
||||||
|
)
|
||||||
|
val version1DB = helper.createDatabase(
|
||||||
|
name = TEST_DB,
|
||||||
|
version = 1
|
||||||
|
)
|
||||||
|
version1DB.run {
|
||||||
|
execSQL("INSERT OR IGNORE INTO `FavouriteEntity` (`contentId`) VALUES (\"123\")")
|
||||||
|
execSQL("INSERT OR IGNORE INTO `FavouriteEntity` (`contentId`) VALUES (124)")
|
||||||
|
execSQL("INSERT OR IGNORE INTO `FavouriteEntity` (`contentId`) VALUES (125)")
|
||||||
|
}
|
||||||
|
version1DB.close()
|
||||||
|
|
||||||
|
val version2DB = helper.runMigrationsAndValidate(
|
||||||
|
name = TEST_DB,
|
||||||
|
version = 2,
|
||||||
|
validateDroppedTables = true,
|
||||||
|
Migration1To2()
|
||||||
|
)
|
||||||
|
version2DB.close()
|
||||||
|
|
||||||
|
val favouriteDao = getMigratedRoomDatabase().favouriteDao
|
||||||
|
|
||||||
|
val entities = runBlocking { favouriteDao.get().first() }.toSet()
|
||||||
|
|
||||||
|
Assert.assertEquals(expectedEntities, entities)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TEST_DB = "migration-test"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
package org.fnives.test.showcase.hilt.test.shared.testutils
|
||||||
|
|
||||||
|
import org.fnives.test.showcase.hilt.network.testutil.HttpsConfigurationModuleTemplate
|
||||||
|
import org.fnives.test.showcase.hilt.test.shared.di.TestBaseUrlHolder
|
||||||
|
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
|
||||||
|
import org.junit.rules.TestRule
|
||||||
|
import org.junit.runner.Description
|
||||||
|
import org.junit.runners.model.Statement
|
||||||
|
|
||||||
|
class MockServerScenarioSetupTestRule : TestRule {
|
||||||
|
|
||||||
|
lateinit var mockServerScenarioSetup: MockServerScenarioSetup
|
||||||
|
|
||||||
|
override fun apply(base: Statement, description: Description): Statement = createStatement(base)
|
||||||
|
|
||||||
|
private fun createStatement(base: Statement) = object : Statement() {
|
||||||
|
@Throws(Throwable::class)
|
||||||
|
override fun evaluate() {
|
||||||
|
before()
|
||||||
|
try {
|
||||||
|
base.evaluate()
|
||||||
|
} finally {
|
||||||
|
after()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun before() {
|
||||||
|
val (mockServerScenarioSetup, url) = HttpsConfigurationModuleTemplate.startWithHTTPSMockWebServer()
|
||||||
|
TestBaseUrlHolder.url = url
|
||||||
|
this.mockServerScenarioSetup = mockServerScenarioSetup
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun after() {
|
||||||
|
mockServerScenarioSetup.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
package org.fnives.test.showcase.hilt.test.shared.testutils.idling
|
||||||
|
|
||||||
|
import org.fnives.test.showcase.hilt.ui.shared.executor.AsyncTaskExecutor
|
||||||
|
import org.fnives.test.showcase.hilt.ui.shared.executor.TaskExecutor
|
||||||
|
import org.junit.rules.TestRule
|
||||||
|
import org.junit.runner.Description
|
||||||
|
import org.junit.runners.model.Statement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Similar Test Rule to InstantTaskExecutorRule just for the [AsyncTaskExecutor] to make AsyncDiffUtil synchronized.
|
||||||
|
*/
|
||||||
|
class AsyncDiffUtilInstantTestRule : TestRule {
|
||||||
|
override fun apply(base: Statement, description: Description): Statement =
|
||||||
|
object : Statement() {
|
||||||
|
@Throws(Throwable::class)
|
||||||
|
override fun evaluate() {
|
||||||
|
AsyncTaskExecutor.delegate = object : TaskExecutor {
|
||||||
|
override fun executeOnDiskIO(runnable: Runnable) {
|
||||||
|
runnable.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun postToMainThread(runnable: Runnable) {
|
||||||
|
runnable.run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
base.evaluate()
|
||||||
|
|
||||||
|
AsyncTaskExecutor.delegate = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
package org.fnives.test.showcase.hilt.test.shared.testutils.idling
|
||||||
|
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.TestDispatcher
|
||||||
|
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.anyResourceNotIdle
|
||||||
|
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.awaitIdlingResources
|
||||||
|
import org.fnives.test.showcase.android.testutil.synchronization.runOnUIAwaitOnCurrent
|
||||||
|
import org.fnives.test.showcase.hilt.test.shared.testutils.storage.TestDatabaseInitialization
|
||||||
|
import org.junit.rules.TestRule
|
||||||
|
import org.junit.runner.Description
|
||||||
|
import org.junit.runners.model.Statement
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class DatabaseDispatcherTestRule : TestRule {
|
||||||
|
|
||||||
|
lateinit var testDispatcher: TestDispatcher
|
||||||
|
|
||||||
|
override fun apply(base: Statement, description: Description): Statement =
|
||||||
|
object : Statement() {
|
||||||
|
@Throws(Throwable::class)
|
||||||
|
override fun evaluate() {
|
||||||
|
val dispatcher = StandardTestDispatcher()
|
||||||
|
testDispatcher = dispatcher
|
||||||
|
TestDatabaseInitialization.dispatcher = dispatcher
|
||||||
|
base.evaluate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun advanceUntilIdleWithIdlingResources() = runOnUIAwaitOnCurrent {
|
||||||
|
testDispatcher.advanceUntilIdleWithIdlingResources()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun advanceUntilIdle() = runOnUIAwaitOnCurrent {
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun advanceTimeBy(delayInMillis: Long) = runOnUIAwaitOnCurrent {
|
||||||
|
testDispatcher.scheduler.advanceTimeBy(delayInMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun TestDispatcher.advanceUntilIdleWithIdlingResources() {
|
||||||
|
scheduler.advanceUntilIdle() // advance until a request is sent
|
||||||
|
while (anyResourceNotIdle()) { // check if any request is in progress
|
||||||
|
awaitIdlingResources() // complete all requests and other idling resources
|
||||||
|
scheduler.advanceUntilIdle() // run coroutines after request is finished
|
||||||
|
}
|
||||||
|
scheduler.advanceUntilIdle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package org.fnives.test.showcase.hilt.test.shared.testutils.idling
|
||||||
|
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.TestDispatcher
|
||||||
|
import org.fnives.test.showcase.hilt.test.shared.testutils.storage.TestDatabaseInitialization
|
||||||
|
import org.fnives.test.showcase.android.testutil.synchronization.MainDispatcherTestRule as LibMainDispatcherTestRule
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class MainDispatcherTestRule(useStandard: Boolean = true) : LibMainDispatcherTestRule(useStandard) {
|
||||||
|
|
||||||
|
override fun onTestDispatcherInitialized(testDispatcher: TestDispatcher) {
|
||||||
|
TestDatabaseInitialization.dispatcher = testDispatcher
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
package org.fnives.test.showcase.hilt.test.shared.testutils.idling
|
||||||
|
|
||||||
|
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.CompositeDisposable
|
||||||
|
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.IdlingResourceDisposable
|
||||||
|
import org.fnives.test.showcase.hilt.network.testutil.NetworkSynchronization
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class NetworkSynchronizationHelper @Inject constructor(private val networkSynchronization: NetworkSynchronization) {
|
||||||
|
|
||||||
|
private val compositeDisposable = CompositeDisposable()
|
||||||
|
|
||||||
|
fun setup() {
|
||||||
|
networkSynchronization.networkIdlingResources().map {
|
||||||
|
IdlingResourceDisposable(it)
|
||||||
|
}.forEach {
|
||||||
|
compositeDisposable.add(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dispose() {
|
||||||
|
compositeDisposable.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
package org.fnives.test.showcase.hilt.test.shared.testutils.statesetup
|
||||||
|
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.test.core.app.ActivityScenario
|
||||||
|
import androidx.test.espresso.intent.Intents
|
||||||
|
import androidx.test.runner.intent.IntentStubberRegistry
|
||||||
|
import org.fnives.test.showcase.android.testutil.activity.safeClose
|
||||||
|
import org.fnives.test.showcase.hilt.test.shared.testutils.idling.MainDispatcherTestRule
|
||||||
|
import org.fnives.test.showcase.hilt.test.shared.ui.auth.LoginRobot
|
||||||
|
import org.fnives.test.showcase.hilt.test.shared.ui.home.HomeRobot
|
||||||
|
import org.fnives.test.showcase.hilt.ui.auth.AuthActivity
|
||||||
|
import org.fnives.test.showcase.hilt.ui.home.MainActivity
|
||||||
|
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
|
||||||
|
import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario
|
||||||
|
import org.koin.test.KoinTest
|
||||||
|
|
||||||
|
object SetupAuthenticationState : KoinTest {
|
||||||
|
|
||||||
|
fun setupLogin(
|
||||||
|
mainDispatcherTestRule: MainDispatcherTestRule,
|
||||||
|
mockServerScenarioSetup: MockServerScenarioSetup,
|
||||||
|
resetIntents: Boolean = true,
|
||||||
|
) {
|
||||||
|
resetIntentsIfNeeded(resetIntents) {
|
||||||
|
mockServerScenarioSetup.setScenario(AuthScenario.Success(username = "a", password = "b"))
|
||||||
|
val activityScenario = ActivityScenario.launch(AuthActivity::class.java)
|
||||||
|
activityScenario.moveToState(Lifecycle.State.RESUMED)
|
||||||
|
|
||||||
|
val loginRobot = LoginRobot()
|
||||||
|
loginRobot.setupIntentResults()
|
||||||
|
loginRobot
|
||||||
|
.setPassword("b")
|
||||||
|
.setUsername("a")
|
||||||
|
.clickOnLogin()
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
|
||||||
|
activityScenario.safeClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setupLogout(
|
||||||
|
mainDispatcherTestRule: MainDispatcherTestRule,
|
||||||
|
resetIntents: Boolean = true,
|
||||||
|
) {
|
||||||
|
resetIntentsIfNeeded(resetIntents) {
|
||||||
|
val activityScenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
activityScenario.moveToState(Lifecycle.State.RESUMED)
|
||||||
|
|
||||||
|
val homeRobot = HomeRobot()
|
||||||
|
homeRobot.setupIntentResults()
|
||||||
|
homeRobot.clickSignOut()
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
|
||||||
|
activityScenario.safeClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resetIntentsIfNeeded(resetIntents: Boolean, action: () -> Unit) {
|
||||||
|
val wasInitialized = IntentStubberRegistry.isLoaded()
|
||||||
|
if (!wasInitialized) {
|
||||||
|
Intents.init()
|
||||||
|
}
|
||||||
|
action()
|
||||||
|
Intents.release()
|
||||||
|
if (resetIntents && wasInitialized) {
|
||||||
|
Intents.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
package org.fnives.test.showcase.hilt.test.shared.testutils.storage
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Room
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import dagger.hilt.testing.TestInstallIn
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.asExecutor
|
||||||
|
import org.fnives.test.showcase.hilt.di.StorageModule
|
||||||
|
import org.fnives.test.showcase.hilt.storage.LocalDatabase
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reloads the Database module, so it uses the inMemory database with the switched out Executors.
|
||||||
|
*
|
||||||
|
* This is needed so in AndroidTests not a real File based device is used.
|
||||||
|
* This speeds tests up, and isolates them better, there will be no junk in the Database file from previous tests.
|
||||||
|
*/
|
||||||
|
@Module
|
||||||
|
@TestInstallIn(
|
||||||
|
components = [SingletonComponent::class],
|
||||||
|
replaces = [StorageModule::class]
|
||||||
|
)
|
||||||
|
object TestDatabaseInitialization {
|
||||||
|
|
||||||
|
var dispatcher: CoroutineDispatcher? = null
|
||||||
|
|
||||||
|
@Suppress("ObjectPropertyName")
|
||||||
|
private val _dispatcher: CoroutineDispatcher
|
||||||
|
get() = dispatcher ?: throw IllegalStateException("TestDispatcher is not initialized")
|
||||||
|
|
||||||
|
fun create(context: Context, dispatcher: CoroutineDispatcher = this._dispatcher): LocalDatabase {
|
||||||
|
val executor = dispatcher.asExecutor()
|
||||||
|
return Room.inMemoryDatabaseBuilder(context, LocalDatabase::class.java)
|
||||||
|
.setTransactionExecutor(executor)
|
||||||
|
.setQueryExecutor(executor)
|
||||||
|
.allowMainThreadQueries()
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
@Provides
|
||||||
|
fun provideLocalDatabase(@ApplicationContext context: Context): LocalDatabase =
|
||||||
|
create(context)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
package org.fnives.test.showcase.hilt.test.shared.ui
|
||||||
|
|
||||||
|
import dagger.hilt.android.testing.HiltAndroidRule
|
||||||
|
import org.fnives.test.showcase.hilt.test.shared.testutils.idling.NetworkSynchronizationHelper
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@Suppress("UnnecessaryAbstractClass")
|
||||||
|
abstract class NetworkSynchronizedActivityTest {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var networkSynchronizationHelper: NetworkSynchronizationHelper
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val hiltRule = HiltAndroidRule(this)
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
setupBeforeInjection()
|
||||||
|
|
||||||
|
hiltRule.inject()
|
||||||
|
networkSynchronizationHelper.setup()
|
||||||
|
setupAfterInjection()
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun setupBeforeInjection() {
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun setupAfterInjection() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
networkSynchronizationHelper.dispose()
|
||||||
|
additionalTearDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun additionalTearDown() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
package org.fnives.test.showcase.hilt.test.shared.ui.auth
|
||||||
|
|
||||||
|
import androidx.test.core.app.ActivityScenario
|
||||||
|
import androidx.test.espresso.intent.Intents
|
||||||
|
import org.fnives.test.showcase.android.testutil.activity.SafeCloseActivityRule
|
||||||
|
import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule
|
||||||
|
import org.fnives.test.showcase.android.testutil.screenshot.ScreenshotRule
|
||||||
|
import org.fnives.test.showcase.hilt.R
|
||||||
|
import org.fnives.test.showcase.hilt.test.shared.testutils.MockServerScenarioSetupTestRule
|
||||||
|
import org.fnives.test.showcase.hilt.test.shared.testutils.idling.MainDispatcherTestRule
|
||||||
|
import org.fnives.test.showcase.hilt.test.shared.ui.NetworkSynchronizedActivityTest
|
||||||
|
import org.fnives.test.showcase.hilt.ui.auth.AuthActivity
|
||||||
|
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
|
||||||
|
import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.rules.RuleChain
|
||||||
|
|
||||||
|
@Suppress("TestFunctionName")
|
||||||
|
abstract class AuthActivityInstrumentedSharedTest : NetworkSynchronizedActivityTest() {
|
||||||
|
|
||||||
|
private lateinit var activityScenario: ActivityScenario<AuthActivity>
|
||||||
|
|
||||||
|
private val mainDispatcherTestRule = MainDispatcherTestRule()
|
||||||
|
private val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule()
|
||||||
|
private val mockServerScenarioSetup: MockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup
|
||||||
|
private lateinit var robot: LoginRobot
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
@JvmField
|
||||||
|
val ruleOrder: RuleChain = RuleChain.outerRule(DismissSystemDialogsRule())
|
||||||
|
.around(mockServerScenarioSetupTestRule)
|
||||||
|
.around(mainDispatcherTestRule)
|
||||||
|
.around(SafeCloseActivityRule { activityScenario })
|
||||||
|
.around(ScreenshotRule("test-showcase"))
|
||||||
|
|
||||||
|
override fun setupAfterInjection() {
|
||||||
|
Intents.init()
|
||||||
|
robot = LoginRobot()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun additionalTearDown() {
|
||||||
|
Intents.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GIVEN non empty password and username and successful response WHEN signIn THEN no error is shown and navigating to home */
|
||||||
|
@Test
|
||||||
|
fun properLoginResultsInNavigationToHome() {
|
||||||
|
mockServerScenarioSetup.setScenario(
|
||||||
|
AuthScenario.Success(password = "alma", username = "banan")
|
||||||
|
)
|
||||||
|
activityScenario = ActivityScenario.launch(AuthActivity::class.java)
|
||||||
|
robot
|
||||||
|
.setPassword("alma")
|
||||||
|
.setUsername("banan")
|
||||||
|
.assertPassword("alma")
|
||||||
|
.assertUsername("banan")
|
||||||
|
.clickOnLogin()
|
||||||
|
.assertLoadingBeforeRequests()
|
||||||
|
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
robot.assertNavigatedToHome()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GIVEN empty password and username WHEN signIn THEN error password is shown */
|
||||||
|
@Test
|
||||||
|
fun emptyPasswordShowsProperErrorMessage() {
|
||||||
|
activityScenario = ActivityScenario.launch(AuthActivity::class.java)
|
||||||
|
robot
|
||||||
|
.setUsername("banan")
|
||||||
|
.assertUsername("banan")
|
||||||
|
.clickOnLogin()
|
||||||
|
.assertLoadingBeforeRequests()
|
||||||
|
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
robot.assertErrorIsShown(R.string.password_is_invalid)
|
||||||
|
.assertNotNavigatedToHome()
|
||||||
|
.assertNotLoading()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GIVEN password and empty username WHEN signIn THEN error username is shown */
|
||||||
|
@Test
|
||||||
|
fun emptyUserNameShowsProperErrorMessage() {
|
||||||
|
activityScenario = ActivityScenario.launch(AuthActivity::class.java)
|
||||||
|
robot
|
||||||
|
.setPassword("banan")
|
||||||
|
.assertPassword("banan")
|
||||||
|
.clickOnLogin()
|
||||||
|
.assertLoadingBeforeRequests()
|
||||||
|
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
robot.assertErrorIsShown(R.string.username_is_invalid)
|
||||||
|
.assertNotNavigatedToHome()
|
||||||
|
.assertNotLoading()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GIVEN password and username and invalid credentials response WHEN signIn THEN error invalid credentials is shown */
|
||||||
|
@Test
|
||||||
|
fun invalidCredentialsGivenShowsProperErrorMessage() {
|
||||||
|
mockServerScenarioSetup.setScenario(
|
||||||
|
AuthScenario.InvalidCredentials(username = "alma", password = "banan")
|
||||||
|
)
|
||||||
|
activityScenario = ActivityScenario.launch(AuthActivity::class.java)
|
||||||
|
robot
|
||||||
|
.setUsername("alma")
|
||||||
|
.setPassword("banan")
|
||||||
|
.assertUsername("alma")
|
||||||
|
.assertPassword("banan")
|
||||||
|
.clickOnLogin()
|
||||||
|
.assertLoadingBeforeRequests()
|
||||||
|
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
robot.assertErrorIsShown(R.string.credentials_invalid)
|
||||||
|
.assertNotNavigatedToHome()
|
||||||
|
.assertNotLoading()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GIVEN password and username and error response WHEN signIn THEN error invalid credentials is shown */
|
||||||
|
@Test
|
||||||
|
fun networkErrorShowsProperErrorMessage() {
|
||||||
|
mockServerScenarioSetup.setScenario(
|
||||||
|
AuthScenario.GenericError(username = "alma", password = "banan")
|
||||||
|
)
|
||||||
|
activityScenario = ActivityScenario.launch(AuthActivity::class.java)
|
||||||
|
robot
|
||||||
|
.setUsername("alma")
|
||||||
|
.setPassword("banan")
|
||||||
|
.assertUsername("alma")
|
||||||
|
.assertPassword("banan")
|
||||||
|
.clickOnLogin()
|
||||||
|
.assertLoadingBeforeRequests()
|
||||||
|
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
robot.assertErrorIsShown(R.string.something_went_wrong)
|
||||||
|
.assertNotNavigatedToHome()
|
||||||
|
.assertNotLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
package org.fnives.test.showcase.hilt.test.shared.ui.auth
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.Instrumentation
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.test.espresso.Espresso.onView
|
||||||
|
import androidx.test.espresso.action.ViewActions
|
||||||
|
import androidx.test.espresso.assertion.ViewAssertions
|
||||||
|
import androidx.test.espresso.intent.Intents
|
||||||
|
import androidx.test.espresso.intent.Intents.intended
|
||||||
|
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||||
|
import org.fnives.test.showcase.android.testutil.intent.notIntended
|
||||||
|
import org.fnives.test.showcase.android.testutil.snackbar.SnackbarVerificationHelper.assertSnackBarIsNotShown
|
||||||
|
import org.fnives.test.showcase.android.testutil.snackbar.SnackbarVerificationHelper.assertSnackBarIsShownWithText
|
||||||
|
import org.fnives.test.showcase.android.testutil.viewaction.progressbar.ReplaceProgressBarDrawableToStatic
|
||||||
|
import org.fnives.test.showcase.hilt.R
|
||||||
|
import org.fnives.test.showcase.hilt.ui.home.MainActivity
|
||||||
|
import org.hamcrest.core.IsNot.not
|
||||||
|
|
||||||
|
class LoginRobot {
|
||||||
|
|
||||||
|
fun setupIntentResults() {
|
||||||
|
Intents.intending(hasComponent(MainActivity::class.java.canonicalName))
|
||||||
|
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Needed because Espresso idling waits until mainThread is idle.
|
||||||
|
*
|
||||||
|
* However, ProgressBar keeps the main thread active since it's animating.
|
||||||
|
*
|
||||||
|
* Another solution is described here: https://proandroiddev.com/progressbar-animations-with-espresso-57f826102187
|
||||||
|
* In short they replace the inflater to remove animations, by using custom test runner.
|
||||||
|
*/
|
||||||
|
fun replaceProgressBar() = apply {
|
||||||
|
onView(withId(R.id.loading_indicator)).perform(ReplaceProgressBarDrawableToStatic())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setUsername(username: String): LoginRobot = apply {
|
||||||
|
onView(withId(R.id.user_edit_text))
|
||||||
|
.perform(ViewActions.replaceText(username), ViewActions.closeSoftKeyboard())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPassword(password: String): LoginRobot = apply {
|
||||||
|
onView(withId(R.id.password_edit_text))
|
||||||
|
.perform(ViewActions.replaceText(password), ViewActions.closeSoftKeyboard())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clickOnLogin() = apply {
|
||||||
|
replaceProgressBar()
|
||||||
|
onView(withId(R.id.login_cta))
|
||||||
|
.perform(ViewActions.click())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertPassword(password: String) = apply {
|
||||||
|
onView(withId((R.id.password_edit_text)))
|
||||||
|
.check(ViewAssertions.matches(ViewMatchers.withText(password)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertUsername(username: String) = apply {
|
||||||
|
onView(withId((R.id.user_edit_text)))
|
||||||
|
.check(ViewAssertions.matches(ViewMatchers.withText(username)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertErrorIsShown(@StringRes stringResID: Int) = apply {
|
||||||
|
assertSnackBarIsShownWithText(stringResID)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertLoadingBeforeRequests() = apply {
|
||||||
|
onView(withId(R.id.loading_indicator))
|
||||||
|
.check(ViewAssertions.matches(isDisplayed()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertNotLoading() = apply {
|
||||||
|
onView(withId(R.id.loading_indicator))
|
||||||
|
.check(ViewAssertions.matches(not(isDisplayed())))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertErrorIsNotShown() = apply {
|
||||||
|
assertSnackBarIsNotShown()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertNavigatedToHome() = apply {
|
||||||
|
intended(hasComponent(MainActivity::class.java.canonicalName))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertNotNavigatedToHome() = apply {
|
||||||
|
notIntended(hasComponent(MainActivity::class.java.canonicalName))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
package org.fnives.test.showcase.hilt.test.shared.ui.home
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.Instrumentation
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import androidx.test.espresso.Espresso
|
||||||
|
import androidx.test.espresso.action.ViewActions.click
|
||||||
|
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||||
|
import androidx.test.espresso.contrib.RecyclerViewActions
|
||||||
|
import androidx.test.espresso.intent.Intents
|
||||||
|
import androidx.test.espresso.intent.matcher.IntentMatchers
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers.hasChildCount
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers.withChild
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers.withParent
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||||
|
import org.fnives.test.showcase.android.testutil.intent.notIntended
|
||||||
|
import org.fnives.test.showcase.android.testutil.viewaction.imageview.WithDrawable
|
||||||
|
import org.fnives.test.showcase.android.testutil.viewaction.recycler.RemoveItemAnimations
|
||||||
|
import org.fnives.test.showcase.android.testutil.viewaction.swiperefresh.PullToRefresh
|
||||||
|
import org.fnives.test.showcase.hilt.R
|
||||||
|
import org.fnives.test.showcase.hilt.ui.auth.AuthActivity
|
||||||
|
import org.fnives.test.showcase.model.content.Content
|
||||||
|
import org.fnives.test.showcase.model.content.FavouriteContent
|
||||||
|
import org.hamcrest.Matchers.allOf
|
||||||
|
|
||||||
|
class HomeRobot {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Needed because Espresso idling sometimes not in sync with RecyclerView's animation.
|
||||||
|
* So we simply remove the item animations, the animations should be disabled anyway for test.
|
||||||
|
*
|
||||||
|
* Reference: https://github.com/android/android-test/issues/223
|
||||||
|
*/
|
||||||
|
fun removeItemAnimations() = apply {
|
||||||
|
Espresso.onView(withId(R.id.recycler)).perform(RemoveItemAnimations())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertToolbarIsShown() = apply {
|
||||||
|
Espresso.onView(withId(R.id.toolbar))
|
||||||
|
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setupIntentResults() {
|
||||||
|
Intents.intending(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
|
||||||
|
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertNavigatedToAuth() = apply {
|
||||||
|
Intents.intended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertDidNotNavigateToAuth() = apply {
|
||||||
|
notIntended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clickSignOut(setupIntentResults: Boolean = true) = apply {
|
||||||
|
if (setupIntentResults) {
|
||||||
|
Intents.intending(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
|
||||||
|
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent()))
|
||||||
|
}
|
||||||
|
|
||||||
|
Espresso.onView(withId(R.id.logout_cta)).perform(click())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertContainsItem(index: Int, item: FavouriteContent) = apply {
|
||||||
|
removeItemAnimations()
|
||||||
|
val isFavouriteResourceId = if (item.isFavourite) {
|
||||||
|
R.drawable.favorite_24
|
||||||
|
} else {
|
||||||
|
R.drawable.favorite_border_24
|
||||||
|
}
|
||||||
|
Espresso.onView(withId(R.id.recycler))
|
||||||
|
.perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
|
||||||
|
|
||||||
|
Espresso.onView(
|
||||||
|
allOf(
|
||||||
|
withChild(allOf(withText(item.content.title), withId(R.id.title))),
|
||||||
|
withChild(allOf(withText(item.content.description), withId(R.id.description))),
|
||||||
|
withChild(allOf(withId(R.id.favourite_cta), WithDrawable(isFavouriteResourceId)))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clickOnContentItem(index: Int, item: Content) = apply {
|
||||||
|
removeItemAnimations()
|
||||||
|
Espresso.onView(withId(R.id.recycler))
|
||||||
|
.perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
|
||||||
|
|
||||||
|
Espresso.onView(
|
||||||
|
allOf(
|
||||||
|
withId(R.id.favourite_cta),
|
||||||
|
withParent(
|
||||||
|
allOf(
|
||||||
|
withChild(allOf(withText(item.title), withId(R.id.title))),
|
||||||
|
withChild(allOf(withText(item.description), withId(R.id.description)))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.perform(click())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun swipeRefresh() = apply {
|
||||||
|
Espresso.onView(withId(R.id.swipe_refresh_layout)).perform(PullToRefresh())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertContainsNoItems() = apply {
|
||||||
|
removeItemAnimations()
|
||||||
|
Espresso.onView(withId(R.id.recycler))
|
||||||
|
.check(matches(hasChildCount(0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertContainsError() = apply {
|
||||||
|
Espresso.onView(withId(R.id.error_message))
|
||||||
|
.check(matches(allOf(isDisplayed(), withText(R.string.something_went_wrong))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,214 @@
|
||||||
|
package org.fnives.test.showcase.hilt.test.shared.ui.home
|
||||||
|
|
||||||
|
import androidx.test.core.app.ActivityScenario
|
||||||
|
import androidx.test.espresso.intent.Intents
|
||||||
|
import org.fnives.test.showcase.android.testutil.activity.SafeCloseActivityRule
|
||||||
|
import org.fnives.test.showcase.android.testutil.activity.safeClose
|
||||||
|
import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule
|
||||||
|
import org.fnives.test.showcase.android.testutil.screenshot.ScreenshotRule
|
||||||
|
import org.fnives.test.showcase.android.testutil.synchronization.loopMainThreadFor
|
||||||
|
import org.fnives.test.showcase.hilt.test.shared.testutils.MockServerScenarioSetupTestRule
|
||||||
|
import org.fnives.test.showcase.hilt.test.shared.testutils.idling.AsyncDiffUtilInstantTestRule
|
||||||
|
import org.fnives.test.showcase.hilt.test.shared.testutils.idling.MainDispatcherTestRule
|
||||||
|
import org.fnives.test.showcase.hilt.test.shared.testutils.statesetup.SetupAuthenticationState.setupLogin
|
||||||
|
import org.fnives.test.showcase.hilt.test.shared.ui.NetworkSynchronizedActivityTest
|
||||||
|
import org.fnives.test.showcase.hilt.ui.home.MainActivity
|
||||||
|
import org.fnives.test.showcase.model.content.FavouriteContent
|
||||||
|
import org.fnives.test.showcase.network.mockserver.ContentData
|
||||||
|
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
|
||||||
|
import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScenario
|
||||||
|
import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshTokenScenario
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.rules.RuleChain
|
||||||
|
|
||||||
|
@Suppress("TestFunctionName")
|
||||||
|
abstract class MainActivityInstrumentedSharedTest : NetworkSynchronizedActivityTest() {
|
||||||
|
|
||||||
|
private lateinit var activityScenario: ActivityScenario<MainActivity>
|
||||||
|
|
||||||
|
private val mainDispatcherTestRule = MainDispatcherTestRule()
|
||||||
|
private val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule()
|
||||||
|
private val mockServerScenarioSetup: MockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup
|
||||||
|
private lateinit var robot: HomeRobot
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
@JvmField
|
||||||
|
val ruleOrder: RuleChain = RuleChain.outerRule(DismissSystemDialogsRule())
|
||||||
|
.around(mockServerScenarioSetupTestRule)
|
||||||
|
.around(mainDispatcherTestRule)
|
||||||
|
.around(AsyncDiffUtilInstantTestRule())
|
||||||
|
.around(SafeCloseActivityRule { activityScenario })
|
||||||
|
.around(ScreenshotRule("test-showcase"))
|
||||||
|
|
||||||
|
override fun setupAfterInjection() {
|
||||||
|
super.setupAfterInjection()
|
||||||
|
robot = HomeRobot()
|
||||||
|
setupLogin(mainDispatcherTestRule, mockServerScenarioSetup)
|
||||||
|
Intents.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun additionalTearDown() {
|
||||||
|
super.additionalTearDown()
|
||||||
|
Intents.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GIVEN initialized MainActivity WHEN signout is clicked THEN user is signed out */
|
||||||
|
@Test
|
||||||
|
fun signOutClickedResultsInNavigation() {
|
||||||
|
mockServerScenarioSetup.setScenario(ContentScenario.Error(usingRefreshedToken = false))
|
||||||
|
activityScenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
|
||||||
|
robot.clickSignOut()
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
|
||||||
|
robot.assertNavigatedToAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GIVEN success response WHEN data is returned THEN it is shown on the ui */
|
||||||
|
@Test
|
||||||
|
fun successfulDataLoadingShowsTheElementsOnTheUI() {
|
||||||
|
mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false))
|
||||||
|
activityScenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
ContentData.contentSuccess.forEachIndexed { index, content ->
|
||||||
|
robot.assertContainsItem(index, FavouriteContent(content, false))
|
||||||
|
}
|
||||||
|
robot.assertDidNotNavigateToAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GIVEN success response WHEN item is clicked THEN ui is updated */
|
||||||
|
@Test
|
||||||
|
fun clickingOnListElementUpdatesTheElementsFavouriteState() {
|
||||||
|
mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false))
|
||||||
|
activityScenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
robot.clickOnContentItem(0, ContentData.contentSuccess.first())
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
|
||||||
|
val expectedItem = FavouriteContent(ContentData.contentSuccess.first(), true)
|
||||||
|
robot.assertContainsItem(0, expectedItem)
|
||||||
|
.assertDidNotNavigateToAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GIVEN success response WHEN item is clicked THEN ui is updated even if activity is recreated */
|
||||||
|
@Test
|
||||||
|
fun elementFavouritedIsKeptEvenIfActivityIsRecreated() {
|
||||||
|
mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false))
|
||||||
|
activityScenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
robot.clickOnContentItem(0, ContentData.contentSuccess.first())
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
|
||||||
|
val expectedItem = FavouriteContent(ContentData.contentSuccess.first(), true)
|
||||||
|
|
||||||
|
activityScenario.safeClose()
|
||||||
|
activityScenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
|
||||||
|
robot.assertContainsItem(0, expectedItem)
|
||||||
|
.assertDidNotNavigateToAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GIVEN success response WHEN item is clicked then clicked again THEN ui is updated */
|
||||||
|
@Test
|
||||||
|
fun clickingAnElementMultipleTimesProperlyUpdatesIt() {
|
||||||
|
mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false))
|
||||||
|
activityScenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
robot.clickOnContentItem(0, ContentData.contentSuccess.first())
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
robot.clickOnContentItem(0, ContentData.contentSuccess.first())
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
|
||||||
|
val expectedItem = FavouriteContent(ContentData.contentSuccess.first(), false)
|
||||||
|
robot.assertContainsItem(0, expectedItem)
|
||||||
|
.assertDidNotNavigateToAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GIVEN error response WHEN loaded THEN error is Shown */
|
||||||
|
@Test
|
||||||
|
fun networkErrorResultsInUIErrorStateShown() {
|
||||||
|
mockServerScenarioSetup.setScenario(ContentScenario.Error(usingRefreshedToken = false))
|
||||||
|
activityScenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
|
||||||
|
robot.assertContainsNoItems()
|
||||||
|
.assertContainsError()
|
||||||
|
.assertDidNotNavigateToAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GIVEN error response then success WHEN retried THEN success is shown */
|
||||||
|
@Test
|
||||||
|
fun retryingFromErrorStateAndSucceedingShowsTheData() {
|
||||||
|
mockServerScenarioSetup.setScenario(
|
||||||
|
ContentScenario.Error(usingRefreshedToken = false)
|
||||||
|
.then(ContentScenario.Success(usingRefreshedToken = false))
|
||||||
|
)
|
||||||
|
activityScenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
|
||||||
|
robot.swipeRefresh()
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
loopMainThreadFor(2000L)
|
||||||
|
|
||||||
|
ContentData.contentSuccess.forEachIndexed { index, content ->
|
||||||
|
robot.assertContainsItem(index, FavouriteContent(content, false))
|
||||||
|
}
|
||||||
|
robot.assertDidNotNavigateToAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GIVEN success then error WHEN retried THEN error is shown */
|
||||||
|
@Test
|
||||||
|
fun errorIsShownIfTheDataIsFetchedAndErrorIsReceived() {
|
||||||
|
mockServerScenarioSetup.setScenario(
|
||||||
|
ContentScenario.Success(usingRefreshedToken = false)
|
||||||
|
.then(ContentScenario.Error(usingRefreshedToken = false))
|
||||||
|
)
|
||||||
|
activityScenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
|
||||||
|
robot.swipeRefresh()
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
|
||||||
|
robot
|
||||||
|
.assertContainsError()
|
||||||
|
.assertContainsNoItems()
|
||||||
|
.assertDidNotNavigateToAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GIVEN unauthenticated then success WHEN loaded THEN success is shown */
|
||||||
|
@Test
|
||||||
|
fun authenticationIsHandledWithASingleLoading() {
|
||||||
|
mockServerScenarioSetup.setScenario(
|
||||||
|
ContentScenario.Unauthorized(usingRefreshedToken = false)
|
||||||
|
.then(ContentScenario.Success(usingRefreshedToken = true))
|
||||||
|
)
|
||||||
|
.setScenario(RefreshTokenScenario.Success)
|
||||||
|
|
||||||
|
activityScenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
|
||||||
|
ContentData.contentSuccess.forEachIndexed { index, content ->
|
||||||
|
robot.assertContainsItem(index, FavouriteContent(content, false))
|
||||||
|
}
|
||||||
|
robot.assertDidNotNavigateToAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GIVEN unauthenticated then error WHEN loaded THEN navigated to auth */
|
||||||
|
@Test
|
||||||
|
fun sessionExpirationResultsInNavigation() {
|
||||||
|
mockServerScenarioSetup.setScenario(ContentScenario.Unauthorized(usingRefreshedToken = false))
|
||||||
|
.setScenario(RefreshTokenScenario.Error)
|
||||||
|
|
||||||
|
activityScenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
|
||||||
|
robot.assertNavigatedToAuth()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
package org.fnives.test.showcase.hilt.test.shared.ui.splash
|
||||||
|
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.test.core.app.ActivityScenario
|
||||||
|
import androidx.test.espresso.intent.Intents
|
||||||
|
import org.fnives.test.showcase.android.testutil.activity.SafeCloseActivityRule
|
||||||
|
import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule
|
||||||
|
import org.fnives.test.showcase.android.testutil.screenshot.ScreenshotRule
|
||||||
|
import org.fnives.test.showcase.hilt.test.shared.testutils.MockServerScenarioSetupTestRule
|
||||||
|
import org.fnives.test.showcase.hilt.test.shared.testutils.idling.MainDispatcherTestRule
|
||||||
|
import org.fnives.test.showcase.hilt.test.shared.testutils.statesetup.SetupAuthenticationState.setupLogin
|
||||||
|
import org.fnives.test.showcase.hilt.test.shared.testutils.statesetup.SetupAuthenticationState.setupLogout
|
||||||
|
import org.fnives.test.showcase.hilt.test.shared.ui.NetworkSynchronizedActivityTest
|
||||||
|
import org.fnives.test.showcase.hilt.ui.splash.SplashActivity
|
||||||
|
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.rules.RuleChain
|
||||||
|
|
||||||
|
@Suppress("TestFunctionName")
|
||||||
|
abstract class SplashActivityInstrumentedSharedTest : NetworkSynchronizedActivityTest() {
|
||||||
|
|
||||||
|
private lateinit var activityScenario: ActivityScenario<SplashActivity>
|
||||||
|
|
||||||
|
private val mainDispatcherTestRule = MainDispatcherTestRule()
|
||||||
|
private val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule()
|
||||||
|
private val mockServerScenarioSetup: MockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup
|
||||||
|
|
||||||
|
private lateinit var robot: SplashRobot
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
@JvmField
|
||||||
|
val ruleOrder: RuleChain = RuleChain.outerRule(DismissSystemDialogsRule())
|
||||||
|
.around(mainDispatcherTestRule)
|
||||||
|
.around(mockServerScenarioSetupTestRule)
|
||||||
|
.around(SafeCloseActivityRule { activityScenario })
|
||||||
|
.around(ScreenshotRule("test-showcase"))
|
||||||
|
|
||||||
|
override fun setupAfterInjection() {
|
||||||
|
Intents.init()
|
||||||
|
robot = SplashRobot()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun additionalTearDown() {
|
||||||
|
Intents.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GIVEN loggedInState WHEN opened after some time THEN MainActivity is started */
|
||||||
|
@Test
|
||||||
|
fun loggedInStateNavigatesToHome() {
|
||||||
|
setupLogin(mainDispatcherTestRule, mockServerScenarioSetup)
|
||||||
|
|
||||||
|
activityScenario = ActivityScenario.launch(SplashActivity::class.java)
|
||||||
|
activityScenario.moveToState(Lifecycle.State.RESUMED)
|
||||||
|
|
||||||
|
mainDispatcherTestRule.advanceTimeBy(501)
|
||||||
|
|
||||||
|
robot.assertHomeIsStarted()
|
||||||
|
.assertAuthIsNotStarted()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GIVEN loggedOffState WHEN opened after some time THEN AuthActivity is started */
|
||||||
|
@Test
|
||||||
|
fun loggedOutStatesNavigatesToAuthentication() {
|
||||||
|
setupLogout(mainDispatcherTestRule)
|
||||||
|
activityScenario = ActivityScenario.launch(SplashActivity::class.java)
|
||||||
|
activityScenario.moveToState(Lifecycle.State.RESUMED)
|
||||||
|
|
||||||
|
mainDispatcherTestRule.advanceTimeBy(501)
|
||||||
|
|
||||||
|
robot.assertAuthIsStarted()
|
||||||
|
.assertHomeIsNotStarted()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GIVEN loggedOffState and not enough time WHEN opened THEN no activity is started */
|
||||||
|
@Test
|
||||||
|
fun loggedOutStatesNotEnoughTime() {
|
||||||
|
setupLogout(mainDispatcherTestRule)
|
||||||
|
activityScenario = ActivityScenario.launch(SplashActivity::class.java)
|
||||||
|
activityScenario.moveToState(Lifecycle.State.RESUMED)
|
||||||
|
|
||||||
|
mainDispatcherTestRule.advanceTimeBy(500)
|
||||||
|
|
||||||
|
robot.assertAuthIsNotStarted()
|
||||||
|
.assertHomeIsNotStarted()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GIVEN loggedInState and not enough time WHEN opened THEN no activity is started */
|
||||||
|
@Test
|
||||||
|
fun loggedInStatesNotEnoughTime() {
|
||||||
|
setupLogin(mainDispatcherTestRule, mockServerScenarioSetup)
|
||||||
|
|
||||||
|
activityScenario = ActivityScenario.launch(SplashActivity::class.java)
|
||||||
|
activityScenario.moveToState(Lifecycle.State.RESUMED)
|
||||||
|
|
||||||
|
mainDispatcherTestRule.advanceTimeBy(500)
|
||||||
|
|
||||||
|
robot.assertHomeIsNotStarted()
|
||||||
|
.assertAuthIsNotStarted()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
package org.fnives.test.showcase.hilt.test.shared.ui.splash
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.Instrumentation
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.test.espresso.intent.Intents
|
||||||
|
import androidx.test.espresso.intent.matcher.IntentMatchers
|
||||||
|
import org.fnives.test.showcase.android.testutil.intent.notIntended
|
||||||
|
import org.fnives.test.showcase.hilt.ui.auth.AuthActivity
|
||||||
|
import org.fnives.test.showcase.hilt.ui.home.MainActivity
|
||||||
|
|
||||||
|
class SplashRobot {
|
||||||
|
|
||||||
|
fun setupIntentResults() {
|
||||||
|
Intents.intending(IntentMatchers.hasComponent(MainActivity::class.java.canonicalName))
|
||||||
|
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent()))
|
||||||
|
Intents.intending(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
|
||||||
|
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertHomeIsStarted() = apply {
|
||||||
|
Intents.intended(IntentMatchers.hasComponent(MainActivity::class.java.canonicalName))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertHomeIsNotStarted() = apply {
|
||||||
|
notIntended(IntentMatchers.hasComponent(MainActivity::class.java.canonicalName))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertAuthIsStarted() = apply {
|
||||||
|
Intents.intended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertAuthIsNotStarted() = apply {
|
||||||
|
notIntended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
|
||||||
|
}
|
||||||
|
}
|
||||||
1
hilt/hilt-app/.gitignore
vendored
Normal file
1
hilt/hilt-app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/build
|
||||||
127
hilt/hilt-app/build.gradle
Normal file
127
hilt/hilt-app/build.gradle
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
plugins {
|
||||||
|
id 'com.android.application'
|
||||||
|
id 'kotlin-android'
|
||||||
|
id 'kotlin-kapt'
|
||||||
|
id 'dagger.hilt.android.plugin'
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdk 31
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "org.fnives.test.showcase.hilt"
|
||||||
|
minSdk 21
|
||||||
|
targetSdk 31
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0"
|
||||||
|
buildConfigField "String", "BASE_URL", '"https://606844a10add49001733fe6b.mockapi.io/"'
|
||||||
|
|
||||||
|
kapt {
|
||||||
|
arguments {
|
||||||
|
arg("room.schemaLocation", "$projectDir/schemas")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testInstrumentationRunner "org.fnives.test.showcase.hilt.runner.HiltTestRunner"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flavorDimensions 'di'
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
viewBinding true
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion = project.androidx_compose_version
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
test {
|
||||||
|
java.srcDirs += "src/robolectricTest/java"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// needed for androidTest
|
||||||
|
packagingOptions {
|
||||||
|
exclude 'META-INF/LGPL2.1'
|
||||||
|
exclude 'META-INF/AL2.0'
|
||||||
|
exclude 'META-INF/LICENSE.md'
|
||||||
|
exclude 'META-INF/LICENSE-notice.md'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hilt {
|
||||||
|
enableAggregatingTask = true
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEvaluate {
|
||||||
|
// making sure the :mockserver is assembled after :clean when running tests
|
||||||
|
testDebugUnitTest.dependsOn tasks.getByPath(':mockserver:assemble')
|
||||||
|
testReleaseUnitTest.dependsOn tasks.getByPath(':mockserver:assemble')
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation "androidx.core:core-ktx:$androidx_core_version"
|
||||||
|
implementation "androidx.appcompat:appcompat:$androidx_appcompat_version"
|
||||||
|
implementation "com.google.android.material:material:$androidx_material_version"
|
||||||
|
implementation "androidx.constraintlayout:constraintlayout:$androidx_constraintlayout_version"
|
||||||
|
implementation "androidx.constraintlayout:constraintlayout-compose:$androidx_compose_constraintlayout_version"
|
||||||
|
implementation "androidx.lifecycle:lifecycle-livedata-core-ktx:$androidx_livedata_version"
|
||||||
|
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$androidx_livedata_version"
|
||||||
|
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$androidx_livedata_version"
|
||||||
|
implementation "androidx.swiperefreshlayout:swiperefreshlayout:$androidx_swiperefreshlayout_version"
|
||||||
|
|
||||||
|
implementation "androidx.activity:activity-compose:$activity_ktx_version"
|
||||||
|
implementation "androidx.navigation:navigation-compose:$androidx_navigation"
|
||||||
|
|
||||||
|
implementation "androidx.compose.ui:ui:$androidx_compose_version"
|
||||||
|
implementation "androidx.compose.ui:ui-tooling:$androidx_compose_version"
|
||||||
|
implementation "androidx.compose.foundation:foundation:$androidx_compose_version"
|
||||||
|
implementation "androidx.compose.material:material:$androidx_compose_version"
|
||||||
|
implementation "androidx.compose.animation:animation-graphics:$androidx_compose_version"
|
||||||
|
implementation "com.google.accompanist:accompanist-insets:$google_accompanist_version"
|
||||||
|
implementation "com.google.accompanist:accompanist-swiperefresh:$google_accompanist_version"
|
||||||
|
|
||||||
|
// Hilt
|
||||||
|
implementation "com.google.dagger:hilt-android:$hilt_version"
|
||||||
|
kapt "com.google.dagger:hilt-compiler:$hilt_version"
|
||||||
|
|
||||||
|
implementation "androidx.room:room-runtime:$room_version"
|
||||||
|
kapt "androidx.room:room-compiler:$room_version"
|
||||||
|
implementation "androidx.room:room-ktx:$room_version"
|
||||||
|
|
||||||
|
implementation "io.coil-kt:coil:$coil_version"
|
||||||
|
implementation "io.coil-kt:coil-compose:$coil_version"
|
||||||
|
|
||||||
|
implementation project(":hilt:hilt-core")
|
||||||
|
|
||||||
|
applyAppTestDependenciesTo(this)
|
||||||
|
applyComposeTestDependenciesTo(this)
|
||||||
|
|
||||||
|
androidTestImplementation project(':mockserver')
|
||||||
|
|
||||||
|
testImplementation project(':test-util-junit5-android')
|
||||||
|
testImplementation project(':test-util-shared-robolectric')
|
||||||
|
testImplementation project(':test-util-android')
|
||||||
|
androidTestImplementation project(':test-util-android')
|
||||||
|
androidTestImplementation project(':test-util-shared-android')
|
||||||
|
|
||||||
|
testImplementation testFixtures(project(":hilt:hilt-core"))
|
||||||
|
androidTestImplementation testFixtures(project(":hilt:hilt-core"))
|
||||||
|
|
||||||
|
testImplementation project(':hilt:hilt-app-shared-test')
|
||||||
|
androidTestImplementation project(':hilt:hilt-app-shared-test')
|
||||||
|
|
||||||
|
testImplementation "com.google.dagger:hilt-android-testing:$hilt_version"
|
||||||
|
kaptTest "com.google.dagger:hilt-compiler:$hilt_version"
|
||||||
|
androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version"
|
||||||
|
kaptAndroidTest "com.google.dagger:hilt-compiler:$hilt_version"
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: '../../gradlescripts/pull-screenshots.gradle'
|
||||||
0
hilt/hilt-app/consumer-rules.pro
Normal file
0
hilt/hilt-app/consumer-rules.pro
Normal file
21
hilt/hilt-app/proguard-rules.pro
vendored
Normal file
21
hilt/hilt-app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 1,
|
||||||
|
"identityHash": "36d840e89667f36e0c265593da36fe23",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "FavouriteEntity",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contentId` TEXT NOT NULL, PRIMARY KEY(`contentId`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "contentId",
|
||||||
|
"columnName": "contentId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"contentId"
|
||||||
|
],
|
||||||
|
"autoGenerate": false
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"views": [],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '36d840e89667f36e0c265593da36fe23')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 2,
|
||||||
|
"identityHash": "3723fe73a9d3dc43de8ff3e52ec46490",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "FavouriteEntity",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`content_id` TEXT NOT NULL, PRIMARY KEY(`content_id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "contentId",
|
||||||
|
"columnName": "content_id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"content_id"
|
||||||
|
],
|
||||||
|
"autoGenerate": false
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"views": [],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3723fe73a9d3dc43de8ff3e52ec46490')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
package org.fnives.test.showcase.hilt.di
|
||||||
|
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import dagger.hilt.testing.TestInstallIn
|
||||||
|
import org.fnives.test.showcase.hilt.network.di.BindsBaseOkHttpClient
|
||||||
|
import org.fnives.test.showcase.hilt.network.di.SessionLessQualifier
|
||||||
|
import org.fnives.test.showcase.hilt.network.shared.PlatformInterceptor
|
||||||
|
import org.fnives.test.showcase.hilt.network.testutil.HttpsConfigurationModuleTemplate
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@TestInstallIn(
|
||||||
|
components = [SingletonComponent::class],
|
||||||
|
replaces = [BindsBaseOkHttpClient::class]
|
||||||
|
)
|
||||||
|
object HttpsConfigurationModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
@SessionLessQualifier
|
||||||
|
fun bindsBaseOkHttpClient(enableLogging: Boolean, platformInterceptor: PlatformInterceptor) =
|
||||||
|
HttpsConfigurationModuleTemplate.bindsBaseOkHttpClient(enableLogging, platformInterceptor)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
package org.fnives.test.showcase.hilt.di
|
||||||
|
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import dagger.hilt.testing.TestInstallIn
|
||||||
|
import org.fnives.test.showcase.hilt.test.shared.di.TestBaseUrlHolder
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@TestInstallIn(
|
||||||
|
components = [SingletonComponent::class],
|
||||||
|
replaces = [BaseUrlModule::class]
|
||||||
|
)
|
||||||
|
object TestBaseUrlModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideBaseUrl(): String = TestBaseUrlHolder.url
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
package org.fnives.test.showcase.hilt.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import dagger.hilt.testing.TestInstallIn
|
||||||
|
import org.fnives.test.showcase.hilt.storage.LocalDatabase
|
||||||
|
import org.fnives.test.showcase.hilt.test.shared.testutils.storage.TestDatabaseInitialization
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@TestInstallIn(
|
||||||
|
components = [SingletonComponent::class],
|
||||||
|
replaces = [StorageModule::class]
|
||||||
|
)
|
||||||
|
object TestDatabaseInitializationModule {
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
@Provides
|
||||||
|
fun provideLocalDatabase(@ApplicationContext context: Context): LocalDatabase =
|
||||||
|
TestDatabaseInitialization.provideLocalDatabase(context)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
package org.fnives.test.showcase.hilt.di
|
||||||
|
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import dagger.hilt.testing.TestInstallIn
|
||||||
|
import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage
|
||||||
|
import org.fnives.test.showcase.hilt.storage.SharedPreferencesManagerImpl
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@TestInstallIn(
|
||||||
|
components = [SingletonComponent::class],
|
||||||
|
replaces = [UserDataLocalStorageModule::class]
|
||||||
|
)
|
||||||
|
object TestUserDataLocalStorageModule {
|
||||||
|
|
||||||
|
var replacement: UserDataLocalStorage? = null
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
@Provides
|
||||||
|
fun provideUserDataLocalStorage(
|
||||||
|
sharedPreferencesManagerImpl: SharedPreferencesManagerImpl,
|
||||||
|
): UserDataLocalStorage = replacement ?: sharedPreferencesManagerImpl
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package org.fnives.test.showcase.hilt.runner
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.test.runner.AndroidJUnitRunner
|
||||||
|
import dagger.hilt.android.testing.HiltTestApplication
|
||||||
|
|
||||||
|
class HiltTestRunner : AndroidJUnitRunner() {
|
||||||
|
|
||||||
|
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application =
|
||||||
|
super.newApplication(cl, HiltTestApplication::class.java.name, context)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package org.fnives.test.showcase.hilt.storage.migration
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import org.fnives.test.showcase.hilt.test.shared.storage.migration.MigrationToLatestInstrumentedSharedTest
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class MigrationToLatestInstrumentedTest : MigrationToLatestInstrumentedSharedTest()
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
package org.fnives.test.showcase.hilt.ui.auth
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import dagger.hilt.android.testing.HiltAndroidTest
|
||||||
|
import org.fnives.test.showcase.hilt.test.shared.ui.auth.AuthActivityInstrumentedSharedTest
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@HiltAndroidTest
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class AuthActivityInstrumentedTest : AuthActivityInstrumentedSharedTest()
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
package org.fnives.test.showcase.hilt.ui.compose
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.junit4.StateRestorationTester
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import dagger.hilt.android.testing.HiltAndroidRule
|
||||||
|
import dagger.hilt.android.testing.HiltAndroidTest
|
||||||
|
import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule
|
||||||
|
import org.fnives.test.showcase.android.testutil.screenshot.ScreenshotRule
|
||||||
|
import org.fnives.test.showcase.hilt.R
|
||||||
|
import org.fnives.test.showcase.hilt.compose.screen.AppNavigation
|
||||||
|
import org.fnives.test.showcase.hilt.core.integration.fake.FakeUserDataLocalStorage
|
||||||
|
import org.fnives.test.showcase.hilt.di.TestUserDataLocalStorageModule
|
||||||
|
import org.fnives.test.showcase.hilt.test.shared.testutils.MockServerScenarioSetupTestRule
|
||||||
|
import org.fnives.test.showcase.hilt.test.shared.testutils.idling.DatabaseDispatcherTestRule
|
||||||
|
import org.fnives.test.showcase.hilt.ui.compose.idle.ComposeNetworkSyncHelper
|
||||||
|
import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.rules.RuleChain
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltAndroidTest
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class AuthComposeInstrumentedTest {
|
||||||
|
|
||||||
|
private val composeTestRule = createComposeRule()
|
||||||
|
private val stateRestorationTester = StateRestorationTester(composeTestRule)
|
||||||
|
|
||||||
|
private val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule()
|
||||||
|
private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup
|
||||||
|
private val dispatcherTestRule = DatabaseDispatcherTestRule()
|
||||||
|
private lateinit var robot: ComposeLoginRobot
|
||||||
|
private lateinit var navigationRobot: ComposeNavigationRobot
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var composeNetworkSyncHelper: ComposeNetworkSyncHelper
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val hiltRule = HiltAndroidRule(this)
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
@JvmField
|
||||||
|
val ruleOrder: RuleChain = RuleChain.outerRule(DismissSystemDialogsRule())
|
||||||
|
.around(mockServerScenarioSetupTestRule)
|
||||||
|
.around(dispatcherTestRule)
|
||||||
|
.around(composeTestRule)
|
||||||
|
.around(ScreenshotRule("test-showcase-compose"))
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
TestUserDataLocalStorageModule.replacement = FakeUserDataLocalStorage()
|
||||||
|
hiltRule.inject()
|
||||||
|
|
||||||
|
stateRestorationTester.setContent {
|
||||||
|
AppNavigation()
|
||||||
|
}
|
||||||
|
robot = ComposeLoginRobot(composeTestRule)
|
||||||
|
navigationRobot = ComposeNavigationRobot(composeTestRule)
|
||||||
|
composeNetworkSyncHelper.setup(composeTestRule)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
composeNetworkSyncHelper.tearDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GIVEN non empty password and username and successful response WHEN signIn THEN no error is shown and navigating to home */
|
||||||
|
@Test
|
||||||
|
fun properLoginResultsInNavigationToHome() {
|
||||||
|
mockServerScenarioSetup.setScenario(
|
||||||
|
AuthScenario.Success(password = "alma", username = "banan")
|
||||||
|
)
|
||||||
|
composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY)
|
||||||
|
|
||||||
|
navigationRobot.assertAuthScreen()
|
||||||
|
robot.setPassword("alma")
|
||||||
|
.setUsername("banan")
|
||||||
|
.assertUsername("banan")
|
||||||
|
.assertPassword("alma")
|
||||||
|
|
||||||
|
composeTestRule.mainClock.autoAdvance = false
|
||||||
|
robot.clickOnLogin()
|
||||||
|
composeTestRule.mainClock.advanceTimeByFrame()
|
||||||
|
robot.assertLoading()
|
||||||
|
composeTestRule.mainClock.autoAdvance = true
|
||||||
|
|
||||||
|
navigationRobot.assertHomeScreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emptyPasswordShowsProperErrorMessage() {
|
||||||
|
composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY)
|
||||||
|
navigationRobot.assertAuthScreen()
|
||||||
|
|
||||||
|
robot.setUsername("banan")
|
||||||
|
.assertUsername("banan")
|
||||||
|
.clickOnLogin()
|
||||||
|
|
||||||
|
robot.assertErrorIsShown(R.string.password_is_invalid)
|
||||||
|
.assertNotLoading()
|
||||||
|
navigationRobot.assertAuthScreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GIVEN password and empty username WHEN signIn THEN error username is shown */
|
||||||
|
@Test
|
||||||
|
fun emptyUserNameShowsProperErrorMessage() {
|
||||||
|
composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY)
|
||||||
|
navigationRobot.assertAuthScreen()
|
||||||
|
|
||||||
|
robot
|
||||||
|
.setPassword("banan")
|
||||||
|
.assertPassword("banan")
|
||||||
|
.clickOnLogin()
|
||||||
|
|
||||||
|
robot.assertErrorIsShown(R.string.username_is_invalid)
|
||||||
|
.assertNotLoading()
|
||||||
|
navigationRobot.assertAuthScreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GIVEN password and username and invalid credentials response WHEN signIn THEN error invalid credentials is shown */
|
||||||
|
@Test
|
||||||
|
fun invalidCredentialsGivenShowsProperErrorMessage() {
|
||||||
|
mockServerScenarioSetup.setScenario(
|
||||||
|
AuthScenario.InvalidCredentials(password = "alma", username = "banan")
|
||||||
|
)
|
||||||
|
|
||||||
|
composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY)
|
||||||
|
navigationRobot.assertAuthScreen()
|
||||||
|
robot.setUsername("alma")
|
||||||
|
.setPassword("banan")
|
||||||
|
.assertUsername("alma")
|
||||||
|
.assertPassword("banan")
|
||||||
|
|
||||||
|
composeTestRule.mainClock.autoAdvance = false
|
||||||
|
robot.clickOnLogin()
|
||||||
|
composeTestRule.mainClock.advanceTimeByFrame()
|
||||||
|
robot.assertLoading()
|
||||||
|
composeTestRule.mainClock.autoAdvance = true
|
||||||
|
|
||||||
|
robot.assertErrorIsShown(R.string.credentials_invalid)
|
||||||
|
.assertNotLoading()
|
||||||
|
navigationRobot.assertAuthScreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GIVEN password and username and error response WHEN signIn THEN error invalid credentials is shown */
|
||||||
|
@Test
|
||||||
|
fun networkErrorShowsProperErrorMessage() {
|
||||||
|
mockServerScenarioSetup.setScenario(
|
||||||
|
AuthScenario.GenericError(username = "alma", password = "banan")
|
||||||
|
)
|
||||||
|
|
||||||
|
composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY)
|
||||||
|
navigationRobot.assertAuthScreen()
|
||||||
|
robot.setUsername("alma")
|
||||||
|
.setPassword("banan")
|
||||||
|
.assertUsername("alma")
|
||||||
|
.assertPassword("banan")
|
||||||
|
|
||||||
|
composeTestRule.mainClock.autoAdvance = false
|
||||||
|
robot.clickOnLogin()
|
||||||
|
composeTestRule.mainClock.advanceTimeByFrame()
|
||||||
|
robot.assertLoading()
|
||||||
|
composeTestRule.mainClock.autoAdvance = true
|
||||||
|
|
||||||
|
robot.assertErrorIsShown(R.string.something_went_wrong)
|
||||||
|
.assertNotLoading()
|
||||||
|
navigationRobot.assertAuthScreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GIVEN username and password WHEN restoring THEN username and password fields contain the same text */
|
||||||
|
@Test
|
||||||
|
fun restoringContentShowPreviousCredentials() {
|
||||||
|
composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY)
|
||||||
|
navigationRobot.assertAuthScreen()
|
||||||
|
robot.setUsername("alma")
|
||||||
|
.setPassword("banan")
|
||||||
|
.assertUsername("alma")
|
||||||
|
.assertPassword("banan")
|
||||||
|
|
||||||
|
stateRestorationTester.emulateSavedInstanceStateRestore()
|
||||||
|
composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY) // ensure all time based operation run
|
||||||
|
|
||||||
|
navigationRobot.assertAuthScreen()
|
||||||
|
robot.assertUsername("alma")
|
||||||
|
.assertPassword("banan")
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val SPLASH_DELAY = 600L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
package org.fnives.test.showcase.hilt.ui.compose
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
|
||||||
|
import androidx.compose.ui.test.assertCountEquals
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.assertTextContains
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithTag
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performTextInput
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import org.fnives.test.showcase.hilt.compose.screen.auth.AuthScreenTag
|
||||||
|
|
||||||
|
class ComposeLoginRobot(
|
||||||
|
semanticsNodeInteractionsProvider: SemanticsNodeInteractionsProvider,
|
||||||
|
) : SemanticsNodeInteractionsProvider by semanticsNodeInteractionsProvider {
|
||||||
|
|
||||||
|
fun setUsername(username: String): ComposeLoginRobot = apply {
|
||||||
|
onNodeWithTag(AuthScreenTag.UsernameInput).performTextInput(username)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPassword(password: String): ComposeLoginRobot = apply {
|
||||||
|
onNodeWithTag(AuthScreenTag.PasswordInput).performTextInput(password)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertPassword(password: String): ComposeLoginRobot = apply {
|
||||||
|
onNodeWithTag(AuthScreenTag.PasswordVisibilityToggle).performClick()
|
||||||
|
onNodeWithTag(AuthScreenTag.PasswordInput).assertTextContains(password)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertUsername(username: String): ComposeLoginRobot = apply {
|
||||||
|
onNodeWithTag(AuthScreenTag.UsernameInput).assertTextContains(username)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clickOnLogin(): ComposeLoginRobot = apply {
|
||||||
|
onNodeWithTag(AuthScreenTag.LoginButton).performClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertLoading(): ComposeLoginRobot = apply {
|
||||||
|
onNodeWithTag(AuthScreenTag.LoadingIndicator).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertNotLoading(): ComposeLoginRobot = apply {
|
||||||
|
onAllNodesWithTag(AuthScreenTag.LoadingIndicator).assertCountEquals(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertErrorIsShown(stringId: Int): ComposeLoginRobot = apply {
|
||||||
|
onNodeWithTag(AuthScreenTag.LoginError)
|
||||||
|
.assertTextContains(ApplicationProvider.getApplicationContext<Context>().resources.getString(stringId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
package org.fnives.test.showcase.hilt.ui.compose
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.junit4.ComposeTestRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import org.fnives.test.showcase.hilt.compose.screen.AppNavigationTag
|
||||||
|
|
||||||
|
class ComposeNavigationRobot(
|
||||||
|
private val composeTestRule: ComposeTestRule,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun assertHomeScreen(): ComposeNavigationRobot = apply {
|
||||||
|
composeTestRule.onNodeWithTag(AppNavigationTag.HomeScreen).assertExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertAuthScreen(): ComposeNavigationRobot = apply {
|
||||||
|
composeTestRule.onNodeWithTag(AppNavigationTag.AuthScreen).assertExists()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
package org.fnives.test.showcase.hilt.ui.compose.idle
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.IdlingResource
|
||||||
|
import androidx.compose.ui.test.junit4.ComposeTestRule
|
||||||
|
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.Disposable
|
||||||
|
|
||||||
|
class ComposeIdlingDisposable(
|
||||||
|
private val idlingResource: IdlingResource,
|
||||||
|
private val testRule: ComposeTestRule,
|
||||||
|
) : Disposable {
|
||||||
|
override var isDisposed: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
init {
|
||||||
|
testRule.registerIdlingResource(idlingResource)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun dispose() {
|
||||||
|
isDisposed = true
|
||||||
|
testRule.unregisterIdlingResource(idlingResource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
package org.fnives.test.showcase.hilt.ui.compose.idle
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.ui.test.junit4.ComposeTestRule
|
||||||
|
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.CompositeDisposable
|
||||||
|
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.Disposable
|
||||||
|
import org.fnives.test.showcase.hilt.network.testutil.NetworkSynchronization
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class ComposeNetworkSyncHelper @Inject constructor(
|
||||||
|
private val networkSynchronization: NetworkSynchronization,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private var disposable: Disposable? = null
|
||||||
|
|
||||||
|
fun setup(composeTestRule: ComposeTestRule) {
|
||||||
|
disposable = networkSynchronization.networkIdlingResources()
|
||||||
|
.map(::EspressoToComposeIdlingResourceAdapter)
|
||||||
|
.map { ComposeIdlingDisposable(it, composeTestRule) }
|
||||||
|
.let(::CompositeDisposable)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun tearDown() {
|
||||||
|
if (disposable == null) {
|
||||||
|
Log.w("ComposeNetworkSyncHelper", "tearDown called, but setup wasn't!")
|
||||||
|
}
|
||||||
|
disposable?.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package org.fnives.test.showcase.hilt.ui.compose.idle
|
||||||
|
|
||||||
|
import androidx.test.espresso.IdlingResource
|
||||||
|
|
||||||
|
class EspressoToComposeIdlingResourceAdapter(private val idlingResource: IdlingResource) : androidx.compose.ui.test.IdlingResource {
|
||||||
|
override val isIdleNow: Boolean get() = idlingResource.isIdleNow
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
package org.fnives.test.showcase.hilt.ui.home
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import dagger.hilt.android.testing.HiltAndroidTest
|
||||||
|
import org.fnives.test.showcase.hilt.test.shared.ui.home.MainActivityInstrumentedSharedTest
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@HiltAndroidTest
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class MainActivityInstrumentedTest : MainActivityInstrumentedSharedTest()
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
package org.fnives.test.showcase.hilt.ui.splash
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import dagger.hilt.android.testing.HiltAndroidTest
|
||||||
|
import org.fnives.test.showcase.hilt.test.shared.ui.splash.SplashActivityInstrumentedSharedTest
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
@HiltAndroidTest
|
||||||
|
class SplashActivityInstrumentedTest : SplashActivityInstrumentedSharedTest()
|
||||||
44
hilt/hilt-app/src/main/AndroidManifest.xml
Normal file
44
hilt/hilt-app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
package="org.fnives.test.showcase.hilt">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".TestShowcaseApplication"
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.TestShowCase"
|
||||||
|
tools:ignore="AllowBackup,DataExtractionRules">
|
||||||
|
<activity
|
||||||
|
android:name=".ui.splash.SplashActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<activity android:name=".ui.home.MainActivity" />
|
||||||
|
<activity android:name=".ui.auth.AuthActivity" />
|
||||||
|
<activity
|
||||||
|
android:name=".compose.ComposeActivity"
|
||||||
|
android:configChanges="colorMode|density|fontScale|fontWeightAdjustment|keyboard|keyboardHidden|layoutDirection|locale|mcc|mnc|navigation|orientation|screenLayout|screenSize|smallestScreenSize|touchscreen|uiMode"
|
||||||
|
android:taskAffinity="org.fnives.test.showcase.compose"
|
||||||
|
android:icon="@mipmap/ic_compose_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_compose_launcher_round"
|
||||||
|
android:label="@string/app_name_compose"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package org.fnives.test.showcase.hilt
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
|
||||||
|
@HiltAndroidApp
|
||||||
|
class TestShowcaseApplication : Application()
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
package org.fnives.test.showcase.hilt.compose
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import com.google.accompanist.insets.ProvideWindowInsets
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.fnives.test.showcase.hilt.compose.screen.AppNavigation
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class ComposeActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContent {
|
||||||
|
TestShowCaseApp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TestShowCaseApp() {
|
||||||
|
ProvideWindowInsets {
|
||||||
|
MaterialTheme {
|
||||||
|
AppNavigation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
package org.fnives.test.showcase.hilt.compose.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.navigation.NavOptions
|
||||||
|
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.screen.auth.AuthScreen
|
||||||
|
import org.fnives.test.showcase.hilt.compose.screen.auth.rememberAuthScreenState
|
||||||
|
import org.fnives.test.showcase.hilt.compose.screen.home.HomeScreen
|
||||||
|
import org.fnives.test.showcase.hilt.compose.screen.home.rememberHomeScreenState
|
||||||
|
import org.fnives.test.showcase.hilt.compose.screen.splash.SplashScreen
|
||||||
|
import org.fnives.test.showcase.hilt.core.login.IsUserLoggedInUseCase
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppNavigation(
|
||||||
|
isUserLogeInUseCase: IsUserLoggedInUseCase = AppNavigationEntryPoint.get().isUserLoggedInUseCase
|
||||||
|
) {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
|
||||||
|
LaunchedEffect(isUserLogeInUseCase) {
|
||||||
|
val loginStateRoute = if (isUserLogeInUseCase.invoke()) RouteTag.HOME else RouteTag.AUTH
|
||||||
|
if (navController.currentDestination?.route == loginStateRoute) return@LaunchedEffect
|
||||||
|
delay(500)
|
||||||
|
navController.navigate(
|
||||||
|
route = loginStateRoute,
|
||||||
|
navOptions = NavOptions.Builder().setPopUpTo(route = RouteTag.SPLASH, inclusive = true).build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
NavHost(
|
||||||
|
navController,
|
||||||
|
startDestination = RouteTag.SPLASH,
|
||||||
|
modifier = Modifier.background(MaterialTheme.colors.surface)
|
||||||
|
) {
|
||||||
|
composable(RouteTag.SPLASH) { SplashScreen() }
|
||||||
|
composable(RouteTag.AUTH) {
|
||||||
|
AuthScreen(
|
||||||
|
modifier = Modifier.testTag(AppNavigationTag.AuthScreen),
|
||||||
|
authScreenState = rememberAuthScreenState(
|
||||||
|
onLoginSuccess = {
|
||||||
|
navController.navigate(
|
||||||
|
route = RouteTag.HOME,
|
||||||
|
navOptions = NavOptions.Builder().setPopUpTo(route = RouteTag.AUTH, inclusive = true).build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(RouteTag.HOME) {
|
||||||
|
HomeScreen(
|
||||||
|
modifier = Modifier.testTag(AppNavigationTag.HomeScreen),
|
||||||
|
homeScreenState = rememberHomeScreenState(
|
||||||
|
onLogout = {
|
||||||
|
navController.navigate(
|
||||||
|
route = RouteTag.AUTH,
|
||||||
|
navOptions = NavOptions.Builder().setPopUpTo(route = RouteTag.HOME, inclusive = true).build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object RouteTag {
|
||||||
|
const val HOME = "Home"
|
||||||
|
const val AUTH = "Auth"
|
||||||
|
const val SPLASH = "Splash"
|
||||||
|
}
|
||||||
|
|
||||||
|
object AppNavigationTag {
|
||||||
|
const val AuthScreen = "AppNavigationTag.AuthScreen"
|
||||||
|
const val HomeScreen = "AppNavigationTag.HomeScreen"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,211 @@
|
||||||
|
package org.fnives.test.showcase.hilt.compose.screen.auth
|
||||||
|
|
||||||
|
import androidx.compose.animation.graphics.res.animatedVectorResource
|
||||||
|
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
|
||||||
|
import androidx.compose.animation.graphics.vector.AnimatedImageVector
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.Button
|
||||||
|
import androidx.compose.material.CircularProgressIndicator
|
||||||
|
import androidx.compose.material.Icon
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.OutlinedTextField
|
||||||
|
import androidx.compose.material.Snackbar
|
||||||
|
import androidx.compose.material.SnackbarHost
|
||||||
|
import androidx.compose.material.SnackbarHostState
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
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.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.constraintlayout.compose.ConstraintLayout
|
||||||
|
import com.google.accompanist.insets.statusBarsPadding
|
||||||
|
import org.fnives.test.showcase.hilt.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AuthScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
authScreenState: AuthScreenState = rememberAuthScreenState()
|
||||||
|
) {
|
||||||
|
ConstraintLayout(modifier.fillMaxSize()) {
|
||||||
|
val (title, credentials, snackbar, loading, login) = createRefs()
|
||||||
|
Title(
|
||||||
|
modifier = Modifier
|
||||||
|
.statusBarsPadding()
|
||||||
|
.constrainAs(title) { top.linkTo(parent.top) }
|
||||||
|
)
|
||||||
|
CredentialsFields(
|
||||||
|
authScreenState = authScreenState,
|
||||||
|
modifier = Modifier.constrainAs(credentials) {
|
||||||
|
top.linkTo(title.bottom)
|
||||||
|
bottom.linkTo(login.top)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Snackbar(
|
||||||
|
authScreenState = authScreenState,
|
||||||
|
modifier = Modifier.constrainAs(snackbar) {
|
||||||
|
bottom.linkTo(login.top)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (authScreenState.loading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
Modifier
|
||||||
|
.testTag(AuthScreenTag.LoadingIndicator)
|
||||||
|
.constrainAs(loading) {
|
||||||
|
bottom.linkTo(login.top)
|
||||||
|
centerHorizontallyTo(parent)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LoginButton(
|
||||||
|
modifier = Modifier
|
||||||
|
.constrainAs(login) { bottom.linkTo(parent.bottom) }
|
||||||
|
.padding(16.dp),
|
||||||
|
onClick = { authScreenState.onLogin() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CredentialsFields(authScreenState: AuthScreenState, modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
UsernameField(authScreenState)
|
||||||
|
PasswordField(authScreenState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class, androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun PasswordField(authScreenState: AuthScreenState) {
|
||||||
|
var passwordVisible by remember { mutableStateOf(false) }
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
OutlinedTextField(
|
||||||
|
value = authScreenState.password,
|
||||||
|
label = { Text(text = stringResource(id = R.string.password)) },
|
||||||
|
placeholder = { Text(text = stringResource(id = R.string.password)) },
|
||||||
|
trailingIcon = {
|
||||||
|
val image = AnimatedImageVector.animatedVectorResource(R.drawable.show_password)
|
||||||
|
Icon(
|
||||||
|
painter = rememberAnimatedVectorPainter(image, passwordVisible),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable { passwordVisible = !passwordVisible }
|
||||||
|
.testTag(AuthScreenTag.PasswordVisibilityToggle)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onValueChange = { authScreenState.onPasswordChanged(it) },
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
autoCorrect = false,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
keyboardType = KeyboardType.Password
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(onDone = {
|
||||||
|
keyboardController?.hide()
|
||||||
|
authScreenState.onLogin()
|
||||||
|
}),
|
||||||
|
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 16.dp)
|
||||||
|
.testTag(AuthScreenTag.PasswordInput)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UsernameField(authScreenState: AuthScreenState) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = authScreenState.username,
|
||||||
|
label = { Text(text = stringResource(id = R.string.username)) },
|
||||||
|
placeholder = { Text(text = stringResource(id = R.string.username)) },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
|
||||||
|
onValueChange = { authScreenState.onUsernameChanged(it) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag(AuthScreenTag.UsernameInput)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Snackbar(authScreenState: AuthScreenState, modifier: Modifier = Modifier) {
|
||||||
|
val snackbarState = remember { SnackbarHostState() }
|
||||||
|
val error = authScreenState.error
|
||||||
|
LaunchedEffect(error) {
|
||||||
|
if (error != null) {
|
||||||
|
snackbarState.showSnackbar(error.name)
|
||||||
|
authScreenState.dismissError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SnackbarHost(hostState = snackbarState, modifier) {
|
||||||
|
val stringId = error?.stringResId()
|
||||||
|
if (stringId != null) {
|
||||||
|
Snackbar(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||||
|
Text(text = stringResource(stringId), Modifier.testTag(AuthScreenTag.LoginError))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LoginButton(modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
|
Box(modifier) {
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag(AuthScreenTag.LoginButton)
|
||||||
|
) {
|
||||||
|
Text(text = "Login")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Title(modifier: Modifier = Modifier) {
|
||||||
|
Text(
|
||||||
|
stringResource(id = R.string.login_title),
|
||||||
|
modifier = modifier.padding(16.dp),
|
||||||
|
style = MaterialTheme.typography.h4
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AuthScreenState.ErrorType.stringResId() = when (this) {
|
||||||
|
AuthScreenState.ErrorType.INVALID_CREDENTIALS -> R.string.credentials_invalid
|
||||||
|
AuthScreenState.ErrorType.GENERAL_NETWORK_ERROR -> R.string.something_went_wrong
|
||||||
|
AuthScreenState.ErrorType.UNSUPPORTED_USERNAME -> R.string.username_is_invalid
|
||||||
|
AuthScreenState.ErrorType.UNSUPPORTED_PASSWORD -> R.string.password_is_invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
object AuthScreenTag {
|
||||||
|
const val UsernameInput = "AuthScreenTag.UsernameInput"
|
||||||
|
const val PasswordInput = "AuthScreenTag.PasswordInput"
|
||||||
|
const val LoadingIndicator = "AuthScreenTag.LoadingIndicator"
|
||||||
|
const val LoginButton = "AuthScreenTag.LoginButton"
|
||||||
|
const val LoginError = "AuthScreenTag.LoginError"
|
||||||
|
const val PasswordVisibilityToggle = "AuthScreenTag.PasswordVisibilityToggle"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
package org.fnives.test.showcase.hilt.compose.screen.auth
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.Saver
|
||||||
|
import androidx.compose.runtime.saveable.mapSaver
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
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
|
||||||
|
import org.fnives.test.showcase.model.shared.Answer
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberAuthScreenState(
|
||||||
|
stateScope: CoroutineScope = rememberCoroutineScope { Dispatchers.Main },
|
||||||
|
loginUseCase: LoginUseCase = AuthEntryPoint.get().loginUseCase,
|
||||||
|
onLoginSuccess: () -> Unit = {},
|
||||||
|
): AuthScreenState {
|
||||||
|
return rememberSaveable(saver = AuthScreenState.getSaver(stateScope, loginUseCase, onLoginSuccess)) {
|
||||||
|
AuthScreenState(stateScope, loginUseCase, onLoginSuccess)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthScreenState(
|
||||||
|
private val stateScope: CoroutineScope,
|
||||||
|
private val loginUseCase: LoginUseCase,
|
||||||
|
private val onLoginSuccess: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
|
||||||
|
var username by mutableStateOf("")
|
||||||
|
private set
|
||||||
|
var password by mutableStateOf("")
|
||||||
|
private set
|
||||||
|
var loading by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
var error by mutableStateOf<ErrorType?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun onUsernameChanged(username: String) {
|
||||||
|
this.username = username
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onPasswordChanged(password: String) {
|
||||||
|
this.password = password
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onLogin() {
|
||||||
|
if (loading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading = true
|
||||||
|
stateScope.launch {
|
||||||
|
val credentials = LoginCredentials(
|
||||||
|
username = username,
|
||||||
|
password = password
|
||||||
|
)
|
||||||
|
when (val response = loginUseCase.invoke(credentials)) {
|
||||||
|
is Answer.Error -> error = ErrorType.GENERAL_NETWORK_ERROR
|
||||||
|
is Answer.Success -> processLoginStatus(response.data)
|
||||||
|
}
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processLoginStatus(loginStatus: LoginStatus) {
|
||||||
|
when (loginStatus) {
|
||||||
|
LoginStatus.SUCCESS -> onLoginSuccess()
|
||||||
|
LoginStatus.INVALID_CREDENTIALS -> error = ErrorType.INVALID_CREDENTIALS
|
||||||
|
LoginStatus.INVALID_USERNAME -> error = ErrorType.UNSUPPORTED_USERNAME
|
||||||
|
LoginStatus.INVALID_PASSWORD -> error = ErrorType.UNSUPPORTED_PASSWORD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissError() {
|
||||||
|
error = null
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ErrorType {
|
||||||
|
INVALID_CREDENTIALS,
|
||||||
|
GENERAL_NETWORK_ERROR,
|
||||||
|
UNSUPPORTED_USERNAME,
|
||||||
|
UNSUPPORTED_PASSWORD
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val USERNAME = "USERNAME"
|
||||||
|
private const val PASSWORD = "PASSWORD"
|
||||||
|
|
||||||
|
fun getSaver(
|
||||||
|
stateScope: CoroutineScope,
|
||||||
|
loginUseCase: LoginUseCase,
|
||||||
|
onLoginSuccess: () -> Unit,
|
||||||
|
): Saver<AuthScreenState, *> = mapSaver(
|
||||||
|
save = { mapOf(USERNAME to it.username, PASSWORD to it.password) },
|
||||||
|
restore = {
|
||||||
|
AuthScreenState(stateScope, loginUseCase, onLoginSuccess).apply {
|
||||||
|
onUsernameChanged(it.getOrElse(USERNAME) { "" } as String)
|
||||||
|
onPasswordChanged(it.getOrElse(PASSWORD) { "" } as String)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
package org.fnives.test.showcase.hilt.compose.screen.home
|
||||||
|
|
||||||
|
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.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.LogoutUseCase
|
||||||
|
|
||||||
|
object HomeEntryPoint {
|
||||||
|
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface MainDependencies {
|
||||||
|
val getAllContentUseCase: GetAllContentUseCase
|
||||||
|
val logoutUseCase: LogoutUseCase
|
||||||
|
val fetchContentUseCase: FetchContentUseCase
|
||||||
|
val addContentToFavouriteUseCase: AddContentToFavouriteUseCase
|
||||||
|
val removeContentFromFavouritesUseCase: RemoveContentFromFavouritesUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun get(): MainDependencies {
|
||||||
|
val context = LocalContext.current.applicationContext
|
||||||
|
return remember { EntryPoints.get(context, MainDependencies::class.java) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
package org.fnives.test.showcase.hilt.compose.screen.home
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.compose.rememberImagePainter
|
||||||
|
import com.google.accompanist.swiperefresh.SwipeRefresh
|
||||||
|
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
|
||||||
|
import org.fnives.test.showcase.hilt.R
|
||||||
|
import org.fnives.test.showcase.model.content.FavouriteContent
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HomeScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
homeScreenState: HomeScreenState = rememberHomeScreenState()
|
||||||
|
) {
|
||||||
|
Column(modifier.fillMaxSize()) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Title(Modifier.weight(1f))
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = R.drawable.logout_24),
|
||||||
|
contentDescription = null,
|
||||||
|
colorFilter = ColorFilter.tint(MaterialTheme.colors.primary),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.clickable { homeScreenState.onLogout() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box {
|
||||||
|
if (homeScreenState.isError) {
|
||||||
|
ErrorText(Modifier.align(Alignment.Center))
|
||||||
|
}
|
||||||
|
SwipeRefresh(
|
||||||
|
state = rememberSwipeRefreshState(isRefreshing = homeScreenState.loading),
|
||||||
|
onRefresh = {
|
||||||
|
homeScreenState.onRefresh()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(homeScreenState.content) { item ->
|
||||||
|
Item(
|
||||||
|
Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
favouriteContent = item,
|
||||||
|
onFavouriteToggle = { homeScreenState.onFavouriteToggleClicked(item.content.id) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Item(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
favouriteContent: FavouriteContent,
|
||||||
|
onFavouriteToggle: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Image(
|
||||||
|
painter = rememberImagePainter(favouriteContent.content.imageUrl.url),
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.height(120.dp)
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
Text(text = favouriteContent.content.title)
|
||||||
|
Text(text = favouriteContent.content.description)
|
||||||
|
}
|
||||||
|
val favouriteIcon = if (favouriteContent.isFavourite) R.drawable.favorite_24 else R.drawable.favorite_border_24
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = favouriteIcon),
|
||||||
|
contentDescription = null,
|
||||||
|
Modifier.clickable { onFavouriteToggle() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Title(modifier: Modifier = Modifier) {
|
||||||
|
Text(
|
||||||
|
stringResource(id = R.string.login_title),
|
||||||
|
modifier = modifier.padding(16.dp),
|
||||||
|
style = MaterialTheme.typography.h4
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ErrorText(modifier: Modifier = Modifier) {
|
||||||
|
Text(
|
||||||
|
stringResource(id = R.string.something_went_wrong),
|
||||||
|
modifier = modifier.padding(16.dp),
|
||||||
|
style = MaterialTheme.typography.h4,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
package org.fnives.test.showcase.hilt.compose.screen.home
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.mapNotNull
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
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.LogoutUseCase
|
||||||
|
import org.fnives.test.showcase.model.content.ContentId
|
||||||
|
import org.fnives.test.showcase.model.content.FavouriteContent
|
||||||
|
import org.fnives.test.showcase.model.shared.Resource
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberHomeScreenState(
|
||||||
|
stateScope: CoroutineScope = rememberCoroutineScope(),
|
||||||
|
mainDependencies: HomeEntryPoint.MainDependencies = HomeEntryPoint.get(),
|
||||||
|
onLogout: () -> Unit = {},
|
||||||
|
) =
|
||||||
|
rememberHomeScreenState(
|
||||||
|
stateScope = stateScope,
|
||||||
|
getAllContentUseCase = mainDependencies.getAllContentUseCase,
|
||||||
|
logoutUseCase = mainDependencies.logoutUseCase,
|
||||||
|
fetchContentUseCase = mainDependencies.fetchContentUseCase,
|
||||||
|
addContentToFavouriteUseCase = mainDependencies.addContentToFavouriteUseCase,
|
||||||
|
removeContentFromFavouritesUseCase = mainDependencies.removeContentFromFavouritesUseCase,
|
||||||
|
onLogout = onLogout,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Suppress("LongParameterList")
|
||||||
|
@Composable
|
||||||
|
fun rememberHomeScreenState(
|
||||||
|
stateScope: CoroutineScope = rememberCoroutineScope(),
|
||||||
|
getAllContentUseCase: GetAllContentUseCase,
|
||||||
|
logoutUseCase: LogoutUseCase,
|
||||||
|
fetchContentUseCase: FetchContentUseCase,
|
||||||
|
addContentToFavouriteUseCase: AddContentToFavouriteUseCase,
|
||||||
|
removeContentFromFavouritesUseCase: RemoveContentFromFavouritesUseCase,
|
||||||
|
onLogout: () -> Unit = {},
|
||||||
|
): HomeScreenState {
|
||||||
|
return remember {
|
||||||
|
HomeScreenState(
|
||||||
|
stateScope,
|
||||||
|
getAllContentUseCase,
|
||||||
|
logoutUseCase,
|
||||||
|
fetchContentUseCase,
|
||||||
|
addContentToFavouriteUseCase,
|
||||||
|
removeContentFromFavouritesUseCase,
|
||||||
|
onLogout,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList")
|
||||||
|
class HomeScreenState(
|
||||||
|
private val stateScope: CoroutineScope,
|
||||||
|
private val getAllContentUseCase: GetAllContentUseCase,
|
||||||
|
private val logoutUseCase: LogoutUseCase,
|
||||||
|
private val fetchContentUseCase: FetchContentUseCase,
|
||||||
|
private val addContentToFavouriteUseCase: AddContentToFavouriteUseCase,
|
||||||
|
private val removeContentFromFavouritesUseCase: RemoveContentFromFavouritesUseCase,
|
||||||
|
private val logoutEvent: () -> Unit,
|
||||||
|
) {
|
||||||
|
|
||||||
|
var loading by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
var isError by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
var content by mutableStateOf<List<FavouriteContent>>(emptyList())
|
||||||
|
private set
|
||||||
|
|
||||||
|
init {
|
||||||
|
stateScope.launch {
|
||||||
|
fetch().collect {
|
||||||
|
content = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetch() = getAllContentUseCase.get()
|
||||||
|
.mapNotNull {
|
||||||
|
when (it) {
|
||||||
|
is Resource.Error -> {
|
||||||
|
isError = true
|
||||||
|
loading = false
|
||||||
|
return@mapNotNull emptyList<FavouriteContent>()
|
||||||
|
}
|
||||||
|
is Resource.Loading -> {
|
||||||
|
isError = false
|
||||||
|
loading = true
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
is Resource.Success -> {
|
||||||
|
isError = false
|
||||||
|
loading = false
|
||||||
|
return@mapNotNull it.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onLogout() {
|
||||||
|
stateScope.launch {
|
||||||
|
logoutUseCase.invoke()
|
||||||
|
logoutEvent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onRefresh() {
|
||||||
|
if (loading) return
|
||||||
|
loading = true
|
||||||
|
stateScope.launch {
|
||||||
|
fetchContentUseCase.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onFavouriteToggleClicked(contentId: ContentId) {
|
||||||
|
stateScope.launch {
|
||||||
|
val item = content.firstOrNull { it.content.id == contentId } ?: return@launch
|
||||||
|
if (item.isFavourite) {
|
||||||
|
removeContentFromFavouritesUseCase.invoke(contentId)
|
||||||
|
} else {
|
||||||
|
addContentToFavouriteUseCase.invoke(contentId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
package org.fnives.test.showcase.hilt.compose.screen.splash
|
||||||
|
|
||||||
|
import android.os.Build.VERSION
|
||||||
|
import android.os.Build.VERSION_CODES
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.colorResource
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.fnives.test.showcase.hilt.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SplashScreen() {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(colorResource(R.color.purple_700)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
val resourceId = if (VERSION.SDK_INT >= VERSION_CODES.N) {
|
||||||
|
R.drawable.ic_launcher_foreground
|
||||||
|
} else {
|
||||||
|
R.mipmap.ic_launcher_round
|
||||||
|
}
|
||||||
|
Image(
|
||||||
|
painter = painterResource(resourceId),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(120.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
package org.fnives.test.showcase.hilt.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import org.fnives.test.showcase.hilt.core.session.SessionExpirationListener
|
||||||
|
import org.fnives.test.showcase.hilt.core.storage.content.FavouriteContentLocalStorage
|
||||||
|
import org.fnives.test.showcase.hilt.session.SessionExpirationListenerImpl
|
||||||
|
import org.fnives.test.showcase.hilt.storage.LocalDatabase
|
||||||
|
import org.fnives.test.showcase.hilt.storage.SharedPreferencesManagerImpl
|
||||||
|
import org.fnives.test.showcase.hilt.storage.favourite.FavouriteContentLocalStorageImpl
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
@Module
|
||||||
|
object AppModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun enableLogging(): Boolean = true
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
@Provides
|
||||||
|
fun provideFavouriteDao(localDatabase: LocalDatabase) =
|
||||||
|
localDatabase.favouriteDao
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideSharedPreferencesManagerImpl(@ApplicationContext context: Context) =
|
||||||
|
SharedPreferencesManagerImpl.create(context)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideFavouriteContentLocalStorage(
|
||||||
|
favouriteContentLocalStorageImpl: FavouriteContentLocalStorageImpl
|
||||||
|
): FavouriteContentLocalStorage = favouriteContentLocalStorageImpl
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
internal fun bindSessionExpirationListener(
|
||||||
|
sessionExpirationListenerImpl: SessionExpirationListenerImpl
|
||||||
|
): SessionExpirationListener = sessionExpirationListenerImpl
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package org.fnives.test.showcase.hilt.di
|
||||||
|
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import org.fnives.test.showcase.hilt.BuildConfig
|
||||||
|
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
@Module
|
||||||
|
|
||||||
|
object BaseUrlModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideBaseUrl(): String = BuildConfig.BASE_URL
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
package org.fnives.test.showcase.hilt.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import org.fnives.test.showcase.hilt.storage.LocalDatabase
|
||||||
|
import org.fnives.test.showcase.hilt.storage.database.DatabaseInitialization
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
@Module
|
||||||
|
object StorageModule {
|
||||||
|
@Singleton
|
||||||
|
@Provides
|
||||||
|
fun provideLocalDatabase(@ApplicationContext context: Context): LocalDatabase =
|
||||||
|
DatabaseInitialization.create(context)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
package org.fnives.test.showcase.hilt.di
|
||||||
|
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage
|
||||||
|
import org.fnives.test.showcase.hilt.storage.SharedPreferencesManagerImpl
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
@Module
|
||||||
|
object UserDataLocalStorageModule {
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
@Provides
|
||||||
|
fun provideUserDataLocalStorage(
|
||||||
|
sharedPreferencesManagerImpl: SharedPreferencesManagerImpl
|
||||||
|
): UserDataLocalStorage = sharedPreferencesManagerImpl
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
package org.fnives.test.showcase.hilt.session
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import org.fnives.test.showcase.hilt.core.session.SessionExpirationListener
|
||||||
|
import org.fnives.test.showcase.hilt.ui.IntentCoordinator
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class SessionExpirationListenerImpl @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) : SessionExpirationListener {
|
||||||
|
|
||||||
|
override fun onSessionExpired() {
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
context.startActivity(
|
||||||
|
IntentCoordinator.authActivitygetStartIntent(context)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package org.fnives.test.showcase.hilt.storage
|
||||||
|
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import org.fnives.test.showcase.hilt.storage.favourite.FavouriteDao
|
||||||
|
import org.fnives.test.showcase.hilt.storage.favourite.FavouriteEntity
|
||||||
|
|
||||||
|
@Database(
|
||||||
|
entities = [FavouriteEntity::class],
|
||||||
|
version = 2,
|
||||||
|
exportSchema = true
|
||||||
|
)
|
||||||
|
abstract class LocalDatabase : RoomDatabase() {
|
||||||
|
|
||||||
|
abstract val favouriteDao: FavouriteDao
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
package org.fnives.test.showcase.hilt.storage
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage
|
||||||
|
import org.fnives.test.showcase.model.session.Session
|
||||||
|
import kotlin.properties.ReadWriteProperty
|
||||||
|
import kotlin.reflect.KProperty
|
||||||
|
|
||||||
|
class SharedPreferencesManagerImpl(
|
||||||
|
private val sharedPreferences: SharedPreferences
|
||||||
|
) : UserDataLocalStorage {
|
||||||
|
|
||||||
|
override var session: Session? by SessionDelegate(SESSION_KEY)
|
||||||
|
|
||||||
|
private class SessionDelegate(private val key: String) :
|
||||||
|
ReadWriteProperty<SharedPreferencesManagerImpl, Session?> {
|
||||||
|
|
||||||
|
override fun setValue(
|
||||||
|
thisRef: SharedPreferencesManagerImpl,
|
||||||
|
property: KProperty<*>,
|
||||||
|
value: Session?
|
||||||
|
) {
|
||||||
|
if (value == null) {
|
||||||
|
thisRef.sharedPreferences.edit().remove(key).apply()
|
||||||
|
} else {
|
||||||
|
val values = setOf(
|
||||||
|
ACCESS_TOKEN_KEY + value.accessToken,
|
||||||
|
REFRESH_TOKEN_KEY + value.refreshToken
|
||||||
|
)
|
||||||
|
thisRef.sharedPreferences.edit().putStringSet(key, values).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getValue(
|
||||||
|
thisRef: SharedPreferencesManagerImpl,
|
||||||
|
property: KProperty<*>
|
||||||
|
): Session? {
|
||||||
|
val values = thisRef.sharedPreferences.getStringSet(key, null)?.toList()
|
||||||
|
val accessToken = values?.firstOrNull { it.startsWith(ACCESS_TOKEN_KEY) }
|
||||||
|
?.drop(ACCESS_TOKEN_KEY.length) ?: return null
|
||||||
|
val refreshToken = values.firstOrNull { it.startsWith(REFRESH_TOKEN_KEY) }
|
||||||
|
?.drop(REFRESH_TOKEN_KEY.length) ?: return null
|
||||||
|
|
||||||
|
return Session(accessToken = accessToken, refreshToken = refreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ACCESS_TOKEN_KEY = "ACCESS_TOKEN_KEY"
|
||||||
|
private const val REFRESH_TOKEN_KEY = "REFRESH_TOKEN_KEY"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val SESSION_KEY = "SESSION_KEY"
|
||||||
|
private const val SESSION_SHARED_PREFERENCES_NAME = "SESSION_SHARED_PREFERENCES_NAME"
|
||||||
|
|
||||||
|
fun create(context: Context): SharedPreferencesManagerImpl {
|
||||||
|
val sharedPreferences = context.getSharedPreferences(
|
||||||
|
SESSION_SHARED_PREFERENCES_NAME,
|
||||||
|
Context.MODE_PRIVATE
|
||||||
|
)
|
||||||
|
|
||||||
|
return SharedPreferencesManagerImpl(sharedPreferences)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
package org.fnives.test.showcase.hilt.storage.database
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Room
|
||||||
|
import org.fnives.test.showcase.hilt.storage.LocalDatabase
|
||||||
|
import org.fnives.test.showcase.hilt.storage.migation.Migration1To2
|
||||||
|
|
||||||
|
object DatabaseInitialization {
|
||||||
|
|
||||||
|
fun create(context: Context): LocalDatabase =
|
||||||
|
Room.databaseBuilder(context, LocalDatabase::class.java, "local_database")
|
||||||
|
.addMigrations(Migration1To2())
|
||||||
|
.allowMainThreadQueries()
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
package org.fnives.test.showcase.hilt.storage.favourite
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import org.fnives.test.showcase.hilt.core.storage.content.FavouriteContentLocalStorage
|
||||||
|
import org.fnives.test.showcase.model.content.ContentId
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class FavouriteContentLocalStorageImpl @Inject constructor(
|
||||||
|
private val favouriteDao: FavouriteDao
|
||||||
|
) : FavouriteContentLocalStorage {
|
||||||
|
|
||||||
|
override fun observeFavourites(): Flow<List<ContentId>> =
|
||||||
|
favouriteDao.get().map { it.map(FavouriteEntity::contentId).map(::ContentId) }
|
||||||
|
|
||||||
|
override suspend fun markAsFavourite(contentId: ContentId) {
|
||||||
|
favouriteDao.addFavourite(FavouriteEntity(contentId.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteAsFavourite(contentId: ContentId) {
|
||||||
|
favouriteDao.deleteFavourite(FavouriteEntity(contentId.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package org.fnives.test.showcase.hilt.storage.favourite
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Delete
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface FavouriteDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM FavouriteEntity")
|
||||||
|
fun get(): Flow<List<FavouriteEntity>>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
suspend fun addFavourite(favouriteEntity: FavouriteEntity)
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun deleteFavourite(favouriteEntity: FavouriteEntity)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package org.fnives.test.showcase.hilt.storage.favourite
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
data class FavouriteEntity(
|
||||||
|
@ColumnInfo(name = "content_id")
|
||||||
|
@PrimaryKey val contentId: String
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package org.fnives.test.showcase.hilt.storage.migation
|
||||||
|
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
class Migration1To2 : Migration(1, 2) {
|
||||||
|
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
database.execSQL("ALTER TABLE FavouriteEntity RENAME TO FavouriteEntityOld")
|
||||||
|
database.execSQL("CREATE TABLE FavouriteEntity(content_id TEXT NOT NULL PRIMARY KEY)")
|
||||||
|
database.execSQL("INSERT INTO FavouriteEntity(content_id) SELECT contentId FROM FavouriteEntityOld")
|
||||||
|
database.execSQL("DROP TABLE FavouriteEntityOld")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
package org.fnives.test.showcase.hilt.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import org.fnives.test.showcase.hilt.ui.auth.AuthActivity
|
||||||
|
import org.fnives.test.showcase.hilt.ui.home.MainActivity
|
||||||
|
|
||||||
|
object IntentCoordinator {
|
||||||
|
|
||||||
|
fun mainActivitygetStartIntent(context: Context): Intent =
|
||||||
|
MainActivity.getStartIntent(context)
|
||||||
|
|
||||||
|
fun authActivitygetStartIntent(context: Context): Intent =
|
||||||
|
AuthActivity.getStartIntent(context)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package org.fnives.test.showcase.hilt.ui
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelStoreOwner
|
||||||
|
import androidx.activity.viewModels as androidxViewModel
|
||||||
|
|
||||||
|
inline fun <reified T : ViewModel> ViewModelStoreOwner.viewModels(): Lazy<T> =
|
||||||
|
when (this) {
|
||||||
|
is ComponentActivity -> androidxViewModel()
|
||||||
|
else -> throw IllegalStateException("Only supports activity viewModel for now")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
package org.fnives.test.showcase.hilt.ui.auth
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.core.widget.doAfterTextChanged
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.fnives.test.showcase.hilt.R
|
||||||
|
import org.fnives.test.showcase.hilt.databinding.ActivityAuthenticationBinding
|
||||||
|
import org.fnives.test.showcase.hilt.ui.IntentCoordinator
|
||||||
|
import org.fnives.test.showcase.hilt.ui.viewModels
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class AuthActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
private val viewModel by viewModels<AuthViewModel>()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val binding = ActivityAuthenticationBinding.inflate(layoutInflater)
|
||||||
|
viewModel.loading.observe(this) {
|
||||||
|
binding.loadingIndicator.isVisible = it == true
|
||||||
|
}
|
||||||
|
viewModel.password.observe(this, SetTextIfNotSameObserver(binding.passwordEditText))
|
||||||
|
binding.passwordEditText.doAfterTextChanged { viewModel.onPasswordChanged(it?.toString().orEmpty()) }
|
||||||
|
viewModel.username.observe(this, SetTextIfNotSameObserver(binding.userEditText))
|
||||||
|
binding.userEditText.doAfterTextChanged { viewModel.onUsernameChanged(it?.toString().orEmpty()) }
|
||||||
|
binding.loginCta.setOnClickListener {
|
||||||
|
viewModel.onLogin()
|
||||||
|
}
|
||||||
|
viewModel.error.observe(this) {
|
||||||
|
val stringResId = it?.consume()?.stringResId() ?: return@observe
|
||||||
|
Snackbar.make(binding.snackbarHolder, stringResId, Snackbar.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
viewModel.navigateToHome.observe(this) {
|
||||||
|
it.consume() ?: return@observe
|
||||||
|
startActivity(IntentCoordinator.mainActivitygetStartIntent(this))
|
||||||
|
finishAffinity()
|
||||||
|
}
|
||||||
|
setContentView(binding.root)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private fun AuthViewModel.ErrorType.stringResId() = when (this) {
|
||||||
|
AuthViewModel.ErrorType.INVALID_CREDENTIALS -> R.string.credentials_invalid
|
||||||
|
AuthViewModel.ErrorType.GENERAL_NETWORK_ERROR -> R.string.something_went_wrong
|
||||||
|
AuthViewModel.ErrorType.UNSUPPORTED_USERNAME -> R.string.username_is_invalid
|
||||||
|
AuthViewModel.ErrorType.UNSUPPORTED_PASSWORD -> R.string.password_is_invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getStartIntent(context: Context): Intent = Intent(context, AuthActivity::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
package org.fnives.test.showcase.hilt.ui.auth
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.fnives.test.showcase.hilt.core.login.LoginUseCase
|
||||||
|
import org.fnives.test.showcase.hilt.ui.shared.Event
|
||||||
|
import org.fnives.test.showcase.model.auth.LoginCredentials
|
||||||
|
import org.fnives.test.showcase.model.auth.LoginStatus
|
||||||
|
import org.fnives.test.showcase.model.shared.Answer
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class AuthViewModel @Inject constructor(private val loginUseCase: LoginUseCase) : ViewModel() {
|
||||||
|
|
||||||
|
private val _username = MutableLiveData<String>()
|
||||||
|
val username: LiveData<String> = _username
|
||||||
|
private val _password = MutableLiveData<String>()
|
||||||
|
val password: LiveData<String> = _password
|
||||||
|
private val _loading = MutableLiveData<Boolean>(false)
|
||||||
|
val loading: LiveData<Boolean> = _loading
|
||||||
|
private val _error = MutableLiveData<Event<ErrorType>>()
|
||||||
|
val error: LiveData<Event<ErrorType>> = _error
|
||||||
|
private val _navigateToHome = MutableLiveData<Event<Unit>>()
|
||||||
|
val navigateToHome: LiveData<Event<Unit>> = _navigateToHome
|
||||||
|
|
||||||
|
fun onPasswordChanged(password: String) {
|
||||||
|
_password.value = password
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onUsernameChanged(username: String) {
|
||||||
|
_username.value = username
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onLogin() {
|
||||||
|
if (_loading.value == true) return
|
||||||
|
_loading.value = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
val credentials = LoginCredentials(
|
||||||
|
username = _username.value.orEmpty(),
|
||||||
|
password = _password.value.orEmpty()
|
||||||
|
)
|
||||||
|
when (val response = loginUseCase.invoke(credentials)) {
|
||||||
|
is Answer.Error -> _error.value = Event(ErrorType.GENERAL_NETWORK_ERROR)
|
||||||
|
is Answer.Success -> processLoginStatus(response.data)
|
||||||
|
}
|
||||||
|
_loading.postValue(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processLoginStatus(loginStatus: LoginStatus) {
|
||||||
|
when (loginStatus) {
|
||||||
|
LoginStatus.SUCCESS -> _navigateToHome.value = Event(Unit)
|
||||||
|
LoginStatus.INVALID_CREDENTIALS -> _error.value = Event(ErrorType.INVALID_CREDENTIALS)
|
||||||
|
LoginStatus.INVALID_USERNAME -> _error.value = Event(ErrorType.UNSUPPORTED_USERNAME)
|
||||||
|
LoginStatus.INVALID_PASSWORD -> _error.value = Event(ErrorType.UNSUPPORTED_PASSWORD)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ErrorType {
|
||||||
|
INVALID_CREDENTIALS,
|
||||||
|
GENERAL_NETWORK_ERROR,
|
||||||
|
UNSUPPORTED_USERNAME,
|
||||||
|
UNSUPPORTED_PASSWORD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
package org.fnives.test.showcase.hilt.ui.auth
|
||||||
|
|
||||||
|
import android.widget.EditText
|
||||||
|
import androidx.lifecycle.Observer
|
||||||
|
|
||||||
|
class SetTextIfNotSameObserver(private val editText: EditText) : Observer<String> {
|
||||||
|
override fun onChanged(t: String?) {
|
||||||
|
val current = editText.text?.toString()
|
||||||
|
if (current != t) {
|
||||||
|
editText.setText(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
package org.fnives.test.showcase.hilt.ui.home
|
||||||
|
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.recyclerview.widget.AsyncDifferConfig
|
||||||
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
|
import androidx.recyclerview.widget.ListAdapter
|
||||||
|
import org.fnives.test.showcase.hilt.R
|
||||||
|
import org.fnives.test.showcase.hilt.databinding.ItemFavouriteContentBinding
|
||||||
|
import org.fnives.test.showcase.hilt.ui.shared.ViewBindingAdapter
|
||||||
|
import org.fnives.test.showcase.hilt.ui.shared.executor.AsyncTaskExecutor
|
||||||
|
import org.fnives.test.showcase.hilt.ui.shared.layoutInflater
|
||||||
|
import org.fnives.test.showcase.hilt.ui.shared.loadRoundedImage
|
||||||
|
import org.fnives.test.showcase.model.content.ContentId
|
||||||
|
import org.fnives.test.showcase.model.content.FavouriteContent
|
||||||
|
|
||||||
|
class FavouriteContentAdapter(
|
||||||
|
private val listener: OnFavouriteItemClicked,
|
||||||
|
) : ListAdapter<FavouriteContent, ViewBindingAdapter<ItemFavouriteContentBinding>>(
|
||||||
|
AsyncDifferConfig.Builder(DiffUtilItemCallback())
|
||||||
|
.setBackgroundThreadExecutor(AsyncTaskExecutor.iOThreadExecutor)
|
||||||
|
.build()
|
||||||
|
) {
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewBindingAdapter<ItemFavouriteContentBinding> =
|
||||||
|
ViewBindingAdapter(ItemFavouriteContentBinding.inflate(parent.layoutInflater(), parent, false)).apply {
|
||||||
|
viewBinding.favouriteCta.setOnClickListener {
|
||||||
|
if (adapterPosition in 0 until itemCount) {
|
||||||
|
listener.onFavouriteToggleClicked(getItem(adapterPosition).content.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: ViewBindingAdapter<ItemFavouriteContentBinding>, position: Int) {
|
||||||
|
val item = getItem(position)
|
||||||
|
holder.viewBinding.img.loadRoundedImage(item.content.imageUrl)
|
||||||
|
holder.viewBinding.title.text = item.content.title
|
||||||
|
holder.viewBinding.description.text = item.content.description
|
||||||
|
val favouriteResId = if (item.isFavourite) R.drawable.favorite_24 else R.drawable.favorite_border_24
|
||||||
|
holder.viewBinding.favouriteCta.setImageResource(favouriteResId)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OnFavouriteItemClicked {
|
||||||
|
fun onFavouriteToggleClicked(contentId: ContentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
class DiffUtilItemCallback : DiffUtil.ItemCallback<FavouriteContent>() {
|
||||||
|
override fun areItemsTheSame(oldItem: FavouriteContent, newItem: FavouriteContent): Boolean =
|
||||||
|
oldItem.content.id == newItem.content.id
|
||||||
|
|
||||||
|
override fun areContentsTheSame(oldItem: FavouriteContent, newItem: FavouriteContent): Boolean =
|
||||||
|
oldItem == newItem
|
||||||
|
|
||||||
|
override fun getChangePayload(oldItem: FavouriteContent, newItem: FavouriteContent): Any? = oldItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
package org.fnives.test.showcase.hilt.ui.home
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.fnives.test.showcase.hilt.R
|
||||||
|
import org.fnives.test.showcase.hilt.databinding.ActivityMainBinding
|
||||||
|
import org.fnives.test.showcase.hilt.ui.IntentCoordinator
|
||||||
|
import org.fnives.test.showcase.hilt.ui.shared.VerticalSpaceItemDecoration
|
||||||
|
import org.fnives.test.showcase.hilt.ui.shared.getThemePrimaryColor
|
||||||
|
import org.fnives.test.showcase.hilt.ui.viewModels
|
||||||
|
import org.fnives.test.showcase.model.content.ContentId
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
open class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
private val viewModel by viewModels<MainViewModel>()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val binding = ActivityMainBinding.inflate(layoutInflater)
|
||||||
|
binding.toolbar.menu?.findItem(R.id.logout_cta)?.setOnMenuItemClickListener {
|
||||||
|
viewModel.onLogout()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
binding.swipeRefreshLayout.setColorSchemeColors(binding.swipeRefreshLayout.getThemePrimaryColor())
|
||||||
|
binding.swipeRefreshLayout.setOnRefreshListener {
|
||||||
|
viewModel.onRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
val adapter = FavouriteContentAdapter(viewModel.mapToAdapterListener())
|
||||||
|
binding.recycler.layoutManager = LinearLayoutManager(this)
|
||||||
|
binding.recycler.addItemDecoration(
|
||||||
|
VerticalSpaceItemDecoration(resources.getDimensionPixelOffset(R.dimen.padding))
|
||||||
|
)
|
||||||
|
binding.recycler.adapter = adapter
|
||||||
|
|
||||||
|
viewModel.content.observe(this) {
|
||||||
|
adapter.submitList(it.orEmpty())
|
||||||
|
}
|
||||||
|
viewModel.errorMessage.observe(this) {
|
||||||
|
binding.errorMessage.isVisible = it == true
|
||||||
|
}
|
||||||
|
viewModel.navigateToAuth.observe(this) {
|
||||||
|
it.consume() ?: return@observe
|
||||||
|
startActivity(IntentCoordinator.authActivitygetStartIntent(this))
|
||||||
|
finishAffinity()
|
||||||
|
}
|
||||||
|
viewModel.loading.observe(this) {
|
||||||
|
if (binding.swipeRefreshLayout.isRefreshing != it) {
|
||||||
|
binding.swipeRefreshLayout.isRefreshing = it == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setContentView(binding.root)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun getStartIntent(context: Context): Intent = Intent(context, MainActivity::class.java)
|
||||||
|
|
||||||
|
private fun MainViewModel.mapToAdapterListener(): FavouriteContentAdapter.OnFavouriteItemClicked =
|
||||||
|
object : FavouriteContentAdapter.OnFavouriteItemClicked {
|
||||||
|
override fun onFavouriteToggleClicked(contentId: ContentId) {
|
||||||
|
this@mapToAdapterListener.onFavouriteToggleClicked(contentId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
package org.fnives.test.showcase.hilt.ui.home
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.distinctUntilChanged
|
||||||
|
import androidx.lifecycle.liveData
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
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.LogoutUseCase
|
||||||
|
import org.fnives.test.showcase.hilt.ui.shared.Event
|
||||||
|
import org.fnives.test.showcase.model.content.ContentId
|
||||||
|
import org.fnives.test.showcase.model.content.FavouriteContent
|
||||||
|
import org.fnives.test.showcase.model.shared.Resource
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class MainViewModel @Inject constructor(
|
||||||
|
private val getAllContentUseCase: GetAllContentUseCase,
|
||||||
|
private val logoutUseCase: LogoutUseCase,
|
||||||
|
private val fetchContentUseCase: FetchContentUseCase,
|
||||||
|
private val addContentToFavouriteUseCase: AddContentToFavouriteUseCase,
|
||||||
|
private val removeContentFromFavouritesUseCase: RemoveContentFromFavouritesUseCase
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _loading = MutableLiveData<Boolean>()
|
||||||
|
val loading: LiveData<Boolean> = _loading
|
||||||
|
private val _content: LiveData<List<FavouriteContent>> = liveData {
|
||||||
|
getAllContentUseCase.get().collect {
|
||||||
|
when (it) {
|
||||||
|
is Resource.Error -> {
|
||||||
|
_errorMessage.value = true
|
||||||
|
_loading.value = false
|
||||||
|
emit(emptyList<FavouriteContent>())
|
||||||
|
}
|
||||||
|
is Resource.Loading -> {
|
||||||
|
_errorMessage.value = false
|
||||||
|
_loading.value = true
|
||||||
|
}
|
||||||
|
is Resource.Success -> {
|
||||||
|
_errorMessage.value = false
|
||||||
|
_loading.value = false
|
||||||
|
emit(it.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val content: LiveData<List<FavouriteContent>> = _content
|
||||||
|
private val _errorMessage = MutableLiveData<Boolean>(false)
|
||||||
|
val errorMessage: LiveData<Boolean> = _errorMessage.distinctUntilChanged()
|
||||||
|
private val _navigateToAuth = MutableLiveData<Event<Unit>>()
|
||||||
|
val navigateToAuth: LiveData<Event<Unit>> = _navigateToAuth
|
||||||
|
|
||||||
|
fun onLogout() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
logoutUseCase.invoke()
|
||||||
|
_navigateToAuth.value = Event(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onRefresh() {
|
||||||
|
if (_loading.value == true) return
|
||||||
|
_loading.value = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
fetchContentUseCase.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onFavouriteToggleClicked(contentId: ContentId) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val content = _content.value?.firstOrNull { it.content.id == contentId } ?: return@launch
|
||||||
|
if (content.isFavourite) {
|
||||||
|
removeContentFromFavouritesUseCase.invoke(contentId)
|
||||||
|
} else {
|
||||||
|
addContentToFavouriteUseCase.invoke(contentId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package org.fnives.test.showcase.hilt.ui.shared
|
||||||
|
|
||||||
|
@Suppress("DataClassContainsFunctions")
|
||||||
|
data class Event<T : Any>(private val data: T) {
|
||||||
|
|
||||||
|
private var consumed: Boolean = false
|
||||||
|
|
||||||
|
fun consume(): T? = data.takeUnless { consumed }?.also { consumed = true }
|
||||||
|
|
||||||
|
fun peek() = data
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
package org.fnives.test.showcase.hilt.ui.shared
|
||||||
|
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.view.View
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import androidx.recyclerview.widget.RecyclerView.ItemDecoration
|
||||||
|
|
||||||
|
class VerticalSpaceItemDecoration(private val verticalSpaceHeight: Int) : ItemDecoration() {
|
||||||
|
|
||||||
|
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
|
||||||
|
outRect.set(0, 0, 0, verticalSpaceHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package org.fnives.test.showcase.hilt.ui.shared
|
||||||
|
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import androidx.viewbinding.ViewBinding
|
||||||
|
|
||||||
|
class ViewBindingAdapter<T : ViewBinding>(val viewBinding: T) : RecyclerView.ViewHolder(viewBinding.root)
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
package org.fnives.test.showcase.hilt.ui.shared
|
||||||
|
|
||||||
|
import android.util.TypedValue
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.ImageView
|
||||||
|
import coil.load
|
||||||
|
import coil.transform.RoundedCornersTransformation
|
||||||
|
import org.fnives.test.showcase.hilt.R
|
||||||
|
import org.fnives.test.showcase.model.content.ImageUrl
|
||||||
|
|
||||||
|
fun View.layoutInflater(): LayoutInflater = LayoutInflater.from(context)
|
||||||
|
|
||||||
|
fun ImageView.loadRoundedImage(imageUrl: ImageUrl) {
|
||||||
|
load(imageUrl.url) {
|
||||||
|
transformations(RoundedCornersTransformation(resources.getDimension(R.dimen.rounded_corner)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun View.getThemePrimaryColor(): Int {
|
||||||
|
val value = TypedValue()
|
||||||
|
context.theme.resolveAttribute(R.attr.colorPrimary, value, true)
|
||||||
|
return value.data
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
package org.fnives.test.showcase.hilt.ui.shared.executor
|
||||||
|
|
||||||
|
import java.util.concurrent.Executor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basic copy of [ArchTaskExecutor][androidx.arch.core.executor.ArchTaskExecutor], needed because that is restricted to Library.
|
||||||
|
*
|
||||||
|
* Intended to be used for [AsyncDifferConfig][androidx.recyclerview.widget.AsyncDifferConfig] so it can be synchronized with Espresso.
|
||||||
|
*
|
||||||
|
* Workaround until https://github.com/android/android-test/issues/382 is fixed finally.
|
||||||
|
*/
|
||||||
|
object AsyncTaskExecutor : TaskExecutor {
|
||||||
|
|
||||||
|
val mainThreadExecutor = Executor { command -> postToMainThread(command) }
|
||||||
|
val iOThreadExecutor = Executor { command -> executeOnDiskIO(command) }
|
||||||
|
|
||||||
|
var delegate: TaskExecutor? = null
|
||||||
|
private val defaultExecutor by lazy { DefaultTaskExecutor() }
|
||||||
|
private val executor get() = delegate ?: defaultExecutor
|
||||||
|
|
||||||
|
override fun executeOnDiskIO(runnable: Runnable) {
|
||||||
|
executor.executeOnDiskIO(runnable)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun postToMainThread(runnable: Runnable) {
|
||||||
|
executor.postToMainThread(runnable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
package org.fnives.test.showcase.hilt.ui.shared.executor
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basic copy of [androidx.arch.core.executor.DefaultTaskExecutor], needed because that is restricted to Library.
|
||||||
|
* With a Flavour of [androidx.recyclerview.widget.AsyncDifferConfig].
|
||||||
|
* Used within [AsyncTaskExecutor].
|
||||||
|
*
|
||||||
|
* Intended to be used for AsyncDiffUtil so it can be synchronized with Espresso.
|
||||||
|
*/
|
||||||
|
class DefaultTaskExecutor : TaskExecutor {
|
||||||
|
|
||||||
|
private val diskIO = Executors.newFixedThreadPool(2)
|
||||||
|
private val mMainHandler: Handler by lazy { createAsync(Looper.getMainLooper()) }
|
||||||
|
|
||||||
|
override fun executeOnDiskIO(runnable: Runnable) {
|
||||||
|
diskIO.execute(runnable)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun postToMainThread(runnable: Runnable) {
|
||||||
|
mMainHandler.post(runnable)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createAsync(looper: Looper): Handler =
|
||||||
|
if (Build.VERSION.SDK_INT >= 28) {
|
||||||
|
Handler.createAsync(looper)
|
||||||
|
} else {
|
||||||
|
Handler(looper)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
package org.fnives.test.showcase.hilt.ui.shared.executor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define TaskExecutor intended for [AsyncDifferConfig][androidx.recyclerview.widget.AsyncDifferConfig]
|
||||||
|
*/
|
||||||
|
interface TaskExecutor {
|
||||||
|
fun executeOnDiskIO(runnable: Runnable)
|
||||||
|
|
||||||
|
fun postToMainThread(runnable: Runnable)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
package org.fnives.test.showcase.hilt.ui.splash
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.fnives.test.showcase.hilt.R
|
||||||
|
import org.fnives.test.showcase.hilt.ui.IntentCoordinator
|
||||||
|
import org.fnives.test.showcase.hilt.ui.viewModels
|
||||||
|
|
||||||
|
@SuppressLint("CustomSplashScreen")
|
||||||
|
@AndroidEntryPoint
|
||||||
|
open class SplashActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
private val viewModel by viewModels<SplashViewModel>()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(R.layout.activity_splash)
|
||||||
|
viewModel.navigateTo.observe(this) {
|
||||||
|
val intent = when (it.consume()) {
|
||||||
|
SplashViewModel.NavigateTo.HOME -> IntentCoordinator.mainActivitygetStartIntent(this)
|
||||||
|
SplashViewModel.NavigateTo.AUTHENTICATION -> IntentCoordinator.authActivitygetStartIntent(this)
|
||||||
|
null -> return@observe
|
||||||
|
}
|
||||||
|
startActivity(intent)
|
||||||
|
finishAffinity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
package org.fnives.test.showcase.hilt.ui.splash
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.fnives.test.showcase.hilt.core.login.IsUserLoggedInUseCase
|
||||||
|
import org.fnives.test.showcase.hilt.ui.shared.Event
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class SplashViewModel @Inject constructor(isUserLoggedInUseCase: IsUserLoggedInUseCase) : ViewModel() {
|
||||||
|
|
||||||
|
private val _navigateTo = MutableLiveData<Event<NavigateTo>>()
|
||||||
|
val navigateTo: LiveData<Event<NavigateTo>> = _navigateTo
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
delay(500L)
|
||||||
|
val navigationEvent = if (isUserLoggedInUseCase.invoke()) NavigateTo.HOME else NavigateTo.AUTHENTICATION
|
||||||
|
_navigateTo.value = Event(navigationEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class NavigateTo {
|
||||||
|
HOME, AUTHENTICATION
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<!-- shadow -->
|
||||||
|
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="85.84757"
|
||||||
|
android:endY="92.4963"
|
||||||
|
android:startX="42.9492"
|
||||||
|
android:startY="49.59793"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<!-- "compose icon" -->
|
||||||
|
<path
|
||||||
|
android:pathData="M20,34 L20,68 L40,88"
|
||||||
|
android:strokeWidth="15"
|
||||||
|
android:strokeColor="#132d3d" />
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:pathData="M40,88 L68,68 L68,34"
|
||||||
|
android:strokeWidth="15"
|
||||||
|
android:strokeColor="#4d7fe0" />
|
||||||
|
<path
|
||||||
|
android:pathData="M18,38 L44,18 L72,38"
|
||||||
|
android:strokeWidth="15"
|
||||||
|
android:strokeColor="#6bcd85" />
|
||||||
|
<!-- android head -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||||
|
android:strokeWidth="1"
|
||||||
|
android:strokeColor="#00000000" />
|
||||||
|
</vector>
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:startY="49.59793"
|
||||||
|
android:startX="42.9492"
|
||||||
|
android:endY="92.4963"
|
||||||
|
android:endX="85.84757"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:strokeWidth="1"
|
||||||
|
android:strokeColor="#00000000"/>
|
||||||
|
</vector>
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue