Skip to content

Commit 7d7150d

Browse files
authored
Arrow.kt (#43)
* implements * fix deps * wip * wip * mapper tests * mapper tests * fix unit tests * fix unit tests * fix unit tests * add tests * add tests * rename * update tests * up * wip * wip * rename * add UserRepositoryImplTest * better * update * remove avatar field * more tests for UserErrorMapper * add test_getUsers_withApiCallSuccess_emitsInitial * done data tests
1 parent ecf4883 commit 7d7150d

File tree

41 files changed

+975
-222
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+975
-222
lines changed

.idea/codeStyles/Project.xml

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.idea/codeStyles/codeStyleConfig.xml

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
## Coroutine + Flow = MVI :heart:
1111
* Play MVI with Kotlin Coroutines Flow.
1212
* Multiple modules, Clean Architecture.
13+
* Unit tests for MVI ViewModel, domain and data layer.
1314
* Master branch using Koin for DI.
1415
* **Checkout [dagger_hilt branch](https://github.com/Kotlin-Android-Open-Source/MVI-Coroutines-Flow/tree/dagger_hilt), using Dagger Hilt for DI** (_obsolete_).
1516
* **[Download latest debug APK here](https://nightly.link/Kotlin-Android-Open-Source/MVI-Coroutines-Flow/workflows/build/master/app-debug.zip)**.

app/src/main/java/com/hoc/flowmvi/App.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import org.koin.core.logger.Level
1616
import kotlin.time.ExperimentalTime
1717

1818
@Suppress("unused")
19+
@ExperimentalStdlibApi
1920
@FlowPreview
2021
@ExperimentalCoroutinesApi
2122
@ExperimentalTime

app/src/main/java/com/hoc/flowmvi/core/CoreModule.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import com.hoc.flowmvi.core.navigator.Navigator
55
import org.koin.dsl.module
66

77
val coreModule = module {
8-
single<CoroutineDispatchers> { CoroutineDispatchersImpl() }
8+
single<CoroutineDispatchers> { DefaultCoroutineDispatchers() }
99

1010
single<Navigator> { NavigatorImpl(add = get(), search = get()) }
1111
}

app/src/main/java/com/hoc/flowmvi/core/CoroutineDispatchersImpl.kt renamed to app/src/main/java/com/hoc/flowmvi/core/DefaultCoroutineDispatchers.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import com.hoc.flowmvi.core.dispatchers.CoroutineDispatchers
44
import kotlinx.coroutines.CoroutineDispatcher
55
import kotlinx.coroutines.Dispatchers
66

7-
internal class CoroutineDispatchersImpl(
8-
override val main: CoroutineDispatcher = Dispatchers.Main,
7+
internal class DefaultCoroutineDispatchers : CoroutineDispatchers {
8+
override val main: CoroutineDispatcher = Dispatchers.Main
99
override val io: CoroutineDispatcher = Dispatchers.IO
10-
) : CoroutineDispatchers
10+
}

buildSrc/src/main/kotlin/deps.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ object deps {
4646
const val retrofit = "com.squareup.retrofit2:retrofit:2.9.0"
4747
const val converterMoshi = "com.squareup.retrofit2:converter-moshi:2.9.0"
4848
const val loggingInterceptor = "com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.2"
49-
const val moshiKotlin = "com.squareup.moshi:moshi-kotlin:1.11.0"
49+
const val moshiKotlin = "com.squareup.moshi:moshi-kotlin:1.12.0"
5050
const val leakCanary = "com.squareup.leakcanary:leakcanary-android:2.7"
5151
}
5252

@@ -69,6 +69,11 @@ object deps {
6969
const val viewBindingDelegate = "com.github.hoc081098:ViewBindingDelegate:1.2.0"
7070
const val flowExt = "io.github.hoc081098:FlowExt:0.0.7-SNAPSHOT"
7171

72+
object arrow {
73+
private const val version = "1.0.0"
74+
const val core = "io.arrow-kt:arrow-core:$version"
75+
}
76+
7277
object test {
7378
const val junit = "junit:junit:4.13.2"
7479
const val androidxJunit = "androidx.test.ext:junit:1.1.2"
@@ -86,6 +91,7 @@ inline val PDsS.androidApplication: PDS get() = id("com.android.application")
8691
inline val PDsS.androidLib: PDS get() = id("com.android.library")
8792
inline val PDsS.kotlinAndroid: PDS get() = id("kotlin-android")
8893
inline val PDsS.kotlin: PDS get() = id("kotlin")
94+
inline val PDsS.kotlinKapt: PDS get() = id("kotlin-kapt")
8995

9096
inline val DependencyHandler.domain get() = project(":domain")
9197
inline val DependencyHandler.core get() = project(":core")

core/src/main/java/com/hoc/flowmvi/core/Either.kt

Lines changed: 0 additions & 16 deletions
This file was deleted.

data/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
plugins {
22
androidLib
33
kotlinAndroid
4+
kotlinKapt
45
}
56

67
android {
@@ -49,6 +50,7 @@ dependencies {
4950
implementation(deps.squareup.loggingInterceptor)
5051

5152
implementation(deps.koin.core)
53+
implementation(deps.arrow.core)
5254

5355
addUnitTest()
5456
}

data/src/main/java/com/hoc/flowmvi/data/DataModule.kt

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package com.hoc.flowmvi.data
22

33
import com.hoc.flowmvi.data.mapper.UserDomainToUserBodyMapper
4-
import com.hoc.flowmvi.data.mapper.UserDomainToUserResponseMapper
4+
import com.hoc.flowmvi.data.mapper.UserErrorMapper
55
import com.hoc.flowmvi.data.mapper.UserResponseToUserDomainMapper
6+
import com.hoc.flowmvi.data.remote.ErrorResponse
67
import com.hoc.flowmvi.data.remote.UserApiService
78
import com.hoc.flowmvi.domain.repository.UserRepository
89
import com.squareup.moshi.Moshi
10+
import com.squareup.moshi.adapter
911
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
1012
import kotlinx.coroutines.ExperimentalCoroutinesApi
1113
import okhttp3.OkHttpClient
@@ -18,16 +20,17 @@ import retrofit2.converter.moshi.MoshiConverterFactory
1820
import java.util.concurrent.TimeUnit
1921
import kotlin.time.ExperimentalTime
2022

21-
private const val BASE_URL = "BASE_URL"
23+
val BASE_URL_QUALIFIER = named("BASE_URL")
2224

25+
@ExperimentalStdlibApi
2326
@ExperimentalTime
2427
@ExperimentalCoroutinesApi
2528
val dataModule = module {
2629
single { UserApiService(retrofit = get()) }
2730

2831
single {
2932
provideRetrofit(
30-
baseUrl = get(named(BASE_URL)),
33+
baseUrl = get(BASE_URL_QUALIFIER),
3134
moshi = get(),
3235
client = get()
3336
)
@@ -37,21 +40,23 @@ val dataModule = module {
3740

3841
single { provideOkHttpClient() }
3942

40-
factory(named(BASE_URL)) { "https://mvi-coroutines-flow-server.herokuapp.com/" }
43+
factory(BASE_URL_QUALIFIER) { "https://mvi-coroutines-flow-server.herokuapp.com/" }
4144

4245
factory { UserResponseToUserDomainMapper() }
4346

44-
factory { UserDomainToUserResponseMapper() }
45-
4647
factory { UserDomainToUserBodyMapper() }
4748

49+
factory { get<Moshi>().adapter<ErrorResponse>() }
50+
51+
factory { UserErrorMapper(errorResponseJsonAdapter = get()) }
52+
4853
single<UserRepository> {
4954
UserRepositoryImpl(
5055
userApiService = get(),
5156
dispatchers = get(),
5257
responseToDomain = get<UserResponseToUserDomainMapper>(),
53-
domainToResponse = get<UserDomainToUserResponseMapper>(),
54-
domainToBody = get<UserDomainToUserBodyMapper>()
58+
domainToBody = get<UserDomainToUserBodyMapper>(),
59+
errorMapper = get<UserErrorMapper>(),
5560
)
5661
}
5762
}
Lines changed: 41 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,46 @@
11
package com.hoc.flowmvi.data
22

33
import android.util.Log
4+
import arrow.core.Either
5+
import arrow.core.left
6+
import arrow.core.leftWiden
7+
import arrow.core.right
48
import com.hoc.flowmvi.core.Mapper
59
import com.hoc.flowmvi.core.dispatchers.CoroutineDispatchers
610
import com.hoc.flowmvi.core.retrySuspend
711
import com.hoc.flowmvi.data.remote.UserApiService
812
import com.hoc.flowmvi.data.remote.UserBody
913
import com.hoc.flowmvi.data.remote.UserResponse
1014
import com.hoc.flowmvi.domain.entity.User
15+
import com.hoc.flowmvi.domain.repository.UserError
1116
import com.hoc.flowmvi.domain.repository.UserRepository
1217
import kotlinx.coroutines.ExperimentalCoroutinesApi
1318
import kotlinx.coroutines.delay
14-
import kotlinx.coroutines.flow.Flow
1519
import kotlinx.coroutines.flow.MutableSharedFlow
20+
import kotlinx.coroutines.flow.catch
1621
import kotlinx.coroutines.flow.emitAll
1722
import kotlinx.coroutines.flow.flow
23+
import kotlinx.coroutines.flow.map
1824
import kotlinx.coroutines.flow.onEach
1925
import kotlinx.coroutines.flow.scan
2026
import kotlinx.coroutines.withContext
27+
import java.io.IOException
2128
import kotlin.time.Duration
2229
import kotlin.time.ExperimentalTime
2330

2431
@ExperimentalTime
2532
@ExperimentalCoroutinesApi
26-
internal class UserRepositoryImpl constructor(
33+
internal class UserRepositoryImpl(
2734
private val userApiService: UserApiService,
2835
private val dispatchers: CoroutineDispatchers,
2936
private val responseToDomain: Mapper<UserResponse, User>,
30-
private val domainToResponse: Mapper<User, UserResponse>,
31-
private val domainToBody: Mapper<User, UserBody>
37+
private val domainToBody: Mapper<User, UserBody>,
38+
private val errorMapper: Mapper<Throwable, UserError>,
3239
) : UserRepository {
3340

3441
private sealed class Change {
35-
data class Removed(val removed: User) : Change()
36-
data class Refreshed(val user: List<User>) : Change()
42+
class Removed(val removed: User) : Change()
43+
class Refreshed(val user: List<User>) : Change()
3744
class Added(val user: User) : Change()
3845
}
3946

@@ -45,59 +52,58 @@ internal class UserRepositoryImpl constructor(
4552
times = 3,
4653
initialDelay = Duration.milliseconds(500),
4754
factor = 2.0,
55+
shouldRetry = { it is IOException }
4856
) {
4957
Log.d("###", "[USER_REPO] Retry times=$it")
5058
userApiService.getUsers().map(responseToDomain)
5159
}
5260
}
5361
}
5462

55-
override fun getUsers(): Flow<List<User>> {
56-
return flow {
57-
val initial = getUsersFromRemote()
63+
override fun getUsers() = flow {
64+
val initial = getUsersFromRemote()
5865

59-
changesFlow
60-
.onEach { Log.d("###", "[USER_REPO] Change=$it") }
61-
.scan(initial) { acc, change ->
62-
when (change) {
63-
is Change.Removed -> acc.filter { it.id != change.removed.id }
64-
is Change.Refreshed -> change.user
65-
is Change.Added -> acc + change.user
66-
}
66+
changesFlow
67+
.onEach { Log.d("###", "[USER_REPO] Change=$it") }
68+
.scan(initial) { acc, change ->
69+
when (change) {
70+
is Change.Removed -> acc.filter { it.id != change.removed.id }
71+
is Change.Refreshed -> change.user
72+
is Change.Added -> acc + change.user
6773
}
68-
.onEach { Log.d("###", "[USER_REPO] Emit users.size=${it.size} ") }
69-
.let { emitAll(it) }
70-
}
74+
}
75+
.onEach { Log.d("###", "[USER_REPO] Emit users.size=${it.size} ") }
76+
.let { emitAll(it) }
7177
}
78+
.map { it.right().leftWiden<UserError, Nothing, List<User>>() }
79+
.catch { emit(errorMapper(it).left()) }
7280

73-
override suspend fun refresh() =
81+
override suspend fun refresh() = Either.catch(errorMapper) {
7482
getUsersFromRemote().let { changesFlow.emit(Change.Refreshed(it)) }
83+
}
7584

76-
override suspend fun remove(user: User) {
85+
override suspend fun remove(user: User) = Either.catch(errorMapper) {
7786
withContext(dispatchers.io) {
78-
val response = userApiService.remove(domainToResponse(user).id)
87+
val response = userApiService.remove(user.id)
7988
changesFlow.emit(Change.Removed(responseToDomain(response)))
8089
}
8190
}
8291

83-
override suspend fun add(user: User) {
92+
override suspend fun add(user: User) = Either.catch(errorMapper) {
8493
withContext(dispatchers.io) {
85-
val body = domainToBody(user).copy(avatar = avatarUrls.random())
94+
val body = domainToBody(user)
8695
val response = userApiService.add(body)
8796
changesFlow.emit(Change.Added(responseToDomain(response)))
88-
delay(400)
97+
extraDelay()
8998
}
9099
}
91100

92-
override suspend fun search(query: String) = withContext(dispatchers.io) {
93-
delay(400)
94-
userApiService.search(query).map(responseToDomain)
101+
override suspend fun search(query: String) = Either.catch(errorMapper) {
102+
withContext(dispatchers.io) {
103+
extraDelay()
104+
userApiService.search(query).map(responseToDomain)
105+
}
95106
}
96107

97-
companion object {
98-
private val avatarUrls =
99-
(0 until 100).map { "https://randomuser.me/api/portraits/men/$it.jpg" } +
100-
(0 until 100).map { "https://randomuser.me/api/portraits/women/$it.jpg" } +
101-
(0 until 10).map { "https://randomuser.me/api/portraits/lego/$it.jpg" }
102-
}
108+
private suspend inline fun extraDelay() = delay(400)
103109
}

data/src/main/java/com/hoc/flowmvi/data/mapper/UserDomainToUserBodyMapper.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ internal class UserDomainToUserBodyMapper : Mapper<User, UserBody> {
88
override fun invoke(domain: User): UserBody {
99
return UserBody(
1010
email = domain.email,
11-
avatar = domain.avatar,
1211
firstName = domain.firstName,
1312
lastName = domain.lastName
1413
)

data/src/main/java/com/hoc/flowmvi/data/mapper/UserDomainToUserResponseMapper.kt

Lines changed: 0 additions & 17 deletions
This file was deleted.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package com.hoc.flowmvi.data.mapper
2+
3+
import arrow.core.nonFatalOrThrow
4+
import com.hoc.flowmvi.core.Mapper
5+
import com.hoc.flowmvi.data.remote.ErrorResponse
6+
import com.hoc.flowmvi.domain.repository.UserError
7+
import com.squareup.moshi.JsonAdapter
8+
import okhttp3.ResponseBody
9+
import retrofit2.HttpException
10+
import java.io.IOException
11+
import java.net.SocketException
12+
import java.net.SocketTimeoutException
13+
import java.net.UnknownHostException
14+
15+
internal class UserErrorMapper(private val errorResponseJsonAdapter: JsonAdapter<ErrorResponse>) :
16+
Mapper<Throwable, UserError> {
17+
override fun invoke(throwable: Throwable): UserError {
18+
throwable.nonFatalOrThrow()
19+
20+
return runCatching {
21+
when (throwable) {
22+
is IOException -> when (throwable) {
23+
is UnknownHostException -> UserError.NetworkError
24+
is SocketTimeoutException -> UserError.NetworkError
25+
is SocketException -> UserError.NetworkError
26+
else -> UserError.NetworkError
27+
}
28+
is HttpException ->
29+
throwable.response()!!
30+
.takeUnless { it.isSuccessful }!!
31+
.errorBody()!!
32+
.use(ResponseBody::string)
33+
.let { mapResponseError(it) }
34+
else -> UserError.Unexpected
35+
}
36+
}.getOrElse {
37+
it.nonFatalOrThrow()
38+
UserError.Unexpected
39+
}
40+
}
41+
42+
@Throws(Throwable::class)
43+
private fun mapResponseError(json: String): UserError {
44+
val errorResponse = errorResponseJsonAdapter.fromJson(json)!!
45+
46+
return when (errorResponse.error) {
47+
"internal-error" -> UserError.ServerError
48+
"invalid-id" -> UserError.InvalidId(id = errorResponse.data as String)
49+
"user-not-found" -> UserError.UserNotFound(id = errorResponse.data as String)
50+
"validation-failed" -> UserError.ValidationFailed
51+
else -> UserError.Unexpected
52+
}
53+
}
54+
}

0 commit comments

Comments
 (0)