Skip to content

GH-80 UseCases: Fix CancellationException handling and add tests #87

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 6 commits into from
Aug 22, 2024
Merged
Show file tree
Hide file tree
Changes from 5 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
4 changes: 2 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ koin-ksp-compiler = { group = "io.insert-koin", name = "koin-ksp-compiler", vers

# KotlinX
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
kotlinx-immutableCollections = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinx-immutableCollections" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
kotlinx-dateTime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-dateTime" }
Expand All @@ -86,8 +87,7 @@ decompose-compose-ext = { group = "com.arkivanov.decompose", name = "extensions-
essenty = { group = "com.arkivanov.essenty", name = "lifecycle", version.ref = "essenty" }

# Testing
kotlin-testCommon = { group = "org.jetbrains.kotlin", name = "kotlin-test-common", version.ref = "kotlin" }
kotlin-testAnnotationsCommon = { group = "org.jetbrains.kotlin", name = "kotlin-test-annotations-common", version.ref = "kotlin" }
kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" }
Expand Down
3 changes: 1 addition & 2 deletions shared/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,7 @@ kotlin {

commonTest {
dependencies {
implementation(libs.kotlin.testCommon)
implementation(libs.kotlin.testAnnotationsCommon)
implementation(libs.kotlin.test)
}
}

Expand Down
3 changes: 1 addition & 2 deletions shared/feature/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,7 @@ kotlin {

commonTest {
dependencies {
implementation(libs.kotlin.testCommon)
implementation(libs.kotlin.testAnnotationsCommon)
implementation(libs.kotlin.test)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package app.futured.kmptemplate.feature.domain

import app.futured.kmptemplate.util.domain.FlowUseCase
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.isActive
import org.koin.core.annotation.Factory
import kotlin.time.Duration

@Factory
internal class CounterUseCase : FlowUseCase<CounterUseCaseArgs, Long>() {

override fun build(args: CounterUseCaseArgs): Flow<Long> = flow {
var counter = 0L
while (currentCoroutineContext().isActive) {
emit(counter++)
delay(args.interval)
}
}
}

internal data class CounterUseCaseArgs(val interval: Duration)
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package app.futured.kmptemplate.feature.domain

import app.futured.kmptemplate.util.domain.UseCase
import kotlinx.coroutines.delay
import org.koin.core.annotation.Factory
import kotlin.time.Duration.Companion.seconds

@Factory
internal class SyncDataUseCase : UseCase<Unit, Nothing>() {

override suspend fun build(args: Unit): Nothing {
delay(10.seconds)
error("Jokes on you, there's no data to be fetched in here.")
}
}
Original file line number Diff line number Diff line change
@@ -1,44 +1,62 @@
package app.futured.kmptemplate.feature.ui.first

import app.futured.kmptemplate.feature.domain.CounterUseCase
import app.futured.kmptemplate.feature.domain.CounterUseCaseArgs
import app.futured.kmptemplate.feature.domain.SyncDataUseCase
import app.futured.kmptemplate.feature.navigation.signedin.tab.b.TabBNavigator
import app.futured.kmptemplate.resources.MR
import app.futured.kmptemplate.util.arch.SharedViewModel
import app.futured.kmptemplate.util.ext.update
import co.touchlab.kermit.Logger
import dev.icerock.moko.resources.desc.ResourceFormatted
import dev.icerock.moko.resources.desc.StringDesc
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.isActive
import org.koin.core.annotation.Factory
import kotlin.time.Duration.Companion.milliseconds

@Factory
internal class FirstViewModel(
private val tabBNavigator: TabBNavigator,
private val syncDataUseCase: SyncDataUseCase,
private val counterUseCase: CounterUseCase,
) : SharedViewModel<FirstViewState, FirstUiEvent>(),
FirstScreen.Actions {

private val logger: Logger = Logger.withTag("FirstViewModel")

override val viewState: MutableStateFlow<FirstViewState> = MutableStateFlow(FirstViewState())

init {
launchWithHandler {
var counter = 0
while (isActive) {
update(viewState) {
copy(
text = StringDesc.ResourceFormatted(stringRes = MR.strings.first_screen_text, 1, counter++),
)
}

if (counter == 10) {
Logger.withTag("FirstViewmodel").d { "Counter reached 10" }
sendUiEvent(FirstUiEvent.ShowToast("Counter reached 10 🎉"))
}

delay(200.milliseconds)
syncData()
observeCounter()
}

private fun syncData() = syncDataUseCase.execute {
onError { error ->
logger.e(error) { error.message.toString() }
}
}

private fun observeCounter() = counterUseCase.execute(CounterUseCaseArgs(interval = 200.milliseconds)) {
onNext { count ->
updateCount(count)

if (count == 10L) {
logger.d { "Conter reached 10" }
sendUiEvent(FirstUiEvent.ShowToast("Counter reached 10 🎉"))
}
}
onError { error ->
logger.e(error) { "Counter error" }
}
}

private fun updateCount(count: Long) {
update(viewState) {
copy(
text = StringDesc.ResourceFormatted(stringRes = MR.strings.first_screen_text, 1, count),
)
}
}

override fun onBack() = tabBNavigator.pop()
Expand Down
3 changes: 1 addition & 2 deletions shared/network/graphql/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,7 @@ kotlin {

commonTest {
dependencies {
implementation(libs.kotlin.testCommon)
implementation(libs.kotlin.testAnnotationsCommon)
implementation(libs.kotlin.test)
}
}
}
Expand Down
3 changes: 1 addition & 2 deletions shared/network/rest/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,7 @@ kotlin {

commonTest {
dependencies {
implementation(libs.kotlin.testCommon)
implementation(libs.kotlin.testAnnotationsCommon)
implementation(libs.kotlin.test)
}
}
}
Expand Down
3 changes: 1 addition & 2 deletions shared/persistence/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,7 @@ kotlin {

commonTest {
dependencies {
implementation(libs.kotlin.testCommon)
implementation(libs.kotlin.testAnnotationsCommon)
implementation(libs.kotlin.test)
}
}
}
Expand Down
3 changes: 1 addition & 2 deletions shared/platform/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,7 @@ kotlin {

commonTest {
dependencies {
implementation(libs.kotlin.testCommon)
implementation(libs.kotlin.testAnnotationsCommon)
implementation(libs.kotlin.test)
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions shared/util/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ kotlin {

commonTest {
dependencies {
implementation(libs.kotlin.testCommon)
implementation(libs.kotlin.testAnnotationsCommon)
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,12 @@ interface FlowUseCaseExecutionScope : CoroutineScopeOwner {
error is CancellationException -> {
// ignore this exception
}

error != null -> {
// UseCaseErrorHandler.globalOnErrorLogger(error) todo
UseCaseErrorHandler.globalOnErrorLogger(error)
flowUseCaseConfig.onError(error)
}

else -> flowUseCaseConfig.onComplete()
}
}
Expand Down Expand Up @@ -101,10 +103,12 @@ interface FlowUseCaseExecutionScope : CoroutineScopeOwner {
error is CancellationException -> {
// ignore this exception
}

error != null -> {
// UseCaseErrorHandler.globalOnErrorLogger(error) todo
UseCaseErrorHandler.globalOnErrorLogger(error)
flowUseCaseConfig.onError(error)
}

else -> flowUseCaseConfig.onComplete()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@ package app.futured.kmptemplate.util.domain.scope
import app.futured.kmptemplate.util.domain.UseCase
import app.futured.kmptemplate.util.domain.error.UseCaseErrorHandler
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

interface SingleUseCaseExecutionScope : CoroutineScopeOwner {

Expand Down Expand Up @@ -47,13 +46,20 @@ interface SingleUseCaseExecutionScope : CoroutineScopeOwner {
}

useCaseConfig.onStart()
deferred = viewModelScope.async(start = CoroutineStart.LAZY) {
buildOnBg(args, getWorkerDispatcher())
}
deferred = viewModelScope
.async(context = getWorkerDispatcher(), start = CoroutineStart.LAZY) {
build(args)
}
.also {
viewModelScope.launch {
kotlin.runCatching { it.await() }
.fold(useCaseConfig.onSuccess, useCaseConfig.onError)
viewModelScope.launch(Dispatchers.Main) {
try {
useCaseConfig.onSuccess(it.await())
} catch (cancellation: CancellationException) {
// do nothing - this is normal way of suspend function interruption
} catch (error: Throwable) {
UseCaseErrorHandler.globalOnErrorLogger(error)
useCaseConfig.onError.invoke(error)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
useCaseConfig.onError.invoke(error)
useCaseConfig.onError(error)

}
}
}
}
Expand All @@ -79,12 +85,12 @@ interface SingleUseCaseExecutionScope : CoroutineScopeOwner {
if (cancelPrevious) {
deferred?.cancel()
}

return try {
val newDeferred = viewModelScope.async(start = CoroutineStart.LAZY) {
buildOnBg(args, getWorkerDispatcher())
}.also {
deferred = it
}
val newDeferred = viewModelScope.async(getWorkerDispatcher(), CoroutineStart.LAZY) {
build(args)
}.also { deferred = it }

Result.success(newDeferred.await())
} catch (exception: CancellationException) {
throw exception
Expand All @@ -93,11 +99,6 @@ interface SingleUseCaseExecutionScope : CoroutineScopeOwner {
}
}

private suspend fun <ARGS, T : Any?> UseCase<ARGS, T>.buildOnBg(
args: ARGS,
workerDispatcher: CoroutineDispatcher,
) = withContext(workerDispatcher) { build(args) }

/**
* Holds references to lambdas and some basic configuration
* used to process results of Coroutine use case.
Expand Down
Loading
Loading