initial commit
76
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
# Built application files
|
||||||
|
*.apk
|
||||||
|
*.aar
|
||||||
|
*.ap_
|
||||||
|
*.aab
|
||||||
|
|
||||||
|
# Files for the ART/Dalvik VM
|
||||||
|
*.dex
|
||||||
|
|
||||||
|
# Java class files
|
||||||
|
*.class
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
bin/
|
||||||
|
gen/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# Gradle files
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Local configuration file (sdk path, etc)
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Proguard folder generated by Eclipse
|
||||||
|
proguard/
|
||||||
|
|
||||||
|
# Log Files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Android Studio Navigation editor temp files
|
||||||
|
.navigation/
|
||||||
|
|
||||||
|
# Android Studio captures folder
|
||||||
|
captures/
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
*.iml
|
||||||
|
app/app.iml
|
||||||
|
.idea/
|
||||||
|
.idea/workspace.xml
|
||||||
|
.idea/tasks.xml
|
||||||
|
.idea/gradle.xml
|
||||||
|
.idea/assetWizardSettings.xml
|
||||||
|
.idea/dictionaries
|
||||||
|
.idea/libraries
|
||||||
|
# Android Studio 3 in .gitignore file.
|
||||||
|
.idea/caches
|
||||||
|
.idea/modules.xml
|
||||||
|
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||||
|
.idea/navEditor.xml
|
||||||
|
|
||||||
|
# Keystore files
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
|
||||||
|
# External native build folder generated in Android Studio 2.2 and later
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# fastlane
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots
|
||||||
|
fastlane/test_output
|
||||||
|
fastlane/readme.md
|
||||||
|
|
||||||
|
# Version control
|
||||||
|
vcs.xml
|
||||||
|
|
||||||
|
# lint
|
||||||
|
lint/intermediates/
|
||||||
|
lint/generated/
|
||||||
|
lint/outputs/
|
||||||
|
lint/tmp/
|
||||||
|
lint/reports/
|
||||||
1
app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/build
|
||||||
144
app/build.gradle
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
plugins {
|
||||||
|
id 'com.android.application'
|
||||||
|
id 'kotlin-android'
|
||||||
|
id 'kotlin-kapt'
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdkVersion 30
|
||||||
|
buildToolsVersion "30.0.3"
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "org.fnives.test.showcase"
|
||||||
|
minSdkVersion 21
|
||||||
|
targetSdkVersion 30
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0"
|
||||||
|
buildConfigField "String", "BASE_URL", '"https://606844a10add49001733fe6b.mockapi.io/"'
|
||||||
|
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
viewBinding true
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '1.8'
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
androidTest {
|
||||||
|
java.srcDirs += "src/sharedTest/java"
|
||||||
|
}
|
||||||
|
test {
|
||||||
|
java.srcDirs += "src/sharedTest/java"
|
||||||
|
java.srcDirs += "src/robolectricTest/java"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testOptions.unitTests.all {
|
||||||
|
useJUnitPlatform()
|
||||||
|
testLogging {
|
||||||
|
events 'started', 'passed', 'skipped', 'failed'
|
||||||
|
exceptionFormat "full"
|
||||||
|
showStandardStreams true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
testOptions {
|
||||||
|
unitTests {
|
||||||
|
includeAndroidResources = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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'
|
||||||
|
}
|
||||||
|
|
||||||
|
lintOptions {
|
||||||
|
warningsAsErrors true
|
||||||
|
abortOnError true
|
||||||
|
textReport true
|
||||||
|
ignore 'Overdraw'
|
||||||
|
textOutput "stdout"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||||
|
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.lifecycle:lifecycle-livedata-core-ktx:$androidx_livedata_version"
|
||||||
|
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$androidx_livedata_version"
|
||||||
|
implementation "androidx.swiperefreshlayout:swiperefreshlayout:$androidx_swiperefreshlayout_version"
|
||||||
|
|
||||||
|
// Koin
|
||||||
|
implementation "org.koin:koin-androidx-scope:$koin_version"
|
||||||
|
implementation "org.koin:koin-androidx-viewmodel:$koin_version"
|
||||||
|
implementation "org.koin:koin-androidx-fragment:$koin_version"
|
||||||
|
|
||||||
|
implementation "androidx.room:room-runtime:$androidx_room_version"
|
||||||
|
kapt "androidx.room:room-compiler:$androidx_room_version"
|
||||||
|
implementation "androidx.room:room-ktx:$androidx_room_version"
|
||||||
|
|
||||||
|
implementation "io.coil-kt:coil:$coil_version"
|
||||||
|
|
||||||
|
implementation project(":core")
|
||||||
|
releaseImplementation project(":core")
|
||||||
|
|
||||||
|
testImplementation "androidx.room:room-testing:$androidx_room_version"
|
||||||
|
testImplementation "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_version"
|
||||||
|
testImplementation "org.junit.jupiter:junit-jupiter-params:$testing_junit5_version"
|
||||||
|
testImplementation "org.mockito.kotlin:mockito-kotlin:$testing_kotlin_mockito_version"
|
||||||
|
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
|
||||||
|
testImplementation "com.jraska.livedata:testing-ktx:$testing_livedata_version"
|
||||||
|
testImplementation "org.koin:koin-test:$koin_version"
|
||||||
|
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_version"
|
||||||
|
|
||||||
|
// robolectric specific
|
||||||
|
testImplementation "junit:junit:$testing_junit4_version"
|
||||||
|
testImplementation "org.robolectric:robolectric:$testing_robolectric_version"
|
||||||
|
testImplementation "androidx.test:core:$testing_androidx_code_version"
|
||||||
|
testImplementation "androidx.test:runner:$testing_androidx_code_version"
|
||||||
|
testImplementation "androidx.test.ext:junit:$testing_androidx_junit_version"
|
||||||
|
testImplementation "androidx.test.espresso:espresso-core:$testing_espresso_version"
|
||||||
|
testImplementation "androidx.test.espresso:espresso-intents:$testing_espresso_version"
|
||||||
|
testImplementation project(':mockserver')
|
||||||
|
testImplementation "com.jakewharton.espresso:okhttp3-idling-resource:$testing_okhttp3_idling_resource_version"
|
||||||
|
testImplementation "androidx.arch.core:core-testing:$testing_androidx_arch_core_version"
|
||||||
|
testRuntimeOnly "org.junit.vintage:junit-vintage-engine:$testing_junit5_version"
|
||||||
|
|
||||||
|
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
|
||||||
|
androidTestImplementation "org.koin:koin-test:$koin_version"
|
||||||
|
androidTestImplementation "junit:junit:$testing_junit4_version"
|
||||||
|
androidTestImplementation "androidx.test:core:$testing_androidx_code_version"
|
||||||
|
androidTestImplementation "androidx.test:runner:$testing_androidx_code_version"
|
||||||
|
androidTestImplementation "androidx.test.ext:junit:$testing_androidx_junit_version"
|
||||||
|
androidTestImplementation "androidx.test.espresso:espresso-core:$testing_espresso_version"
|
||||||
|
androidTestImplementation "androidx.test.espresso:espresso-intents:$testing_espresso_version"
|
||||||
|
androidTestImplementation project(':mockserver')
|
||||||
|
androidTestImplementation "com.jakewharton.espresso:okhttp3-idling-resource:$testing_okhttp3_idling_resource_version"
|
||||||
|
androidTestImplementation "androidx.arch.core:core-testing:$testing_androidx_arch_core_version"
|
||||||
|
androidTestRuntimeOnly "org.junit.vintage:junit-vintage-engine:$testing_junit5_version"
|
||||||
|
}
|
||||||
21
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,5 @@
|
||||||
|
package org.fnives.test.showcase.testutils.configuration
|
||||||
|
|
||||||
|
object AndroidTestLoginRobotConfiguration : LoginRobotConfiguration {
|
||||||
|
override val assertLoadingBeforeRequest: Boolean get() = false
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
package org.fnives.test.showcase.testutils.configuration
|
||||||
|
|
||||||
|
import androidx.test.espresso.Espresso
|
||||||
|
import androidx.test.espresso.NoActivityResumedException
|
||||||
|
import androidx.test.espresso.assertion.ViewAssertions
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import org.fnives.test.showcase.storage.database.DatabaseInitialization
|
||||||
|
import org.fnives.test.showcase.testutils.idling.loopMainThreadFor
|
||||||
|
import org.fnives.test.showcase.testutils.idling.loopMainThreadUntilIdleWithIdlingResources
|
||||||
|
import org.junit.runner.Description
|
||||||
|
import org.junit.runners.model.Statement
|
||||||
|
|
||||||
|
class AndroidTestMainDispatcherTestRule : MainDispatcherTestRule {
|
||||||
|
|
||||||
|
override fun apply(base: Statement, description: Description): Statement =
|
||||||
|
object : Statement() {
|
||||||
|
@Throws(Throwable::class)
|
||||||
|
override fun evaluate() {
|
||||||
|
DatabaseInitialization.dispatcher = Dispatchers.Main.immediate
|
||||||
|
base.evaluate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun advanceUntilIdleWithIdlingResources() {
|
||||||
|
loopMainThreadUntilIdleWithIdlingResources()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun advanceUntilIdleOrActivityIsDestroyed() {
|
||||||
|
try {
|
||||||
|
advanceUntilIdleWithIdlingResources()
|
||||||
|
Espresso.onView(ViewMatchers.isRoot()).check(ViewAssertions.doesNotExist())
|
||||||
|
} catch (noActivityResumedException: NoActivityResumedException) {
|
||||||
|
// expected to happen
|
||||||
|
} catch (runtimeException: RuntimeException) {
|
||||||
|
if (runtimeException.message?.contains("No activities found") == true) {
|
||||||
|
// expected to happen
|
||||||
|
} else {
|
||||||
|
throw runtimeException
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun advanceUntilIdle() {
|
||||||
|
loopMainThreadUntilIdleWithIdlingResources()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun advanceTimeBy(delayInMillis: Long) {
|
||||||
|
loopMainThreadFor(delayInMillis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
package org.fnives.test.showcase.testutils.configuration
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
|
||||||
|
import org.fnives.test.showcase.testutils.idling.NetworkSynchronization
|
||||||
|
import org.koin.core.context.loadKoinModules
|
||||||
|
import org.koin.core.qualifier.StringQualifier
|
||||||
|
import org.koin.dsl.module
|
||||||
|
import org.koin.test.KoinTest
|
||||||
|
import org.koin.test.get
|
||||||
|
|
||||||
|
object AndroidTestServerTypeConfiguration : ServerTypeConfiguration, KoinTest {
|
||||||
|
override val useHttps: Boolean get() = true
|
||||||
|
|
||||||
|
override val url: String get() = "${MockServerScenarioSetup.HTTPS_BASE_URL}:${MockServerScenarioSetup.PORT}/"
|
||||||
|
|
||||||
|
override fun invoke(mockServerScenarioSetup: MockServerScenarioSetup) {
|
||||||
|
val handshakeCertificates = mockServerScenarioSetup.clientCertificates ?: return
|
||||||
|
val sessionless = StringQualifier(NetworkSynchronization.OkHttpClientTypes.SESSIONLESS.qualifier)
|
||||||
|
val okHttpClientWithCertificate = get<OkHttpClient>(sessionless).newBuilder()
|
||||||
|
.sslSocketFactory(handshakeCertificates.sslSocketFactory(), handshakeCertificates.trustManager)
|
||||||
|
.build()
|
||||||
|
loadKoinModules(
|
||||||
|
module {
|
||||||
|
single(qualifier = sessionless, override = true) { okHttpClientWithCertificate }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
package org.fnives.test.showcase.testutils.configuration
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.test.espresso.Espresso
|
||||||
|
import androidx.test.espresso.UiController
|
||||||
|
import androidx.test.espresso.ViewAction
|
||||||
|
import androidx.test.espresso.action.ViewActions
|
||||||
|
import androidx.test.espresso.assertion.ViewAssertions
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers
|
||||||
|
import com.google.android.material.R
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import org.hamcrest.Matcher
|
||||||
|
import org.hamcrest.Matchers
|
||||||
|
import org.junit.runner.Description
|
||||||
|
import org.junit.runners.model.Statement
|
||||||
|
|
||||||
|
object AndroidTestSnackbarVerificationTestRule : SnackbarVerificationTestRule {
|
||||||
|
|
||||||
|
override fun apply(base: Statement, description: Description): Statement = base
|
||||||
|
|
||||||
|
override fun assertIsShownWithText(@StringRes stringResID: Int) {
|
||||||
|
Espresso.onView(ViewMatchers.withId(R.id.snackbar_text))
|
||||||
|
.check(ViewAssertions.matches(ViewMatchers.withText(stringResID)))
|
||||||
|
Espresso.onView(ViewMatchers.isAssignableFrom(Snackbar.SnackbarLayout::class.java)).perform(ViewActions.swipeRight())
|
||||||
|
Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainUntilSnackbarDismissed())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun assertIsNotShown() {
|
||||||
|
Espresso.onView(ViewMatchers.withId(R.id.snackbar_text)).check(ViewAssertions.doesNotExist())
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoopMainUntilSnackbarDismissed() : ViewAction {
|
||||||
|
override fun getConstraints(): Matcher<View> = Matchers.isA(View::class.java)
|
||||||
|
|
||||||
|
override fun getDescription(): String = "loop MainThread until Snackbar is Dismissed"
|
||||||
|
|
||||||
|
override fun perform(uiController: UiController, view: View?) {
|
||||||
|
while (view?.findViewById<View>(com.google.android.material.R.id.snackbar_text) != null) {
|
||||||
|
uiController.loopMainThreadForAtLeast(100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
package org.fnives.test.showcase.testutils.configuration
|
||||||
|
|
||||||
|
object SpecificTestConfigurationsFactory : TestConfigurationsFactory {
|
||||||
|
override fun createMainDispatcherTestRule(): MainDispatcherTestRule =
|
||||||
|
AndroidTestMainDispatcherTestRule()
|
||||||
|
|
||||||
|
override fun createServerTypeConfiguration(): ServerTypeConfiguration =
|
||||||
|
AndroidTestServerTypeConfiguration
|
||||||
|
|
||||||
|
override fun createLoginRobotConfiguration(): LoginRobotConfiguration =
|
||||||
|
AndroidTestLoginRobotConfiguration
|
||||||
|
|
||||||
|
override fun createSnackbarVerification(): SnackbarVerificationTestRule =
|
||||||
|
AndroidTestSnackbarVerificationTestRule
|
||||||
|
}
|
||||||
28
app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?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">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:name=".TestShowcaseApplication"
|
||||||
|
android:theme="@style/Theme.TestShowCase"
|
||||||
|
tools:ignore="AllowBackup">
|
||||||
|
<activity android:name=".ui.splash.SplashActivity">
|
||||||
|
<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"/>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
BIN
app/src/main/ic_launcher-playstore.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
|
@ -0,0 +1,18 @@
|
||||||
|
package org.fnives.test.showcase
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import org.fnives.test.showcase.di.BaseUrlProvider
|
||||||
|
import org.fnives.test.showcase.di.createAppModules
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
|
import org.koin.core.context.startKoin
|
||||||
|
|
||||||
|
class TestShowcaseApplication : Application() {
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
startKoin {
|
||||||
|
androidContext(this@TestShowcaseApplication)
|
||||||
|
modules(createAppModules(BaseUrlProvider.get()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package org.fnives.test.showcase.di
|
||||||
|
|
||||||
|
import org.fnives.test.showcase.BuildConfig
|
||||||
|
import org.fnives.test.showcase.model.network.BaseUrl
|
||||||
|
|
||||||
|
object BaseUrlProvider {
|
||||||
|
|
||||||
|
fun get() = BaseUrl(BuildConfig.BASE_URL)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
package org.fnives.test.showcase.di
|
||||||
|
|
||||||
|
import org.fnives.test.showcase.core.di.createCoreModule
|
||||||
|
import org.fnives.test.showcase.model.network.BaseUrl
|
||||||
|
import org.fnives.test.showcase.session.SessionExpirationListenerImpl
|
||||||
|
import org.fnives.test.showcase.storage.LocalDatabase
|
||||||
|
import org.fnives.test.showcase.storage.SharedPreferencesManagerImpl
|
||||||
|
import org.fnives.test.showcase.storage.database.DatabaseInitialization
|
||||||
|
import org.fnives.test.showcase.storage.favourite.FavouriteContentLocalStorageImpl
|
||||||
|
import org.fnives.test.showcase.ui.auth.AuthViewModel
|
||||||
|
import org.fnives.test.showcase.ui.home.MainViewModel
|
||||||
|
import org.fnives.test.showcase.ui.splash.SplashViewModel
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
|
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||||
|
import org.koin.core.module.Module
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
fun createAppModules(baseUrl: BaseUrl): List<Module> {
|
||||||
|
return createCoreModule(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
true,
|
||||||
|
userDataLocalStorageProvider = { get<SharedPreferencesManagerImpl>() },
|
||||||
|
sessionExpirationListenerProvider = { get<SessionExpirationListenerImpl>() },
|
||||||
|
favouriteContentLocalStorageProvider = { get<FavouriteContentLocalStorageImpl>() }
|
||||||
|
)
|
||||||
|
.plus(storageModule())
|
||||||
|
.plus(authModule())
|
||||||
|
.plus(appModule())
|
||||||
|
.plus(favouriteModule())
|
||||||
|
.plus(splashModule())
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun storageModule() = module {
|
||||||
|
single { SharedPreferencesManagerImpl.create(androidContext()) }
|
||||||
|
single { DatabaseInitialization.create(androidContext()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun authModule() = module {
|
||||||
|
viewModel { AuthViewModel(get()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun appModule() = module {
|
||||||
|
single { SessionExpirationListenerImpl(androidContext()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun splashModule() = module {
|
||||||
|
viewModel { SplashViewModel(get()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun favouriteModule() = module {
|
||||||
|
single { get<LocalDatabase>().favouriteDao }
|
||||||
|
viewModel { MainViewModel(get(), get(), get(), get(), get()) }
|
||||||
|
single { FavouriteContentLocalStorageImpl(get()) }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package org.fnives.test.showcase.session
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import org.fnives.test.showcase.core.session.SessionExpirationListener
|
||||||
|
import org.fnives.test.showcase.ui.auth.AuthActivity
|
||||||
|
|
||||||
|
class SessionExpirationListenerImpl(private val context: Context) : SessionExpirationListener {
|
||||||
|
|
||||||
|
override fun onSessionExpired() {
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
context.startActivity(
|
||||||
|
AuthActivity.getStartIntent(context)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package org.fnives.test.showcase.storage
|
||||||
|
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import org.fnives.test.showcase.storage.favourite.FavouriteDao
|
||||||
|
import org.fnives.test.showcase.storage.favourite.FavouriteEntity
|
||||||
|
|
||||||
|
@Database(entities = [FavouriteEntity::class], version = 1, exportSchema = false)
|
||||||
|
abstract class LocalDatabase : RoomDatabase() {
|
||||||
|
|
||||||
|
abstract val favouriteDao: FavouriteDao
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
package org.fnives.test.showcase.storage
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import org.fnives.test.showcase.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,13 @@
|
||||||
|
package org.fnives.test.showcase.storage.database
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Room
|
||||||
|
import org.fnives.test.showcase.storage.LocalDatabase
|
||||||
|
|
||||||
|
object DatabaseInitialization {
|
||||||
|
|
||||||
|
fun create(context: Context): LocalDatabase =
|
||||||
|
Room.databaseBuilder(context, LocalDatabase::class.java, "local_database")
|
||||||
|
.allowMainThreadQueries()
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
package org.fnives.test.showcase.storage.favourite
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage
|
||||||
|
import org.fnives.test.showcase.model.content.ContentId
|
||||||
|
|
||||||
|
class FavouriteContentLocalStorageImpl(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.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,7 @@
|
||||||
|
package org.fnives.test.showcase.storage.favourite
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
data class FavouriteEntity(@PrimaryKey val contentId: String)
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
package org.fnives.test.showcase.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 org.fnives.test.showcase.R
|
||||||
|
import org.fnives.test.showcase.databinding.ActivityAuthenticationBinding
|
||||||
|
import org.fnives.test.showcase.ui.home.MainActivity
|
||||||
|
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||||
|
|
||||||
|
class AuthActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
private val viewModel by viewModel<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(MainActivity.getStartIntent(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,66 @@
|
||||||
|
package org.fnives.test.showcase.ui.auth
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.fnives.test.showcase.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
|
||||||
|
import org.fnives.test.showcase.ui.shared.Event
|
||||||
|
|
||||||
|
class AuthViewModel(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.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,51 @@
|
||||||
|
package org.fnives.test.showcase.ui.home
|
||||||
|
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
|
import androidx.recyclerview.widget.ListAdapter
|
||||||
|
import org.fnives.test.showcase.R
|
||||||
|
import org.fnives.test.showcase.databinding.ItemFavouriteContentBinding
|
||||||
|
import org.fnives.test.showcase.model.content.ContentId
|
||||||
|
import org.fnives.test.showcase.model.content.FavouriteContent
|
||||||
|
import org.fnives.test.showcase.ui.shared.ViewBindingAdapter
|
||||||
|
import org.fnives.test.showcase.ui.shared.layoutInflater
|
||||||
|
import org.fnives.test.showcase.ui.shared.loadRoundedImage
|
||||||
|
|
||||||
|
class FavouriteContentAdapter(
|
||||||
|
private val listener: OnFavouriteItemClicked,
|
||||||
|
) : ListAdapter<FavouriteContent, ViewBindingAdapter<ItemFavouriteContentBinding>>(
|
||||||
|
DiffUtilItemCallback()
|
||||||
|
) {
|
||||||
|
|
||||||
|
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,68 @@
|
||||||
|
package org.fnives.test.showcase.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 org.fnives.test.showcase.R
|
||||||
|
import org.fnives.test.showcase.databinding.ActivityMainBinding
|
||||||
|
import org.fnives.test.showcase.model.content.ContentId
|
||||||
|
import org.fnives.test.showcase.ui.auth.AuthActivity
|
||||||
|
import org.fnives.test.showcase.ui.shared.VerticalSpaceItemDecoration
|
||||||
|
import org.fnives.test.showcase.ui.shared.getThemePrimaryColor
|
||||||
|
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||||
|
|
||||||
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
private val viewModel by viewModel<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(AuthActivity.getStartIntent(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,81 @@
|
||||||
|
package org.fnives.test.showcase.ui.home
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.liveData
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.fnives.test.showcase.core.content.AddContentToFavouriteUseCase
|
||||||
|
import org.fnives.test.showcase.core.content.FetchContentUseCase
|
||||||
|
import org.fnives.test.showcase.core.content.GetAllContentUseCase
|
||||||
|
import org.fnives.test.showcase.core.content.RemoveContentFromFavouritesUseCase
|
||||||
|
import org.fnives.test.showcase.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
|
||||||
|
import org.fnives.test.showcase.ui.shared.Event
|
||||||
|
|
||||||
|
class MainViewModel(
|
||||||
|
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
|
||||||
|
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.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.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.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.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.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,27 @@
|
||||||
|
package org.fnives.test.showcase.ui.splash
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import org.fnives.test.showcase.R
|
||||||
|
import org.fnives.test.showcase.ui.auth.AuthActivity
|
||||||
|
import org.fnives.test.showcase.ui.home.MainActivity
|
||||||
|
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||||
|
|
||||||
|
class SplashActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
private val viewModel by viewModel<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 -> MainActivity.getStartIntent(this)
|
||||||
|
SplashViewModel.NavigateTo.AUTHENTICATION -> AuthActivity.getStartIntent(this)
|
||||||
|
null -> return@observe
|
||||||
|
}
|
||||||
|
startActivity(intent)
|
||||||
|
finishAffinity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
package org.fnives.test.showcase.ui.splash
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase
|
||||||
|
import org.fnives.test.showcase.ui.shared.Event
|
||||||
|
|
||||||
|
class SplashViewModel(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
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/src/main/res/drawable-v24/ic_launcher_foreground.xml
Normal file
|
|
@ -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>
|
||||||
10
app/src/main/res/drawable/favorite_24.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorPrimary">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
|
||||||
|
</vector>
|
||||||
10
app/src/main/res/drawable/favorite_border_24.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorPrimary">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M16.5,3c-1.74,0 -3.41,0.81 -4.5,2.09C10.91,3.81 9.24,3 7.5,3 4.42,3 2,5.42 2,8.5c0,3.78 3.4,6.86 8.55,11.54L12,21.35l1.45,-1.32C18.6,15.36 22,12.28 22,8.5 22,5.42 19.58,3 16.5,3zM12.1,18.55l-0.1,0.1 -0.1,-0.1C7.14,14.24 4,11.39 4,8.5 4,6.5 5.5,5 7.5,5c1.54,0 3.04,0.99 3.57,2.36h1.87C13.46,5.99 14.96,5 16.5,5c2,0 3.5,1.5 3.5,3.5 0,2.89 -3.14,5.74 -7.9,10.05z"/>
|
||||||
|
</vector>
|
||||||
10
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/purple_700"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
</vector>
|
||||||
11
app/src/main/res/drawable/logout_24.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorOnSurface"
|
||||||
|
android:autoMirrored="true">
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/white"
|
||||||
|
android:pathData="M17,7l-1.41,1.41L18.17,11H8v2h10.17l-2.58,2.58L17,17l5,-5zM4,5h8V3H4c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h8v-2H4V5z"/>
|
||||||
|
</vector>
|
||||||
98
app/src/main/res/layout/activity_authentication.xml
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.Toolbar
|
||||||
|
android:id="@+id/toolbar"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?attr/colorSurface"
|
||||||
|
android:minHeight="?attr/actionBarSize"
|
||||||
|
android:elevation="@dimen/toolbar_elevation"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:title="@string/login_title" />
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/user_input"
|
||||||
|
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="@dimen/activity_horizontal_margin"
|
||||||
|
android:layout_marginEnd="@dimen/activity_horizontal_margin"
|
||||||
|
android:hint="@string/username"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/password_input"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/toolbar"
|
||||||
|
app:layout_constraintVertical_bias="0.2"
|
||||||
|
app:layout_constraintVertical_chainStyle="packed">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/user_edit_text"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="text"
|
||||||
|
android:lines="1" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/password_input"
|
||||||
|
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="@dimen/activity_horizontal_margin"
|
||||||
|
android:layout_marginTop="@dimen/default_margin"
|
||||||
|
android:layout_marginEnd="@dimen/activity_horizontal_margin"
|
||||||
|
android:hint="@string/password"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/login_cta"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/user_input"
|
||||||
|
app:passwordToggleEnabled="true">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/password_edit_text"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="textPassword"
|
||||||
|
android:lines="1" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/loading_indicator"
|
||||||
|
style="?attr/progressBarStyle"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="@dimen/default_margin"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/login_cta"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent" />
|
||||||
|
|
||||||
|
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||||
|
android:id="@+id/snackbar_holder"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="@dimen/activity_horizontal_margin"
|
||||||
|
android:layout_marginEnd="@dimen/activity_horizontal_margin"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/login_cta"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/login_cta"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="@dimen/activity_horizontal_margin"
|
||||||
|
android:layout_marginEnd="@dimen/activity_horizontal_margin"
|
||||||
|
android:layout_marginBottom="@dimen/default_margin"
|
||||||
|
app:layout_constraintHeight_min="@dimen/default_button_height"
|
||||||
|
android:text="@string/login"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
54
app/src/main/res/layout/activity_main.xml
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:context=".ui.home.MainActivity">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.Toolbar
|
||||||
|
android:id="@+id/toolbar"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:minHeight="?attr/actionBarSize"
|
||||||
|
android:background="?attr/colorSurface"
|
||||||
|
android:elevation="@dimen/toolbar_elevation"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:menu="@menu/main"
|
||||||
|
app:title="@string/content" />
|
||||||
|
|
||||||
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
|
android:id="@+id/swipe_refresh_layout"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
android:layout_marginStart="@dimen/activity_horizontal_margin"
|
||||||
|
android:layout_marginEnd="@dimen/activity_horizontal_margin"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/toolbar">
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/recycler"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
tools:listitem="@layout/item_favourite_content" />
|
||||||
|
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/error_message"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="@dimen/activity_horizontal_margin"
|
||||||
|
android:layout_marginEnd="@dimen/activity_horizontal_margin"
|
||||||
|
android:text="@string/something_went_wrong"
|
||||||
|
android:gravity="center"
|
||||||
|
android:textAppearance="?attr/textAppearanceHeadline4"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
16
app/src/main/res/layout/activity_splash.xml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:background="?attr/colorSurface">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="@dimen/content_img_height"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
app:srcCompat="@mipmap/ic_launcher_round"
|
||||||
|
android:layout_height="@dimen/content_img_height"
|
||||||
|
tools:ignore="ContentDescription" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
56
app/src/main/res/layout/item_favourite_content.xml
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/img"
|
||||||
|
android:layout_width="@dimen/content_img_height"
|
||||||
|
android:layout_height="@dimen/content_img_height"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
tools:ignore="ContentDescription"
|
||||||
|
tools:src="@tools:sample/avatars" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/title"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="@dimen/default_margin"
|
||||||
|
android:layout_marginBottom="@dimen/padding"
|
||||||
|
android:textAppearance="?attr/textAppearanceHeadline6"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/description"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/img"
|
||||||
|
app:layout_constraintEnd_toStartOf="@id/favourite_cta"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintVertical_chainStyle="packed"
|
||||||
|
tools:text="@tools:sample/last_names" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/description"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="@dimen/default_margin"
|
||||||
|
android:layout_marginTop="@dimen/padding"
|
||||||
|
android:textAppearance="?attr/textAppearanceSubtitle2"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toStartOf="@id/favourite_cta"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/img"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/title"
|
||||||
|
tools:text="@tools:sample/last_names" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/favourite_cta"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:layout_width="@dimen/touch_target_size"
|
||||||
|
android:layout_height="@dimen/touch_target_size"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
tools:ignore="ContentDescription"
|
||||||
|
tools:src="@drawable/favorite_24" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
9
app/src/main/res/menu/main.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
<item
|
||||||
|
android:id="@+id/logout_cta"
|
||||||
|
android:icon="@drawable/logout_24"
|
||||||
|
android:title="@string/logout"
|
||||||
|
app:showAsAction="always" />
|
||||||
|
</menu>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
16
app/src/main/res/values-night/themes.xml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="Theme.TestShowCase" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||||
|
<!-- Primary brand color. -->
|
||||||
|
<item name="colorPrimary">@color/purple_200</item>
|
||||||
|
<item name="colorPrimaryVariant">@color/purple_700</item>
|
||||||
|
<item name="colorOnPrimary">@color/black</item>
|
||||||
|
<!-- Secondary brand color. -->
|
||||||
|
<item name="colorSecondary">@color/teal_200</item>
|
||||||
|
<item name="colorSecondaryVariant">@color/teal_200</item>
|
||||||
|
<item name="colorOnSecondary">@color/black</item>
|
||||||
|
<!-- Status bar color. -->
|
||||||
|
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
10
app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="purple_200">#FFBB86FC</color>
|
||||||
|
<color name="purple_500">#FF6200EE</color>
|
||||||
|
<color name="purple_700">#FF3700B3</color>
|
||||||
|
<color name="teal_200">#FF03DAC5</color>
|
||||||
|
<color name="teal_700">#FF018786</color>
|
||||||
|
<color name="black">#FF000000</color>
|
||||||
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
</resources>
|
||||||
11
app/src/main/res/values/dimens.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<dimen name="activity_horizontal_margin">24dp</dimen>
|
||||||
|
<dimen name="default_button_height">56dp</dimen>
|
||||||
|
<dimen name="default_margin">16dp</dimen>
|
||||||
|
<dimen name="toolbar_elevation">8dp</dimen>
|
||||||
|
<dimen name="content_img_height">120dp</dimen>
|
||||||
|
<dimen name="padding">6dp</dimen>
|
||||||
|
<dimen name="touch_target_size">48dp</dimen>
|
||||||
|
<dimen name="rounded_corner">12dp</dimen>
|
||||||
|
</resources>
|
||||||
13
app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Test ShowCase</string>
|
||||||
|
<string name="login">Login</string>
|
||||||
|
<string name="username">Username</string>
|
||||||
|
<string name="password">Password</string>
|
||||||
|
<string name="username_is_invalid">Username is not filled properly!</string>
|
||||||
|
<string name="password_is_invalid">Password is not filled properly!</string>
|
||||||
|
<string name="credentials_invalid">No User with given credentials!</string>
|
||||||
|
<string name="something_went_wrong">Something went wrong!</string>
|
||||||
|
<string name="login_title">Mock Login</string>
|
||||||
|
<string name="content">Content</string>
|
||||||
|
<string name="logout">Logout</string>
|
||||||
|
</resources>
|
||||||
16
app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="Theme.TestShowCase" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||||
|
<!-- Primary brand color. -->
|
||||||
|
<item name="colorPrimary">@color/purple_500</item>
|
||||||
|
<item name="colorPrimaryVariant">@color/purple_700</item>
|
||||||
|
<item name="colorOnPrimary">@color/white</item>
|
||||||
|
<!-- Secondary brand color. -->
|
||||||
|
<item name="colorSecondary">@color/teal_200</item>
|
||||||
|
<item name="colorSecondaryVariant">@color/teal_700</item>
|
||||||
|
<item name="colorOnSecondary">@color/black</item>
|
||||||
|
<!-- Status bar color. -->
|
||||||
|
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
package org.fnives.test.showcase.favourite
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.take
|
||||||
|
import kotlinx.coroutines.flow.toList
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.test.TestCoroutineDispatcher
|
||||||
|
import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage
|
||||||
|
import org.fnives.test.showcase.model.content.ContentId
|
||||||
|
import org.fnives.test.showcase.storage.database.DatabaseInitialization
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.koin.core.context.stopKoin
|
||||||
|
import org.koin.test.KoinTest
|
||||||
|
import org.koin.test.inject
|
||||||
|
|
||||||
|
@Suppress("TestFunctionName")
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
internal class FavouriteContentLocalStorageImplTest : KoinTest {
|
||||||
|
|
||||||
|
private val sut by inject<FavouriteContentLocalStorage>()
|
||||||
|
private lateinit var testDispatcher: TestCoroutineDispatcher
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
testDispatcher = TestCoroutineDispatcher()
|
||||||
|
DatabaseInitialization.dispatcher = testDispatcher
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
stopKoin()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun GIVEN_content_id_WHEN_added_to_Favourite_THEN_it_can_be_read_out() = runBlocking {
|
||||||
|
val expected = listOf(ContentId("a"))
|
||||||
|
|
||||||
|
sut.markAsFavourite(ContentId("a"))
|
||||||
|
val actual = sut.observeFavourites().first()
|
||||||
|
|
||||||
|
Assert.assertEquals(expected, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun GIVEN_content_id_added_WHEN_removed_to_Favourite_THEN_it_no_longer_can_be_read_out() = runBlocking {
|
||||||
|
val expected = listOf<ContentId>()
|
||||||
|
sut.markAsFavourite(ContentId("b"))
|
||||||
|
|
||||||
|
sut.deleteAsFavourite(ContentId("b"))
|
||||||
|
val actual = sut.observeFavourites().first()
|
||||||
|
|
||||||
|
Assert.assertEquals(expected, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun GIVEN_empty_database_WHILE_observing_content_WHEN_favourite_added_THEN_change_is_emitted() =
|
||||||
|
runBlocking<Unit> {
|
||||||
|
val expected = listOf(listOf(), listOf(ContentId("a")))
|
||||||
|
|
||||||
|
val actual = async(testDispatcher) {
|
||||||
|
sut.observeFavourites().take(2).toList()
|
||||||
|
}
|
||||||
|
testDispatcher.advanceUntilIdle()
|
||||||
|
|
||||||
|
sut.markAsFavourite(ContentId("a"))
|
||||||
|
|
||||||
|
Assert.assertEquals(expected, actual.await())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun GIVEN_non_empty_database_WHILE_observing_content_WHEN_favourite_removed_THEN_change_is_emitted() =
|
||||||
|
runBlocking<Unit> {
|
||||||
|
val expected = listOf(listOf(ContentId("a")), listOf())
|
||||||
|
sut.markAsFavourite(ContentId("a"))
|
||||||
|
|
||||||
|
val actual = async(testDispatcher) {
|
||||||
|
sut.observeFavourites().take(2).toList()
|
||||||
|
}
|
||||||
|
testDispatcher.advanceUntilIdle()
|
||||||
|
|
||||||
|
sut.deleteAsFavourite(ContentId("a"))
|
||||||
|
|
||||||
|
Assert.assertEquals(expected, actual.await())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
package org.fnives.test.showcase.testutils.configuration
|
||||||
|
|
||||||
|
object RobolectricLoginRobotConfiguration : LoginRobotConfiguration {
|
||||||
|
override val assertLoadingBeforeRequest: Boolean = true
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package org.fnives.test.showcase.testutils.configuration
|
||||||
|
|
||||||
|
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
|
||||||
|
|
||||||
|
object RobolectricServerTypeConfiguration : ServerTypeConfiguration {
|
||||||
|
override val useHttps: Boolean = false
|
||||||
|
|
||||||
|
override val url: String get() = "${MockServerScenarioSetup.HTTP_BASE_URL}:${MockServerScenarioSetup.PORT}/"
|
||||||
|
|
||||||
|
override fun invoke(mockServerScenarioSetup: MockServerScenarioSetup) = Unit
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
package org.fnives.test.showcase.testutils.configuration
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import org.fnives.test.showcase.testutils.shadow.ShadowSnackbar
|
||||||
|
import org.fnives.test.showcase.testutils.shadow.ShadowSnackbarResetTestRule
|
||||||
|
import org.junit.Assert
|
||||||
|
import org.junit.rules.TestRule
|
||||||
|
|
||||||
|
object RobolectricSnackbarVerificationTestRule : SnackbarVerificationTestRule, TestRule by ShadowSnackbarResetTestRule() {
|
||||||
|
|
||||||
|
override fun assertIsShownWithText(@StringRes stringResID: Int) {
|
||||||
|
val latestSnackbar = ShadowSnackbar.latestSnackbar ?: throw IllegalStateException("Snackbar not found")
|
||||||
|
Assert.assertEquals(latestSnackbar.context.getString(stringResID), ShadowSnackbar.textOfLatestSnackbar)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun assertIsNotShown() {
|
||||||
|
Assert.assertNull(ShadowSnackbar.latestSnackbar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
package org.fnives.test.showcase.testutils.configuration
|
||||||
|
|
||||||
|
object SpecificTestConfigurationsFactory : TestConfigurationsFactory {
|
||||||
|
override fun createMainDispatcherTestRule(): MainDispatcherTestRule =
|
||||||
|
TestCoroutineMainDispatcherTestRule()
|
||||||
|
|
||||||
|
override fun createServerTypeConfiguration(): ServerTypeConfiguration =
|
||||||
|
RobolectricServerTypeConfiguration
|
||||||
|
|
||||||
|
override fun createLoginRobotConfiguration(): LoginRobotConfiguration =
|
||||||
|
RobolectricLoginRobotConfiguration
|
||||||
|
|
||||||
|
override fun createSnackbarVerification(): SnackbarVerificationTestRule =
|
||||||
|
RobolectricSnackbarVerificationTestRule
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
package org.fnives.test.showcase.testutils.configuration
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.test.TestCoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.fnives.test.showcase.storage.database.DatabaseInitialization
|
||||||
|
import org.fnives.test.showcase.testutils.idling.advanceUntilIdleWithIdlingResources
|
||||||
|
import org.junit.runner.Description
|
||||||
|
import org.junit.runners.model.Statement
|
||||||
|
|
||||||
|
class TestCoroutineMainDispatcherTestRule : MainDispatcherTestRule {
|
||||||
|
|
||||||
|
private lateinit var testDispatcher: TestCoroutineDispatcher
|
||||||
|
|
||||||
|
override fun apply(base: Statement, description: Description): Statement =
|
||||||
|
object : Statement() {
|
||||||
|
@Throws(Throwable::class)
|
||||||
|
override fun evaluate() {
|
||||||
|
val dispatcher = TestCoroutineDispatcher()
|
||||||
|
dispatcher.pauseDispatcher()
|
||||||
|
Dispatchers.setMain(dispatcher)
|
||||||
|
testDispatcher = dispatcher
|
||||||
|
DatabaseInitialization.dispatcher = dispatcher
|
||||||
|
try {
|
||||||
|
base.evaluate()
|
||||||
|
} finally {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun advanceUntilIdleWithIdlingResources() {
|
||||||
|
testDispatcher.advanceUntilIdleWithIdlingResources()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun advanceUntilIdleOrActivityIsDestroyed() {
|
||||||
|
advanceUntilIdleWithIdlingResources()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun advanceUntilIdle() {
|
||||||
|
testDispatcher.advanceUntilIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun advanceTimeBy(delayInMillis: Long) {
|
||||||
|
testDispatcher.advanceTimeBy(delayInMillis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
package org.fnives.test.showcase.testutils.shadow
|
||||||
|
|
||||||
|
import android.R
|
||||||
|
import android.content.Context
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||||
|
import com.google.android.material.snackbar.ContentViewCallback
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import com.google.android.material.snackbar.SnackbarContentLayout
|
||||||
|
import org.robolectric.annotation.Implementation
|
||||||
|
import org.robolectric.annotation.Implements
|
||||||
|
import org.robolectric.annotation.RealObject
|
||||||
|
import org.robolectric.shadow.api.Shadow.extract
|
||||||
|
import java.lang.reflect.Modifier
|
||||||
|
|
||||||
|
@Implements(Snackbar::class)
|
||||||
|
class ShadowSnackbar {
|
||||||
|
@RealObject
|
||||||
|
var snackbar: Snackbar? = null
|
||||||
|
var text: String? = null
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val shadowSnackbars = mutableListOf<ShadowSnackbar>()
|
||||||
|
|
||||||
|
@Implementation
|
||||||
|
@JvmStatic
|
||||||
|
fun make(view: View, text: CharSequence, duration: Int): Snackbar? {
|
||||||
|
var snackbar: Snackbar? = null
|
||||||
|
try {
|
||||||
|
val constructor = Snackbar::class.java.getDeclaredConstructor(
|
||||||
|
Context::class.java,
|
||||||
|
ViewGroup::class.java,
|
||||||
|
View::class.java,
|
||||||
|
ContentViewCallback::class.java
|
||||||
|
) ?: throw IllegalArgumentException("Seems like the constructor was not found!")
|
||||||
|
if (Modifier.isPrivate(constructor.modifiers)) {
|
||||||
|
constructor.isAccessible = true
|
||||||
|
}
|
||||||
|
val parent = findSuitableParent(view)
|
||||||
|
val content = LayoutInflater.from(parent.context)
|
||||||
|
.inflate(
|
||||||
|
com.google.android.material.R.layout.design_layout_snackbar_include,
|
||||||
|
parent,
|
||||||
|
false
|
||||||
|
) as SnackbarContentLayout
|
||||||
|
snackbar = constructor.newInstance(view.context, parent, content, content)
|
||||||
|
snackbar.setText(text)
|
||||||
|
snackbar.duration = duration
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
shadowOf(snackbar).text = text.toString()
|
||||||
|
shadowSnackbars.add(shadowOf(snackbar))
|
||||||
|
return snackbar
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findSuitableParent(view: View): ViewGroup =
|
||||||
|
when (view) {
|
||||||
|
is CoordinatorLayout -> view
|
||||||
|
is FrameLayout -> {
|
||||||
|
when {
|
||||||
|
view.id == R.id.content -> view
|
||||||
|
(view.parent as? View) == null -> view
|
||||||
|
else -> findSuitableParent(view.parent as View)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
when {
|
||||||
|
(view.parent as? View) == null && view is ViewGroup -> view
|
||||||
|
(view.parent as? View) == null -> FrameLayout(view.context)
|
||||||
|
else -> findSuitableParent(view.parent as View)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Implementation
|
||||||
|
@JvmStatic
|
||||||
|
fun make(view: View, @StringRes resId: Int, duration: Int): Snackbar? =
|
||||||
|
make(view, view.resources.getText(resId), duration)
|
||||||
|
|
||||||
|
fun shadowOf(bar: Snackbar?): ShadowSnackbar =
|
||||||
|
extract(bar)
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
shadowSnackbars.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shownSnackbarCount(): Int = shadowSnackbars.size
|
||||||
|
|
||||||
|
val textOfLatestSnackbar: String?
|
||||||
|
get() = shadowSnackbars.lastOrNull()?.text
|
||||||
|
val latestSnackbar: Snackbar?
|
||||||
|
get() = shadowSnackbars.lastOrNull()?.snackbar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
package org.fnives.test.showcase.testutils.shadow
|
||||||
|
|
||||||
|
import org.junit.rules.TestRule
|
||||||
|
import org.junit.runner.Description
|
||||||
|
import org.junit.runners.model.Statement
|
||||||
|
|
||||||
|
class ShadowSnackbarResetTestRule : TestRule {
|
||||||
|
override fun apply(base: Statement, description: Description): Statement =
|
||||||
|
object : Statement() {
|
||||||
|
@Throws(Throwable::class)
|
||||||
|
override fun evaluate() {
|
||||||
|
ShadowSnackbar.reset()
|
||||||
|
try {
|
||||||
|
base.evaluate()
|
||||||
|
} finally {
|
||||||
|
ShadowSnackbar.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package org.fnives.test.showcase.di
|
||||||
|
|
||||||
|
import org.fnives.test.showcase.model.network.BaseUrl
|
||||||
|
import org.fnives.test.showcase.testutils.configuration.SpecificTestConfigurationsFactory
|
||||||
|
|
||||||
|
object BaseUrlProvider {
|
||||||
|
|
||||||
|
fun get() = BaseUrl(SpecificTestConfigurationsFactory.createServerTypeConfiguration().url)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package org.fnives.test.showcase.storage.database
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Room
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.asExecutor
|
||||||
|
import org.fnives.test.showcase.storage.LocalDatabase
|
||||||
|
|
||||||
|
object DatabaseInitialization {
|
||||||
|
|
||||||
|
lateinit var dispatcher: CoroutineDispatcher
|
||||||
|
|
||||||
|
fun create(context: Context): LocalDatabase {
|
||||||
|
val executor = dispatcher.asExecutor()
|
||||||
|
return Room.inMemoryDatabaseBuilder(context, LocalDatabase::class.java)
|
||||||
|
.setTransactionExecutor(executor)
|
||||||
|
.setQueryExecutor(executor)
|
||||||
|
.allowMainThreadQueries()
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
package org.fnives.test.showcase.testutils
|
||||||
|
|
||||||
|
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
|
||||||
|
import org.fnives.test.showcase.testutils.configuration.ServerTypeConfiguration
|
||||||
|
import org.fnives.test.showcase.testutils.configuration.SpecificTestConfigurationsFactory
|
||||||
|
import org.junit.rules.TestRule
|
||||||
|
import org.junit.runner.Description
|
||||||
|
import org.junit.runners.model.Statement
|
||||||
|
|
||||||
|
class MockServerScenarioSetupTestRule(
|
||||||
|
val serverTypeConfiguration: ServerTypeConfiguration = SpecificTestConfigurationsFactory.createServerTypeConfiguration()
|
||||||
|
) : TestRule {
|
||||||
|
lateinit var mockServerScenarioSetup: MockServerScenarioSetup
|
||||||
|
|
||||||
|
override fun apply(base: Statement, description: Description): Statement =
|
||||||
|
object : Statement() {
|
||||||
|
@Throws(Throwable::class)
|
||||||
|
override fun evaluate() {
|
||||||
|
before()
|
||||||
|
try {
|
||||||
|
base.evaluate()
|
||||||
|
} finally {
|
||||||
|
after()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun before() {
|
||||||
|
mockServerScenarioSetup = MockServerScenarioSetup()
|
||||||
|
mockServerScenarioSetup.start(serverTypeConfiguration.useHttps)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun after() {
|
||||||
|
mockServerScenarioSetup.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
package org.fnives.test.showcase.testutils
|
||||||
|
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import org.fnives.test.showcase.TestShowcaseApplication
|
||||||
|
import org.fnives.test.showcase.di.BaseUrlProvider
|
||||||
|
import org.fnives.test.showcase.di.createAppModules
|
||||||
|
import org.junit.rules.TestRule
|
||||||
|
import org.junit.runner.Description
|
||||||
|
import org.junit.runners.model.Statement
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
|
import org.koin.core.context.GlobalContext
|
||||||
|
import org.koin.core.context.startKoin
|
||||||
|
import org.koin.core.context.stopKoin
|
||||||
|
|
||||||
|
class ReloadKoinModulesIfNecessaryTestRule : TestRule {
|
||||||
|
override fun apply(base: Statement, description: Description): Statement =
|
||||||
|
object : Statement() {
|
||||||
|
override fun evaluate() {
|
||||||
|
if (GlobalContext.getOrNull() == null) {
|
||||||
|
val application = ApplicationProvider.getApplicationContext<TestShowcaseApplication>()
|
||||||
|
startKoin {
|
||||||
|
androidContext(application)
|
||||||
|
modules(createAppModules(BaseUrlProvider.get()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
base.evaluate()
|
||||||
|
} finally {
|
||||||
|
stopKoin()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package org.fnives.test.showcase.testutils.configuration
|
||||||
|
|
||||||
|
interface LoginRobotConfiguration {
|
||||||
|
|
||||||
|
val assertLoadingBeforeRequest: Boolean
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package org.fnives.test.showcase.testutils.configuration
|
||||||
|
|
||||||
|
import org.junit.rules.TestRule
|
||||||
|
|
||||||
|
interface MainDispatcherTestRule : TestRule {
|
||||||
|
|
||||||
|
fun advanceUntilIdleWithIdlingResources()
|
||||||
|
|
||||||
|
fun advanceUntilIdleOrActivityIsDestroyed()
|
||||||
|
|
||||||
|
fun advanceUntilIdle()
|
||||||
|
|
||||||
|
fun advanceTimeBy(delayInMillis: Long)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package org.fnives.test.showcase.testutils.configuration
|
||||||
|
|
||||||
|
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
|
||||||
|
|
||||||
|
interface ServerTypeConfiguration {
|
||||||
|
|
||||||
|
val useHttps: Boolean
|
||||||
|
|
||||||
|
val url: String
|
||||||
|
|
||||||
|
fun invoke(mockServerScenarioSetup: MockServerScenarioSetup)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
package org.fnives.test.showcase.testutils.configuration
|
||||||
|
|
||||||
|
import org.junit.rules.TestRule
|
||||||
|
|
||||||
|
interface SnackBarTestRule : TestRule
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package org.fnives.test.showcase.testutils.configuration
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import org.junit.rules.TestRule
|
||||||
|
|
||||||
|
interface SnackbarVerificationTestRule : TestRule {
|
||||||
|
|
||||||
|
fun assertIsShownWithText(@StringRes stringResID: Int)
|
||||||
|
|
||||||
|
fun assertIsNotShown()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
package org.fnives.test.showcase.testutils.configuration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the platform specific configurations for Robolectric and AndroidTest.
|
||||||
|
*
|
||||||
|
* Each should have an object [SpecificTestConfigurationsFactory] implementing this interface so the SharedTests are
|
||||||
|
* configured properly.
|
||||||
|
*/
|
||||||
|
interface TestConfigurationsFactory {
|
||||||
|
|
||||||
|
fun createMainDispatcherTestRule(): MainDispatcherTestRule
|
||||||
|
|
||||||
|
fun createServerTypeConfiguration(): ServerTypeConfiguration
|
||||||
|
|
||||||
|
fun createLoginRobotConfiguration(): LoginRobotConfiguration
|
||||||
|
|
||||||
|
fun createSnackbarVerification(): SnackbarVerificationTestRule
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
package org.fnives.test.showcase.testutils
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
|
fun doBlockinglyOnMainThread(action: () -> Unit) {
|
||||||
|
if (Looper.myLooper() === Looper.getMainLooper()) {
|
||||||
|
action()
|
||||||
|
} else {
|
||||||
|
val deferred = CompletableDeferred<Unit>()
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
action()
|
||||||
|
deferred.complete(Unit)
|
||||||
|
}
|
||||||
|
runBlocking { deferred.await() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
package org.fnives.test.showcase.testutils.idling
|
||||||
|
|
||||||
|
class CompositeDisposable(disposable: List<Disposable> = emptyList()) : Disposable {
|
||||||
|
|
||||||
|
constructor(vararg disposables: Disposable) : this(disposables.toList())
|
||||||
|
|
||||||
|
private val disposables = disposable.toMutableList()
|
||||||
|
override val isDisposed: Boolean get() = disposables.all(Disposable::isDisposed)
|
||||||
|
|
||||||
|
fun add(disposable: Disposable) {
|
||||||
|
disposables.add(disposable)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun dispose() {
|
||||||
|
disposables.forEach {
|
||||||
|
it.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package org.fnives.test.showcase.testutils.idling
|
||||||
|
|
||||||
|
interface Disposable {
|
||||||
|
val isDisposed: Boolean
|
||||||
|
fun dispose()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
package org.fnives.test.showcase.testutils.idling
|
||||||
|
|
||||||
|
import androidx.test.espresso.IdlingRegistry
|
||||||
|
import androidx.test.espresso.IdlingResource
|
||||||
|
|
||||||
|
internal class IdlingResourceDisposable(private val idlingResource: IdlingResource) : Disposable {
|
||||||
|
override var isDisposed: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
init {
|
||||||
|
IdlingRegistry.getInstance().register(idlingResource)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun dispose() {
|
||||||
|
if (isDisposed) return
|
||||||
|
isDisposed = true
|
||||||
|
IdlingRegistry.getInstance().unregister(idlingResource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
package org.fnives.test.showcase.testutils.idling
|
||||||
|
|
||||||
|
import androidx.annotation.CheckResult
|
||||||
|
import androidx.test.espresso.IdlingResource
|
||||||
|
import com.jakewharton.espresso.OkHttp3IdlingResource
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import org.koin.core.qualifier.StringQualifier
|
||||||
|
import org.koin.test.KoinTest
|
||||||
|
import org.koin.test.get
|
||||||
|
|
||||||
|
object NetworkSynchronization : KoinTest {
|
||||||
|
|
||||||
|
@CheckResult
|
||||||
|
fun registerNetworkingSynchronization(): Disposable {
|
||||||
|
val idlingResources = OkHttpClientTypes.values()
|
||||||
|
.map { it to getOkHttpClient(it) }
|
||||||
|
.associateBy { it.second.dispatcher }
|
||||||
|
.values
|
||||||
|
.map { (key, client) -> client.asIdlingResource(key.qualifier) }
|
||||||
|
.map(::IdlingResourceDisposable)
|
||||||
|
|
||||||
|
return CompositeDisposable(idlingResources)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getOkHttpClient(type: OkHttpClientTypes): OkHttpClient = get(StringQualifier(type.qualifier))
|
||||||
|
|
||||||
|
private fun OkHttpClient.asIdlingResource(name: String): IdlingResource =
|
||||||
|
OkHttp3IdlingResource.create(name, this)
|
||||||
|
|
||||||
|
enum class OkHttpClientTypes(val qualifier: String) {
|
||||||
|
SESSION("SESSION-NETWORKING"), SESSIONLESS("SESSIONLESS-NETWORKING")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
package org.fnives.test.showcase.testutils.idling
|
||||||
|
|
||||||
|
import androidx.test.espresso.Espresso
|
||||||
|
import androidx.test.espresso.IdlingRegistry
|
||||||
|
import androidx.test.espresso.IdlingResource
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.TestCoroutineDispatcher
|
||||||
|
import org.fnives.test.showcase.testutils.viewactions.LoopMainThreadFor
|
||||||
|
import org.fnives.test.showcase.testutils.viewactions.LoopMainThreadUntilIdle
|
||||||
|
|
||||||
|
private val idleScope = CoroutineScope(Dispatchers.IO)
|
||||||
|
|
||||||
|
// workaround, issue with idlingResources is tracked here https://github.com/robolectric/robolectric/issues/4807
|
||||||
|
fun anyResourceIdling(): Boolean = !IdlingRegistry.getInstance().resources.all(IdlingResource::isIdleNow)
|
||||||
|
|
||||||
|
fun awaitIdlingResources() {
|
||||||
|
val idlingRegistry = IdlingRegistry.getInstance()
|
||||||
|
if (idlingRegistry.resources.all(IdlingResource::isIdleNow)) return
|
||||||
|
|
||||||
|
var isIdle = false
|
||||||
|
idleScope.launch {
|
||||||
|
do {
|
||||||
|
idlingRegistry.resources
|
||||||
|
.filterNot(IdlingResource::isIdleNow)
|
||||||
|
.forEach { idlingRegistry ->
|
||||||
|
idlingRegistry.awaitUntilIdle()
|
||||||
|
}
|
||||||
|
} while (!idlingRegistry.resources.all(IdlingResource::isIdleNow))
|
||||||
|
isIdle = true
|
||||||
|
}
|
||||||
|
while (!isIdle) {
|
||||||
|
loopMainThreadFor(200L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun IdlingResource.awaitUntilIdle() {
|
||||||
|
// using loop because some times, registerIdleTransitionCallback wasn't called
|
||||||
|
while (true) {
|
||||||
|
if (isIdleNow) return
|
||||||
|
delay(100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun TestCoroutineDispatcher.advanceUntilIdleWithIdlingResources() {
|
||||||
|
advanceUntilIdle() // advance until a request is sent
|
||||||
|
while (anyResourceIdling()) { // check if any request is in progress
|
||||||
|
awaitIdlingResources() // complete all requests and other idling resources
|
||||||
|
advanceUntilIdle() // run coroutines after request is finished
|
||||||
|
}
|
||||||
|
advanceUntilIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loopMainThreadUntilIdleWithIdlingResources() {
|
||||||
|
Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainThreadUntilIdle()) // advance until a request is sent
|
||||||
|
while (anyResourceIdling()) { // check if any request is in progress
|
||||||
|
awaitIdlingResources() // complete all requests and other idling resources
|
||||||
|
Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainThreadUntilIdle()) // run coroutines after request is finished
|
||||||
|
}
|
||||||
|
Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainThreadUntilIdle())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loopMainThreadFor(delay: Long) {
|
||||||
|
Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainThreadFor(delay))
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package org.fnives.test.showcase.testutils.robot
|
||||||
|
|
||||||
|
interface Robot {
|
||||||
|
|
||||||
|
fun init()
|
||||||
|
|
||||||
|
fun release()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
package org.fnives.test.showcase.testutils.robot
|
||||||
|
|
||||||
|
import org.junit.rules.TestRule
|
||||||
|
import org.junit.runner.Description
|
||||||
|
import org.junit.runners.model.Statement
|
||||||
|
|
||||||
|
class RobotTestRule<T : Robot>(val robot: T) : TestRule {
|
||||||
|
override fun apply(base: Statement, description: Description): Statement =
|
||||||
|
object : Statement() {
|
||||||
|
@Throws(Throwable::class)
|
||||||
|
override fun evaluate() {
|
||||||
|
robot.init()
|
||||||
|
try {
|
||||||
|
base.evaluate()
|
||||||
|
} finally {
|
||||||
|
robot.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
package org.fnives.test.showcase.testutils.statesetup
|
||||||
|
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase
|
||||||
|
import org.fnives.test.showcase.core.login.LoginUseCase
|
||||||
|
import org.fnives.test.showcase.core.login.LogoutUseCase
|
||||||
|
import org.fnives.test.showcase.model.auth.LoginCredentials
|
||||||
|
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
|
||||||
|
import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario
|
||||||
|
import org.koin.test.KoinTest
|
||||||
|
import org.koin.test.get
|
||||||
|
|
||||||
|
object SetupLoggedInState : KoinTest {
|
||||||
|
|
||||||
|
private val logoutUseCase get() = get<LogoutUseCase>()
|
||||||
|
private val loginUseCase get() = get<LoginUseCase>()
|
||||||
|
private val isUserLoggedInUseCase get() = get<IsUserLoggedInUseCase>()
|
||||||
|
|
||||||
|
fun setupLogin(mockServerScenarioSetup: MockServerScenarioSetup) {
|
||||||
|
mockServerScenarioSetup.setScenario(AuthScenario.Success("a", "b"))
|
||||||
|
runBlocking {
|
||||||
|
loginUseCase.invoke(LoginCredentials("a", "b"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isLoggedIn() = isUserLoggedInUseCase.invoke()
|
||||||
|
|
||||||
|
fun setupLogout() {
|
||||||
|
runBlocking { logoutUseCase.invoke() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
package org.fnives.test.showcase.testutils.viewactions
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import androidx.test.espresso.UiController
|
||||||
|
import androidx.test.espresso.ViewAction
|
||||||
|
import org.hamcrest.Matcher
|
||||||
|
import org.hamcrest.Matchers
|
||||||
|
|
||||||
|
class LoopMainThreadFor(private val delayInMillis: Long) : ViewAction {
|
||||||
|
override fun getConstraints(): Matcher<View> = Matchers.isA(View::class.java)
|
||||||
|
|
||||||
|
override fun getDescription(): String = "loop MainThread for $delayInMillis milliseconds"
|
||||||
|
|
||||||
|
override fun perform(uiController: UiController, view: View?) {
|
||||||
|
uiController.loopMainThreadForAtLeast(delayInMillis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoopMainThreadUntilIdle : ViewAction {
|
||||||
|
override fun getConstraints(): Matcher<View> = Matchers.isA(View::class.java)
|
||||||
|
|
||||||
|
override fun getDescription(): String = "loop MainThread for until Idle"
|
||||||
|
|
||||||
|
override fun perform(uiController: UiController, view: View?) {
|
||||||
|
uiController.loopMainThreadUntilIdle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
package org.fnives.test.showcase.testutils.viewactions
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
|
import androidx.swiperefreshlayout.widget.listener
|
||||||
|
import androidx.test.espresso.UiController
|
||||||
|
import androidx.test.espresso.ViewAction
|
||||||
|
import org.fnives.test.showcase.testutils.doBlockinglyOnMainThread
|
||||||
|
import org.hamcrest.BaseMatcher
|
||||||
|
import org.hamcrest.CoreMatchers.isA
|
||||||
|
import org.hamcrest.Description
|
||||||
|
import org.hamcrest.Matcher
|
||||||
|
|
||||||
|
// swipe-refresh-layout swipe-down doesn't work, inspired by https://github.com/robolectric/robolectric/issues/5375
|
||||||
|
class PullToRefresh : ViewAction {
|
||||||
|
|
||||||
|
override fun getConstraints(): Matcher<View> {
|
||||||
|
return object : BaseMatcher<View>() {
|
||||||
|
override fun matches(item: Any): Boolean {
|
||||||
|
return isA(SwipeRefreshLayout::class.java).matches(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun describeMismatch(item: Any, mismatchDescription: Description) {
|
||||||
|
mismatchDescription.appendText("Expected SwipeRefreshLayout or its Descendant, but got other View")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun describeTo(description: Description) {
|
||||||
|
description.appendText("Action SwipeToRefresh to view SwipeRefreshLayout or its descendant")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDescription(): String {
|
||||||
|
return "Perform pull-to-refresh on the SwipeRefreshLayout"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun perform(uiController: UiController, view: View) {
|
||||||
|
val swipeRefreshLayout = view as SwipeRefreshLayout
|
||||||
|
doBlockinglyOnMainThread {
|
||||||
|
swipeRefreshLayout.isRefreshing = true
|
||||||
|
swipeRefreshLayout.listener().onRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
@file:Suppress("PackageDirectoryMismatch")
|
||||||
|
|
||||||
|
package androidx.swiperefreshlayout.widget
|
||||||
|
|
||||||
|
fun SwipeRefreshLayout.listener() = mListener
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
package org.fnives.test.showcase.testutils.viewactions
|
||||||
|
|
||||||
|
import android.content.res.ColorStateList
|
||||||
|
import android.graphics.PorterDuff
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.ImageView
|
||||||
|
import androidx.annotation.ColorRes
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import org.hamcrest.Description
|
||||||
|
import org.hamcrest.TypeSafeMatcher
|
||||||
|
|
||||||
|
class WithDrawable(
|
||||||
|
@DrawableRes
|
||||||
|
private val id: Int,
|
||||||
|
@ColorRes
|
||||||
|
private val tint: Int? = null,
|
||||||
|
private val tintMode: PorterDuff.Mode = PorterDuff.Mode.SRC_IN
|
||||||
|
) : TypeSafeMatcher<View>() {
|
||||||
|
override fun describeTo(description: Description) {
|
||||||
|
description.appendText("ImageView with drawable same as drawable with id $id")
|
||||||
|
tint?.let { description.appendText(", tint color id: $tint, mode: $tintMode") }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun matchesSafely(view: View): Boolean {
|
||||||
|
val context = view.context
|
||||||
|
val tintColor = tint?.let { ContextCompat.getColor(view.context, it) }
|
||||||
|
val expectedBitmap = context.getDrawable(id)?.apply {
|
||||||
|
if (tintColor != null) {
|
||||||
|
setTintList(ColorStateList.valueOf(tintColor))
|
||||||
|
setTintMode(tintMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return view is ImageView && view.drawable.toBitmap().sameAs(expectedBitmap?.toBitmap())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
package org.fnives.test.showcase.testutils.viewactions
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.test.espresso.intent.Intents.intended
|
||||||
|
import org.hamcrest.Matcher
|
||||||
|
import org.hamcrest.StringDescription
|
||||||
|
|
||||||
|
fun notIntended(matcher: Matcher<Intent>) {
|
||||||
|
try {
|
||||||
|
intended(matcher)
|
||||||
|
} catch (assertionError: AssertionError) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val description = StringDescription()
|
||||||
|
matcher.describeMismatch(null, description)
|
||||||
|
throw IllegalStateException("Navigate to intent found matching $description")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
package org.fnives.test.showcase.ui.home
|
||||||
|
|
||||||
|
import android.app.Instrumentation
|
||||||
|
import androidx.test.espresso.Espresso
|
||||||
|
import androidx.test.espresso.action.ViewActions.click
|
||||||
|
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||||
|
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.R
|
||||||
|
import org.fnives.test.showcase.model.content.Content
|
||||||
|
import org.fnives.test.showcase.model.content.FavouriteContent
|
||||||
|
import org.fnives.test.showcase.testutils.robot.Robot
|
||||||
|
import org.fnives.test.showcase.testutils.viewactions.PullToRefresh
|
||||||
|
import org.fnives.test.showcase.testutils.viewactions.WithDrawable
|
||||||
|
import org.fnives.test.showcase.testutils.viewactions.notIntended
|
||||||
|
import org.fnives.test.showcase.ui.auth.AuthActivity
|
||||||
|
import org.hamcrest.Matchers.allOf
|
||||||
|
|
||||||
|
class HomeRobot : Robot {
|
||||||
|
|
||||||
|
override fun init() {
|
||||||
|
Intents.init()
|
||||||
|
Intents.intending(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
|
||||||
|
.respondWith(Instrumentation.ActivityResult(0, null))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun release() {
|
||||||
|
Intents.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertNavigatedToAuth() = apply {
|
||||||
|
Intents.intended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertDidNotNavigateToAuth() = apply {
|
||||||
|
notIntended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clickSignOut() = apply {
|
||||||
|
Espresso.onView(withId(R.id.logout_cta)).perform(click())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertContainsItem(item: FavouriteContent) = apply {
|
||||||
|
val isFavouriteResourceId = if (item.isFavourite) {
|
||||||
|
R.drawable.favorite_24
|
||||||
|
} else {
|
||||||
|
R.drawable.favorite_border_24
|
||||||
|
}
|
||||||
|
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(item: Content) = apply {
|
||||||
|
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 {
|
||||||
|
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,239 @@
|
||||||
|
package org.fnives.test.showcase.ui.home
|
||||||
|
|
||||||
|
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.test.core.app.ActivityScenario
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import org.fnives.test.showcase.model.content.FavouriteContent
|
||||||
|
import org.fnives.test.showcase.network.mockserver.ContentData
|
||||||
|
import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScenario
|
||||||
|
import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshTokenScenario
|
||||||
|
import org.fnives.test.showcase.testutils.MockServerScenarioSetupTestRule
|
||||||
|
import org.fnives.test.showcase.testutils.ReloadKoinModulesIfNecessaryTestRule
|
||||||
|
import org.fnives.test.showcase.testutils.configuration.SpecificTestConfigurationsFactory
|
||||||
|
import org.fnives.test.showcase.testutils.idling.Disposable
|
||||||
|
import org.fnives.test.showcase.testutils.idling.NetworkSynchronization
|
||||||
|
import org.fnives.test.showcase.testutils.idling.loopMainThreadFor
|
||||||
|
import org.fnives.test.showcase.testutils.idling.loopMainThreadUntilIdleWithIdlingResources
|
||||||
|
import org.fnives.test.showcase.testutils.robot.RobotTestRule
|
||||||
|
import org.fnives.test.showcase.testutils.statesetup.SetupLoggedInState
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.koin.test.KoinTest
|
||||||
|
|
||||||
|
@Suppress("TestFunctionName")
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class MainActivityTest : KoinTest {
|
||||||
|
|
||||||
|
private lateinit var activityScenario: ActivityScenario<MainActivity>
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
@JvmField
|
||||||
|
val instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
@JvmField
|
||||||
|
val snackbarVerificationTestRule = SpecificTestConfigurationsFactory.createSnackbarVerification()
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
@JvmField
|
||||||
|
val robotRule = RobotTestRule(HomeRobot())
|
||||||
|
private val homeRobot get() = robotRule.robot
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
@JvmField
|
||||||
|
val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule()
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
@JvmField
|
||||||
|
val mainDispatcherTestRule = SpecificTestConfigurationsFactory.createMainDispatcherTestRule()
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
@JvmField
|
||||||
|
val reloadKoinModulesIfNecessaryTestRule = ReloadKoinModulesIfNecessaryTestRule()
|
||||||
|
|
||||||
|
private lateinit var disposable: Disposable
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
SpecificTestConfigurationsFactory.createServerTypeConfiguration()
|
||||||
|
.invoke(mockServerScenarioSetupTestRule.mockServerScenarioSetup)
|
||||||
|
|
||||||
|
SetupLoggedInState.setupLogin(mockServerScenarioSetupTestRule.mockServerScenarioSetup)
|
||||||
|
disposable = NetworkSynchronization.registerNetworkingSynchronization()
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
activityScenario.moveToState(Lifecycle.State.DESTROYED)
|
||||||
|
disposable.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun GIVEN_initialized_MainActivity_WHEN_signout_is_clicked_THEN_user_is_signed_out() {
|
||||||
|
mockServerScenarioSetupTestRule.mockServerScenarioSetup
|
||||||
|
.setScenario(ContentScenario.Error(false))
|
||||||
|
activityScenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
|
||||||
|
homeRobot.clickSignOut()
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleOrActivityIsDestroyed()
|
||||||
|
|
||||||
|
homeRobot.assertNavigatedToAuth()
|
||||||
|
Assert.assertEquals(false, SetupLoggedInState.isLoggedIn())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun GIVEN_success_response_WHEN_data_is_returned_THEN_it_is_shown_on_the_ui() {
|
||||||
|
mockServerScenarioSetupTestRule.mockServerScenarioSetup
|
||||||
|
.setScenario(ContentScenario.Success(false))
|
||||||
|
activityScenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
ContentData.contentSuccess.forEach {
|
||||||
|
homeRobot.assertContainsItem(FavouriteContent(it, false))
|
||||||
|
}
|
||||||
|
homeRobot.assertDidNotNavigateToAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun GIVEN_success_response_WHEN_item_is_clicked_THEN_ui_is_updated() {
|
||||||
|
mockServerScenarioSetupTestRule.mockServerScenarioSetup
|
||||||
|
.setScenario(ContentScenario.Success(false))
|
||||||
|
activityScenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
homeRobot.clickOnContentItem(ContentData.contentSuccess.first())
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
|
||||||
|
val expectedItem = FavouriteContent(ContentData.contentSuccess.first(), true)
|
||||||
|
homeRobot.assertContainsItem(expectedItem)
|
||||||
|
.assertDidNotNavigateToAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun GIVEN_success_response_WHEN_item_is_clicked_THEN_ui_is_updated_even_if_activity_is_recreated() {
|
||||||
|
mockServerScenarioSetupTestRule.mockServerScenarioSetup
|
||||||
|
.setScenario(ContentScenario.Success(false))
|
||||||
|
activityScenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
homeRobot.clickOnContentItem(ContentData.contentSuccess.first())
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
|
||||||
|
val expectedItem = FavouriteContent(ContentData.contentSuccess.first(), true)
|
||||||
|
|
||||||
|
activityScenario.moveToState(Lifecycle.State.DESTROYED)
|
||||||
|
activityScenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
|
||||||
|
homeRobot.assertContainsItem(expectedItem)
|
||||||
|
.assertDidNotNavigateToAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun GIVEN_success_response_WHEN_item_is_clicked_then_clicked_again_THEN_ui_is_updated() {
|
||||||
|
mockServerScenarioSetupTestRule.mockServerScenarioSetup
|
||||||
|
.setScenario(ContentScenario.Success(false))
|
||||||
|
activityScenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
homeRobot.clickOnContentItem(ContentData.contentSuccess.first())
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
homeRobot.clickOnContentItem(ContentData.contentSuccess.first())
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
|
||||||
|
val expectedItem = FavouriteContent(ContentData.contentSuccess.first(), false)
|
||||||
|
homeRobot.assertContainsItem(expectedItem)
|
||||||
|
.assertDidNotNavigateToAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun GIVEN_error_response_WHEN_loaded_THEN_error_is_Shown() {
|
||||||
|
mockServerScenarioSetupTestRule.mockServerScenarioSetup
|
||||||
|
.setScenario(ContentScenario.Error(false))
|
||||||
|
activityScenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
|
||||||
|
homeRobot.assertContainsNoItems()
|
||||||
|
.assertContainsError()
|
||||||
|
.assertDidNotNavigateToAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun GIVEN_error_response_then_success_WHEN_retried_THEN_success_is_shown() {
|
||||||
|
mockServerScenarioSetupTestRule.mockServerScenarioSetup
|
||||||
|
.setScenario(
|
||||||
|
ContentScenario.Error(false)
|
||||||
|
.then(ContentScenario.Success(false))
|
||||||
|
)
|
||||||
|
activityScenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
|
||||||
|
homeRobot.swipeRefresh()
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
loopMainThreadFor(2000L)
|
||||||
|
|
||||||
|
ContentData.contentSuccess.forEach {
|
||||||
|
homeRobot.assertContainsItem(FavouriteContent(it, false))
|
||||||
|
}
|
||||||
|
homeRobot.assertDidNotNavigateToAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun GIVEN_success_then_error_WHEN_retried_THEN_error_is_shown() {
|
||||||
|
mockServerScenarioSetupTestRule.mockServerScenarioSetup
|
||||||
|
.setScenario(
|
||||||
|
ContentScenario.Success(false)
|
||||||
|
.then(ContentScenario.Error(false))
|
||||||
|
)
|
||||||
|
activityScenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
|
||||||
|
homeRobot.swipeRefresh()
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
loopMainThreadUntilIdleWithIdlingResources()
|
||||||
|
mainDispatcherTestRule.advanceTimeBy(1000L)
|
||||||
|
loopMainThreadFor(1000)
|
||||||
|
|
||||||
|
homeRobot
|
||||||
|
.assertContainsError()
|
||||||
|
.assertContainsNoItems()
|
||||||
|
.assertDidNotNavigateToAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun GIVEN_unauthenticated_then_success_WHEN_loaded_THEN_success_is_shown() {
|
||||||
|
mockServerScenarioSetupTestRule.mockServerScenarioSetup
|
||||||
|
.setScenario(
|
||||||
|
ContentScenario.Unauthorized(false)
|
||||||
|
.then(ContentScenario.Success(true))
|
||||||
|
)
|
||||||
|
.setScenario(RefreshTokenScenario.Success)
|
||||||
|
|
||||||
|
activityScenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
|
||||||
|
ContentData.contentSuccess.forEach {
|
||||||
|
homeRobot.assertContainsItem(FavouriteContent(it, false))
|
||||||
|
}
|
||||||
|
homeRobot.assertDidNotNavigateToAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun GIVEN_unauthenticated_then_error_WHEN_loaded_THEN_navigated_to_auth() {
|
||||||
|
mockServerScenarioSetupTestRule.mockServerScenarioSetup
|
||||||
|
.setScenario(ContentScenario.Unauthorized(false))
|
||||||
|
.setScenario(RefreshTokenScenario.Error)
|
||||||
|
|
||||||
|
activityScenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
|
||||||
|
homeRobot.assertNavigatedToAuth()
|
||||||
|
Assert.assertEquals(false, SetupLoggedInState.isLoggedIn())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
package org.fnives.test.showcase.ui.login
|
||||||
|
|
||||||
|
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.test.core.app.ActivityScenario
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import org.fnives.test.showcase.R
|
||||||
|
import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario
|
||||||
|
import org.fnives.test.showcase.testutils.MockServerScenarioSetupTestRule
|
||||||
|
import org.fnives.test.showcase.testutils.ReloadKoinModulesIfNecessaryTestRule
|
||||||
|
import org.fnives.test.showcase.testutils.configuration.SpecificTestConfigurationsFactory
|
||||||
|
import org.fnives.test.showcase.testutils.idling.Disposable
|
||||||
|
import org.fnives.test.showcase.testutils.idling.NetworkSynchronization
|
||||||
|
import org.fnives.test.showcase.testutils.robot.RobotTestRule
|
||||||
|
import org.fnives.test.showcase.ui.auth.AuthActivity
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.koin.test.KoinTest
|
||||||
|
|
||||||
|
@Suppress("TestFunctionName")
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class AuthActivityTest : KoinTest {
|
||||||
|
|
||||||
|
private lateinit var activityScenario: ActivityScenario<AuthActivity>
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
@JvmField
|
||||||
|
val instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
@JvmField
|
||||||
|
val snackbarVerificationTestRule = SpecificTestConfigurationsFactory.createSnackbarVerification()
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
@JvmField
|
||||||
|
val robotRule = RobotTestRule(LoginRobot())
|
||||||
|
private val loginRobot get() = robotRule.robot
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
@JvmField
|
||||||
|
val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule()
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
@JvmField
|
||||||
|
val mainDispatcherTestRule = SpecificTestConfigurationsFactory.createMainDispatcherTestRule()
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
@JvmField
|
||||||
|
val reloadKoinModulesIfNecessaryTestRule = ReloadKoinModulesIfNecessaryTestRule()
|
||||||
|
|
||||||
|
private lateinit var disposable: Disposable
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
SpecificTestConfigurationsFactory.createServerTypeConfiguration()
|
||||||
|
.invoke(mockServerScenarioSetupTestRule.mockServerScenarioSetup)
|
||||||
|
disposable = NetworkSynchronization.registerNetworkingSynchronization()
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
activityScenario.moveToState(Lifecycle.State.DESTROYED)
|
||||||
|
disposable.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun GIVEN_non_empty_password_and_username_and_successful_response_WHEN_signIn_THEN_no_error_is_shown_and_navigating_to_home() {
|
||||||
|
mockServerScenarioSetupTestRule.mockServerScenarioSetup.setScenario(
|
||||||
|
AuthScenario.Success(
|
||||||
|
password = "alma",
|
||||||
|
username = "banan"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
activityScenario = ActivityScenario.launch(AuthActivity::class.java)
|
||||||
|
loginRobot
|
||||||
|
.setPassword("alma")
|
||||||
|
.setUsername("banan")
|
||||||
|
.assertPassword("alma")
|
||||||
|
.assertUsername("banan")
|
||||||
|
.clickOnLogin()
|
||||||
|
.assertLoadingBeforeRequests()
|
||||||
|
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleOrActivityIsDestroyed()
|
||||||
|
loginRobot.assertNavigatedToHome()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun GIVEN_empty_password_and_username_WHEN_signIn_THEN_error_password_is_shown() {
|
||||||
|
activityScenario = ActivityScenario.launch(AuthActivity::class.java)
|
||||||
|
loginRobot
|
||||||
|
.setUsername("banan")
|
||||||
|
.assertUsername("banan")
|
||||||
|
.clickOnLogin()
|
||||||
|
.assertLoadingBeforeRequests()
|
||||||
|
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
loginRobot.assertErrorIsShown(R.string.password_is_invalid)
|
||||||
|
.assertNotNavigatedToHome()
|
||||||
|
.assertNotLoading()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun GIVEN_password_and_empty_username_WHEN_signIn_THEN_error_username_is_shown() {
|
||||||
|
activityScenario = ActivityScenario.launch(AuthActivity::class.java)
|
||||||
|
loginRobot
|
||||||
|
.setPassword("banan")
|
||||||
|
.assertPassword("banan")
|
||||||
|
.clickOnLogin()
|
||||||
|
.assertLoadingBeforeRequests()
|
||||||
|
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
loginRobot.assertErrorIsShown(R.string.username_is_invalid)
|
||||||
|
.assertNotNavigatedToHome()
|
||||||
|
.assertNotLoading()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun GIVEN_password_and_username_and_invalid_credentials_response_WHEN_signIn_THEN_error_invalid_credentials_is_shown() {
|
||||||
|
mockServerScenarioSetupTestRule.mockServerScenarioSetup.setScenario(
|
||||||
|
AuthScenario.InvalidCredentials(username = "alma", password = "banan")
|
||||||
|
)
|
||||||
|
activityScenario = ActivityScenario.launch(AuthActivity::class.java)
|
||||||
|
loginRobot
|
||||||
|
.setUsername("alma")
|
||||||
|
.setPassword("banan")
|
||||||
|
.assertUsername("alma")
|
||||||
|
.assertPassword("banan")
|
||||||
|
.clickOnLogin()
|
||||||
|
.assertLoadingBeforeRequests()
|
||||||
|
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
loginRobot.assertErrorIsShown(R.string.credentials_invalid)
|
||||||
|
.assertNotNavigatedToHome()
|
||||||
|
.assertNotLoading()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun GIVEN_password_and_username_and_error_response_WHEN_signIn_THEN_error_invalid_credentials_is_shown() {
|
||||||
|
mockServerScenarioSetupTestRule.mockServerScenarioSetup.setScenario(
|
||||||
|
AuthScenario.GenericError(username = "alma", password = "banan")
|
||||||
|
)
|
||||||
|
activityScenario = ActivityScenario.launch(AuthActivity::class.java)
|
||||||
|
loginRobot
|
||||||
|
.setUsername("alma")
|
||||||
|
.setPassword("banan")
|
||||||
|
.assertUsername("alma")
|
||||||
|
.assertPassword("banan")
|
||||||
|
.clickOnLogin()
|
||||||
|
.assertLoadingBeforeRequests()
|
||||||
|
|
||||||
|
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||||
|
loginRobot.assertErrorIsShown(R.string.something_went_wrong)
|
||||||
|
.assertNotNavigatedToHome()
|
||||||
|
.assertNotLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
package org.fnives.test.showcase.ui.login
|
||||||
|
|
||||||
|
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.Intents.intending
|
||||||
|
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.R
|
||||||
|
import org.fnives.test.showcase.testutils.configuration.LoginRobotConfiguration
|
||||||
|
import org.fnives.test.showcase.testutils.configuration.SnackbarVerificationTestRule
|
||||||
|
import org.fnives.test.showcase.testutils.configuration.SpecificTestConfigurationsFactory
|
||||||
|
import org.fnives.test.showcase.testutils.configuration.TestConfigurationsFactory
|
||||||
|
import org.fnives.test.showcase.testutils.robot.Robot
|
||||||
|
import org.fnives.test.showcase.testutils.viewactions.notIntended
|
||||||
|
import org.fnives.test.showcase.ui.home.MainActivity
|
||||||
|
import org.hamcrest.core.IsNot.not
|
||||||
|
|
||||||
|
class LoginRobot(
|
||||||
|
private val loginRobotConfiguration: LoginRobotConfiguration,
|
||||||
|
private val snackbarVerificationTestRule: SnackbarVerificationTestRule
|
||||||
|
) : Robot {
|
||||||
|
|
||||||
|
constructor(testConfigurationsFactory: TestConfigurationsFactory = SpecificTestConfigurationsFactory) :
|
||||||
|
this(
|
||||||
|
loginRobotConfiguration = testConfigurationsFactory.createLoginRobotConfiguration(),
|
||||||
|
snackbarVerificationTestRule = testConfigurationsFactory.createSnackbarVerification()
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun init() {
|
||||||
|
Intents.init()
|
||||||
|
intending(hasComponent(MainActivity::class.java.canonicalName))
|
||||||
|
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent()))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun release() {
|
||||||
|
Intents.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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 {
|
||||||
|
snackbarVerificationTestRule.assertIsShownWithText(stringResID)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertLoadingBeforeRequests() = apply {
|
||||||
|
if (loginRobotConfiguration.assertLoadingBeforeRequest) {
|
||||||
|
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 {
|
||||||
|
snackbarVerificationTestRule.assertIsNotShown()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertNavigatedToHome() = apply {
|
||||||
|
intended(hasComponent(MainActivity::class.java.canonicalName))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertNotNavigatedToHome() = apply {
|
||||||
|
notIntended(hasComponent(MainActivity::class.java.canonicalName))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
package org.fnives.test.showcase.ui.splash
|
||||||
|
|
||||||
|
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.test.core.app.ActivityScenario
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import org.fnives.test.showcase.testutils.MockServerScenarioSetupTestRule
|
||||||
|
import org.fnives.test.showcase.testutils.ReloadKoinModulesIfNecessaryTestRule
|
||||||
|
import org.fnives.test.showcase.testutils.configuration.SpecificTestConfigurationsFactory
|
||||||
|
import org.fnives.test.showcase.testutils.idling.Disposable
|
||||||
|
import org.fnives.test.showcase.testutils.idling.NetworkSynchronization
|
||||||
|
import org.fnives.test.showcase.testutils.robot.RobotTestRule
|
||||||
|
import org.fnives.test.showcase.testutils.statesetup.SetupLoggedInState
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.koin.test.KoinTest
|
||||||
|
|
||||||
|
@Suppress("TestFunctionName")
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class SplashActivityTest : KoinTest {
|
||||||
|
|
||||||
|
private lateinit var activityScenario: ActivityScenario<SplashActivity>
|
||||||
|
|
||||||
|
private val splashRobot: SplashRobot get() = robotTestRule.robot
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
@JvmField
|
||||||
|
val instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
@JvmField
|
||||||
|
val robotTestRule = RobotTestRule(SplashRobot())
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
@JvmField
|
||||||
|
val mainDispatcherTestRule = SpecificTestConfigurationsFactory.createMainDispatcherTestRule()
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
@JvmField
|
||||||
|
val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule()
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
@JvmField
|
||||||
|
val reloadKoinModulesIfNecessaryTestRule = ReloadKoinModulesIfNecessaryTestRule()
|
||||||
|
|
||||||
|
lateinit var disposable: Disposable
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
SpecificTestConfigurationsFactory.createServerTypeConfiguration()
|
||||||
|
.invoke(mockServerScenarioSetupTestRule.mockServerScenarioSetup)
|
||||||
|
disposable = NetworkSynchronization.registerNetworkingSynchronization()
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
activityScenario.moveToState(Lifecycle.State.DESTROYED)
|
||||||
|
disposable.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun GIVEN_loggedInState_WHEN_opened_THEN_MainActivity_is_started() {
|
||||||
|
SetupLoggedInState.setupLogin(mockServerScenarioSetupTestRule.mockServerScenarioSetup)
|
||||||
|
|
||||||
|
activityScenario = ActivityScenario.launch(SplashActivity::class.java)
|
||||||
|
|
||||||
|
mainDispatcherTestRule.advanceTimeBy(500)
|
||||||
|
|
||||||
|
splashRobot.assertHomeIsStarted()
|
||||||
|
.assertAuthIsNotStarted()
|
||||||
|
|
||||||
|
SetupLoggedInState.setupLogout()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun GIVEN_loggedOffState_WHEN_opened_THEN_AuthActivity_is_started() {
|
||||||
|
SetupLoggedInState.setupLogout()
|
||||||
|
|
||||||
|
activityScenario = ActivityScenario.launch(SplashActivity::class.java)
|
||||||
|
|
||||||
|
mainDispatcherTestRule.advanceTimeBy(500)
|
||||||
|
|
||||||
|
splashRobot.assertAuthIsStarted()
|
||||||
|
.assertHomeIsNotStarted()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
package org.fnives.test.showcase.ui.splash
|
||||||
|
|
||||||
|
import android.app.Instrumentation
|
||||||
|
import androidx.test.espresso.intent.Intents
|
||||||
|
import androidx.test.espresso.intent.matcher.IntentMatchers
|
||||||
|
import org.fnives.test.showcase.testutils.robot.Robot
|
||||||
|
import org.fnives.test.showcase.testutils.viewactions.notIntended
|
||||||
|
import org.fnives.test.showcase.ui.auth.AuthActivity
|
||||||
|
import org.fnives.test.showcase.ui.home.MainActivity
|
||||||
|
|
||||||
|
class SplashRobot : Robot {
|
||||||
|
|
||||||
|
override fun init() {
|
||||||
|
Intents.init()
|
||||||
|
Intents.intending(IntentMatchers.hasComponent(MainActivity::class.java.canonicalName))
|
||||||
|
.respondWith(Instrumentation.ActivityResult(0, null))
|
||||||
|
Intents.intending(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
|
||||||
|
.respondWith(Instrumentation.ActivityResult(0, null))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun release() {
|
||||||
|
Intents.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/src/test/java/org/fnives/test/showcase/di/DITest.kt
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
package org.fnives.test.showcase.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.fnives.test.showcase.model.network.BaseUrl
|
||||||
|
import org.fnives.test.showcase.testutils.TestMainDispatcher
|
||||||
|
import org.fnives.test.showcase.ui.auth.AuthViewModel
|
||||||
|
import org.fnives.test.showcase.ui.home.MainViewModel
|
||||||
|
import org.fnives.test.showcase.ui.splash.SplashViewModel
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
|
import org.koin.core.context.startKoin
|
||||||
|
import org.koin.core.context.stopKoin
|
||||||
|
import org.koin.test.KoinTest
|
||||||
|
import org.koin.test.check.checkModules
|
||||||
|
import org.koin.test.inject
|
||||||
|
import org.mockito.kotlin.anyOrNull
|
||||||
|
import org.mockito.kotlin.doReturn
|
||||||
|
import org.mockito.kotlin.mock
|
||||||
|
import org.mockito.kotlin.whenever
|
||||||
|
|
||||||
|
@ExtendWith(TestMainDispatcher::class)
|
||||||
|
class DITest : KoinTest {
|
||||||
|
|
||||||
|
private val authViewModel by inject<AuthViewModel>()
|
||||||
|
private val mainViewModel by inject<MainViewModel>()
|
||||||
|
private val splashViewModel by inject<SplashViewModel>()
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
TestMainDispatcher.testDispatcher.pauseDispatcher()
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun tearDown() {
|
||||||
|
stopKoin()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun verifyStaticModules() {
|
||||||
|
val mockContext = mock<Context>()
|
||||||
|
whenever(mockContext.getSharedPreferences(anyOrNull(), anyOrNull())).doReturn(mock())
|
||||||
|
checkModules {
|
||||||
|
androidContext(mockContext)
|
||||||
|
modules(createAppModules(BaseUrl("https://a.com/")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun verifyViewModelModules() {
|
||||||
|
val mockContext = mock<Context>()
|
||||||
|
whenever(mockContext.getSharedPreferences(anyOrNull(), anyOrNull())).doReturn(mock())
|
||||||
|
startKoin {
|
||||||
|
androidContext(mockContext)
|
||||||
|
modules(createAppModules(BaseUrl("https://a.com/")))
|
||||||
|
}
|
||||||
|
authViewModel
|
||||||
|
mainViewModel
|
||||||
|
splashViewModel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
package org.fnives.test.showcase.testutils
|
||||||
|
|
||||||
|
import androidx.arch.core.executor.ArchTaskExecutor
|
||||||
|
import androidx.arch.core.executor.TaskExecutor
|
||||||
|
import org.junit.jupiter.api.extension.AfterEachCallback
|
||||||
|
import org.junit.jupiter.api.extension.BeforeEachCallback
|
||||||
|
import org.junit.jupiter.api.extension.ExtensionContext
|
||||||
|
|
||||||
|
class InstantExecutorExtension : BeforeEachCallback, AfterEachCallback {
|
||||||
|
|
||||||
|
override fun beforeEach(context: ExtensionContext?) {
|
||||||
|
ArchTaskExecutor.getInstance()
|
||||||
|
.setDelegate(object : TaskExecutor() {
|
||||||
|
override fun executeOnDiskIO(runnable: Runnable) = runnable.run()
|
||||||
|
|
||||||
|
override fun postToMainThread(runnable: Runnable) = runnable.run()
|
||||||
|
|
||||||
|
override fun isMainThread(): Boolean = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun afterEach(context: ExtensionContext?) {
|
||||||
|
ArchTaskExecutor.getInstance().setDelegate(null)
|
||||||
|
}
|
||||||
|
}
|
||||||