Skip to content

Arrow.kt #43

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 24 commits into from
Oct 22, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .idea/codeStyles/Project.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion .idea/codeStyles/codeStyleConfig.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
## Coroutine + Flow = MVI :heart:
* Play MVI with Kotlin Coroutines Flow.
* Multiple modules, Clean Architecture.
* Unit tests for MVI ViewModel, domain and data layer.
* Master branch using Koin for DI.
* **Checkout [dagger_hilt branch](https://github.com/Kotlin-Android-Open-Source/MVI-Coroutines-Flow/tree/dagger_hilt), using Dagger Hilt for DI** (_obsolete_).
* **[Download latest debug APK here](https://nightly.link/Kotlin-Android-Open-Source/MVI-Coroutines-Flow/workflows/build/master/app-debug.zip)**.
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/com/hoc/flowmvi/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import org.koin.core.logger.Level
import kotlin.time.ExperimentalTime

@Suppress("unused")
@ExperimentalStdlibApi
@FlowPreview
@ExperimentalCoroutinesApi
@ExperimentalTime
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/com/hoc/flowmvi/core/CoreModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import com.hoc.flowmvi.core.navigator.Navigator
import org.koin.dsl.module

val coreModule = module {
single<CoroutineDispatchers> { CoroutineDispatchersImpl() }
single<CoroutineDispatchers> { DefaultCoroutineDispatchers() }

single<Navigator> { NavigatorImpl(add = get(), search = get()) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import com.hoc.flowmvi.core.dispatchers.CoroutineDispatchers
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers

internal class CoroutineDispatchersImpl(
override val main: CoroutineDispatcher = Dispatchers.Main,
internal class DefaultCoroutineDispatchers : CoroutineDispatchers {
override val main: CoroutineDispatcher = Dispatchers.Main
override val io: CoroutineDispatcher = Dispatchers.IO
) : CoroutineDispatchers
}
8 changes: 7 additions & 1 deletion buildSrc/src/main/kotlin/deps.kt
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ object deps {
const val retrofit = "com.squareup.retrofit2:retrofit:2.9.0"
const val converterMoshi = "com.squareup.retrofit2:converter-moshi:2.9.0"
const val loggingInterceptor = "com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.2"
const val moshiKotlin = "com.squareup.moshi:moshi-kotlin:1.11.0"
const val moshiKotlin = "com.squareup.moshi:moshi-kotlin:1.12.0"
const val leakCanary = "com.squareup.leakcanary:leakcanary-android:2.7"
}

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

object arrow {
private const val version = "1.0.0"
const val core = "io.arrow-kt:arrow-core:$version"
}

object test {
const val junit = "junit:junit:4.13.2"
const val androidxJunit = "androidx.test.ext:junit:1.1.2"
Expand All @@ -86,6 +91,7 @@ inline val PDsS.androidApplication: PDS get() = id("com.android.application")
inline val PDsS.androidLib: PDS get() = id("com.android.library")
inline val PDsS.kotlinAndroid: PDS get() = id("kotlin-android")
inline val PDsS.kotlin: PDS get() = id("kotlin")
inline val PDsS.kotlinKapt: PDS get() = id("kotlin-kapt")

inline val DependencyHandler.domain get() = project(":domain")
inline val DependencyHandler.core get() = project(":core")
Expand Down
16 changes: 0 additions & 16 deletions core/src/main/java/com/hoc/flowmvi/core/Either.kt

This file was deleted.

2 changes: 2 additions & 0 deletions data/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
plugins {
androidLib
kotlinAndroid
kotlinKapt
}

android {
Expand Down Expand Up @@ -49,6 +50,7 @@ dependencies {
implementation(deps.squareup.loggingInterceptor)

implementation(deps.koin.core)
implementation(deps.arrow.core)

addUnitTest()
}
21 changes: 13 additions & 8 deletions data/src/main/java/com/hoc/flowmvi/data/DataModule.kt
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package com.hoc.flowmvi.data

import com.hoc.flowmvi.data.mapper.UserDomainToUserBodyMapper
import com.hoc.flowmvi.data.mapper.UserDomainToUserResponseMapper
import com.hoc.flowmvi.data.mapper.UserErrorMapper
import com.hoc.flowmvi.data.mapper.UserResponseToUserDomainMapper
import com.hoc.flowmvi.data.remote.ErrorResponse
import com.hoc.flowmvi.data.remote.UserApiService
import com.hoc.flowmvi.domain.repository.UserRepository
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.ExperimentalCoroutinesApi
import okhttp3.OkHttpClient
Expand All @@ -18,16 +20,17 @@ import retrofit2.converter.moshi.MoshiConverterFactory
import java.util.concurrent.TimeUnit
import kotlin.time.ExperimentalTime

private const val BASE_URL = "BASE_URL"
val BASE_URL_QUALIFIER = named("BASE_URL")

@ExperimentalStdlibApi
@ExperimentalTime
@ExperimentalCoroutinesApi
val dataModule = module {
single { UserApiService(retrofit = get()) }

single {
provideRetrofit(
baseUrl = get(named(BASE_URL)),
baseUrl = get(BASE_URL_QUALIFIER),
moshi = get(),
client = get()
)
Expand All @@ -37,21 +40,23 @@ val dataModule = module {

single { provideOkHttpClient() }

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

factory { UserResponseToUserDomainMapper() }

factory { UserDomainToUserResponseMapper() }

factory { UserDomainToUserBodyMapper() }

factory { get<Moshi>().adapter<ErrorResponse>() }

factory { UserErrorMapper(errorResponseJsonAdapter = get()) }

single<UserRepository> {
UserRepositoryImpl(
userApiService = get(),
dispatchers = get(),
responseToDomain = get<UserResponseToUserDomainMapper>(),
domainToResponse = get<UserDomainToUserResponseMapper>(),
domainToBody = get<UserDomainToUserBodyMapper>()
domainToBody = get<UserDomainToUserBodyMapper>(),
errorMapper = get<UserErrorMapper>(),
)
}
}
Expand Down
76 changes: 41 additions & 35 deletions data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt
Original file line number Diff line number Diff line change
@@ -1,39 +1,46 @@
package com.hoc.flowmvi.data

import android.util.Log
import arrow.core.Either
import arrow.core.left
import arrow.core.leftWiden
import arrow.core.right
import com.hoc.flowmvi.core.Mapper
import com.hoc.flowmvi.core.dispatchers.CoroutineDispatchers
import com.hoc.flowmvi.core.retrySuspend
import com.hoc.flowmvi.data.remote.UserApiService
import com.hoc.flowmvi.data.remote.UserBody
import com.hoc.flowmvi.data.remote.UserResponse
import com.hoc.flowmvi.domain.entity.User
import com.hoc.flowmvi.domain.repository.UserError
import com.hoc.flowmvi.domain.repository.UserRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.scan
import kotlinx.coroutines.withContext
import java.io.IOException
import kotlin.time.Duration
import kotlin.time.ExperimentalTime

@ExperimentalTime
@ExperimentalCoroutinesApi
internal class UserRepositoryImpl constructor(
internal class UserRepositoryImpl(
private val userApiService: UserApiService,
private val dispatchers: CoroutineDispatchers,
private val responseToDomain: Mapper<UserResponse, User>,
private val domainToResponse: Mapper<User, UserResponse>,
private val domainToBody: Mapper<User, UserBody>
private val domainToBody: Mapper<User, UserBody>,
private val errorMapper: Mapper<Throwable, UserError>,
) : UserRepository {

private sealed class Change {
data class Removed(val removed: User) : Change()
data class Refreshed(val user: List<User>) : Change()
class Removed(val removed: User) : Change()
class Refreshed(val user: List<User>) : Change()
class Added(val user: User) : Change()
}

Expand All @@ -45,59 +52,58 @@ internal class UserRepositoryImpl constructor(
times = 3,
initialDelay = Duration.milliseconds(500),
factor = 2.0,
shouldRetry = { it is IOException }
) {
Log.d("###", "[USER_REPO] Retry times=$it")
userApiService.getUsers().map(responseToDomain)
}
}
}

override fun getUsers(): Flow<List<User>> {
return flow {
val initial = getUsersFromRemote()
override fun getUsers() = flow {
val initial = getUsersFromRemote()

changesFlow
.onEach { Log.d("###", "[USER_REPO] Change=$it") }
.scan(initial) { acc, change ->
when (change) {
is Change.Removed -> acc.filter { it.id != change.removed.id }
is Change.Refreshed -> change.user
is Change.Added -> acc + change.user
}
changesFlow
.onEach { Log.d("###", "[USER_REPO] Change=$it") }
.scan(initial) { acc, change ->
when (change) {
is Change.Removed -> acc.filter { it.id != change.removed.id }
is Change.Refreshed -> change.user
is Change.Added -> acc + change.user
}
.onEach { Log.d("###", "[USER_REPO] Emit users.size=${it.size} ") }
.let { emitAll(it) }
}
}
.onEach { Log.d("###", "[USER_REPO] Emit users.size=${it.size} ") }
.let { emitAll(it) }
}
.map { it.right().leftWiden<UserError, Nothing, List<User>>() }
.catch { emit(errorMapper(it).left()) }

override suspend fun refresh() =
override suspend fun refresh() = Either.catch(errorMapper) {
getUsersFromRemote().let { changesFlow.emit(Change.Refreshed(it)) }
}

override suspend fun remove(user: User) {
override suspend fun remove(user: User) = Either.catch(errorMapper) {
withContext(dispatchers.io) {
val response = userApiService.remove(domainToResponse(user).id)
val response = userApiService.remove(user.id)
changesFlow.emit(Change.Removed(responseToDomain(response)))
}
}

override suspend fun add(user: User) {
override suspend fun add(user: User) = Either.catch(errorMapper) {
withContext(dispatchers.io) {
val body = domainToBody(user).copy(avatar = avatarUrls.random())
val body = domainToBody(user)
val response = userApiService.add(body)
changesFlow.emit(Change.Added(responseToDomain(response)))
delay(400)
extraDelay()
}
}

override suspend fun search(query: String) = withContext(dispatchers.io) {
delay(400)
userApiService.search(query).map(responseToDomain)
override suspend fun search(query: String) = Either.catch(errorMapper) {
withContext(dispatchers.io) {
extraDelay()
userApiService.search(query).map(responseToDomain)
}
}

companion object {
private val avatarUrls =
(0 until 100).map { "https://randomuser.me/api/portraits/men/$it.jpg" } +
(0 until 100).map { "https://randomuser.me/api/portraits/women/$it.jpg" } +
(0 until 10).map { "https://randomuser.me/api/portraits/lego/$it.jpg" }
}
private suspend inline fun extraDelay() = delay(400)
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ internal class UserDomainToUserBodyMapper : Mapper<User, UserBody> {
override fun invoke(domain: User): UserBody {
return UserBody(
email = domain.email,
avatar = domain.avatar,
firstName = domain.firstName,
lastName = domain.lastName
)
Expand Down

This file was deleted.

54 changes: 54 additions & 0 deletions data/src/main/java/com/hoc/flowmvi/data/mapper/UserErrorMapper.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.hoc.flowmvi.data.mapper

import arrow.core.nonFatalOrThrow
import com.hoc.flowmvi.core.Mapper
import com.hoc.flowmvi.data.remote.ErrorResponse
import com.hoc.flowmvi.domain.repository.UserError
import com.squareup.moshi.JsonAdapter
import okhttp3.ResponseBody
import retrofit2.HttpException
import java.io.IOException
import java.net.SocketException
import java.net.SocketTimeoutException
import java.net.UnknownHostException

internal class UserErrorMapper(private val errorResponseJsonAdapter: JsonAdapter<ErrorResponse>) :
Mapper<Throwable, UserError> {
override fun invoke(throwable: Throwable): UserError {
throwable.nonFatalOrThrow()

return runCatching {
when (throwable) {
is IOException -> when (throwable) {
is UnknownHostException -> UserError.NetworkError
is SocketTimeoutException -> UserError.NetworkError
is SocketException -> UserError.NetworkError
else -> UserError.NetworkError
}
is HttpException ->
throwable.response()!!
.takeUnless { it.isSuccessful }!!
.errorBody()!!
.use(ResponseBody::string)
.let { mapResponseError(it) }
else -> UserError.Unexpected
}
}.getOrElse {
it.nonFatalOrThrow()
UserError.Unexpected
}
}

@Throws(Throwable::class)
private fun mapResponseError(json: String): UserError {
val errorResponse = errorResponseJsonAdapter.fromJson(json)!!

return when (errorResponse.error) {
"internal-error" -> UserError.ServerError
"invalid-id" -> UserError.InvalidId(id = errorResponse.data as String)
"user-not-found" -> UserError.UserNotFound(id = errorResponse.data as String)
"validation-failed" -> UserError.ValidationFailed
else -> UserError.Unexpected
}
}
}
Loading