initial commit

This commit is contained in:
Gergely Hegedus 2021-04-07 21:12:10 +03:00
parent 85ef73b2ba
commit 90a9426b7d
221 changed files with 7611 additions and 0 deletions

1
app/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

144
app/build.gradle Normal file
View 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
View 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

View file

@ -0,0 +1,5 @@
package org.fnives.test.showcase.testutils.configuration
object AndroidTestLoginRobotConfiguration : LoginRobotConfiguration {
override val assertLoadingBeforeRequest: Boolean get() = false
}

View file

@ -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)
}
}

View file

@ -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 }
}
)
}
}

View file

@ -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)
}
}
}
}

View file

@ -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
}

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -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()))
}
}
}

View file

@ -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)
}

View file

@ -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()) }
}

View file

@ -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)
)
}
}
}

View file

@ -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
}

View file

@ -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)
}
}
}

View file

@ -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()
}

View file

@ -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))
}
}

View file

@ -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)
}

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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)
}
}
}

View file

@ -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
}
}

View file

@ -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)
}
}
}
}

View file

@ -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)
}
}
}
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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
}

View file

@ -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()
}
}
}

View file

@ -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
}
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View 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>

View 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>

View 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>

View 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>

View 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>

View file

@ -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())
}
}

View file

@ -0,0 +1,5 @@
package org.fnives.test.showcase.testutils.configuration
object RobolectricLoginRobotConfiguration : LoginRobotConfiguration {
override val assertLoadingBeforeRequest: Boolean = true
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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()
}
}
}
}

View file

@ -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)
}

View file

@ -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()
}
}

View file

@ -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()
}
}

View file

@ -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()
}
}
}
}

View file

@ -0,0 +1,6 @@
package org.fnives.test.showcase.testutils.configuration
interface LoginRobotConfiguration {
val assertLoadingBeforeRequest: Boolean
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -0,0 +1,5 @@
package org.fnives.test.showcase.testutils.configuration
import org.junit.rules.TestRule
interface SnackBarTestRule : TestRule

View file

@ -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()
}

View file

@ -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
}

View file

@ -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() }
}
}

View file

@ -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()
}
}
}

View file

@ -0,0 +1,6 @@
package org.fnives.test.showcase.testutils.idling
interface Disposable {
val isDisposed: Boolean
fun dispose()
}

View file

@ -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)
}
}

View file

@ -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")
}
}

View file

@ -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))
}

View file

@ -0,0 +1,8 @@
package org.fnives.test.showcase.testutils.robot
interface Robot {
fun init()
fun release()
}

View file

@ -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()
}
}
}
}

View file

@ -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() }
}
}

View file

@ -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()
}
}

View file

@ -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()
}
}
}

View file

@ -0,0 +1,5 @@
@file:Suppress("PackageDirectoryMismatch")
package androidx.swiperefreshlayout.widget
fun SwipeRefreshLayout.listener() = mListener

View file

@ -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())
}
}

View file

@ -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")
}

View file

@ -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))))
}
}

View file

@ -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())
}
}

View file

@ -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()
}
}

View file

@ -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))
}
}

View file

@ -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()
}
}

View file

@ -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))
}
}

View 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
}
}

View file

@ -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)
}
}

View file

@ -0,0 +1,31 @@
package org.fnives.test.showcase.testutils
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.junit.jupiter.api.extension.AfterEachCallback
import org.junit.jupiter.api.extension.BeforeEachCallback
import org.junit.jupiter.api.extension.ExtensionContext
class TestMainDispatcher : BeforeEachCallback, AfterEachCallback {
override fun beforeEach(context: ExtensionContext?) {
val testDispatcher = TestCoroutineDispatcher()
privateTestDispatcher = testDispatcher
DatabaseInitialization.dispatcher = testDispatcher
Dispatchers.setMain(testDispatcher)
}
override fun afterEach(context: ExtensionContext?) {
Dispatchers.resetMain()
privateTestDispatcher = null
}
companion object {
private var privateTestDispatcher: TestCoroutineDispatcher? = null
val testDispatcher: TestCoroutineDispatcher
get() = privateTestDispatcher ?: throw IllegalStateException("TestMainDispatcher is in afterEach State")
}
}

Some files were not shown because too many files have changed in this diff Show more