Skip to content

Commit 9d6c610

Browse files
Merge pull request #87 from futuredapp/feature/matsem/GH-80-uc-improvements
GH-80 UseCases: Fix CancellationException handling and add tests
2 parents bdcce8f + da2895b commit 9d6c610

File tree

19 files changed

+495
-54
lines changed

19 files changed

+495
-54
lines changed

gradle/libs.versions.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ koin-ksp-compiler = { group = "io.insert-koin", name = "koin-ksp-compiler", vers
7171

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

8889
# Testing
89-
kotlin-testCommon = { group = "org.jetbrains.kotlin", name = "kotlin-test-common", version.ref = "kotlin" }
90-
kotlin-testAnnotationsCommon = { group = "org.jetbrains.kotlin", name = "kotlin-test-annotations-common", version.ref = "kotlin" }
90+
kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" }
9191
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit" }
9292
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
9393
androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" }

shared/app/build.gradle.kts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,7 @@ kotlin {
6969

7070
commonTest {
7171
dependencies {
72-
implementation(libs.kotlin.testCommon)
73-
implementation(libs.kotlin.testAnnotationsCommon)
72+
implementation(libs.kotlin.test)
7473
}
7574
}
7675

shared/feature/build.gradle.kts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,7 @@ kotlin {
6060

6161
commonTest {
6262
dependencies {
63-
implementation(libs.kotlin.testCommon)
64-
implementation(libs.kotlin.testAnnotationsCommon)
63+
implementation(libs.kotlin.test)
6564
}
6665
}
6766
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package app.futured.kmptemplate.feature.domain
2+
3+
import app.futured.kmptemplate.util.domain.FlowUseCase
4+
import kotlinx.coroutines.currentCoroutineContext
5+
import kotlinx.coroutines.delay
6+
import kotlinx.coroutines.flow.Flow
7+
import kotlinx.coroutines.flow.flow
8+
import kotlinx.coroutines.isActive
9+
import org.koin.core.annotation.Factory
10+
import kotlin.time.Duration
11+
12+
@Factory
13+
internal class CounterUseCase : FlowUseCase<CounterUseCaseArgs, Long>() {
14+
15+
override fun build(args: CounterUseCaseArgs): Flow<Long> = flow {
16+
var counter = 0L
17+
while (currentCoroutineContext().isActive) {
18+
emit(counter++)
19+
delay(args.interval)
20+
}
21+
}
22+
}
23+
24+
internal data class CounterUseCaseArgs(val interval: Duration)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package app.futured.kmptemplate.feature.domain
2+
3+
import app.futured.kmptemplate.util.domain.UseCase
4+
import kotlinx.coroutines.delay
5+
import org.koin.core.annotation.Factory
6+
import kotlin.time.Duration.Companion.seconds
7+
8+
@Factory
9+
internal class SyncDataUseCase : UseCase<Unit, Nothing>() {
10+
11+
override suspend fun build(args: Unit): Nothing {
12+
delay(10.seconds)
13+
error("Jokes on you, there's no data to be fetched in here.")
14+
}
15+
}

shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/ui/first/FirstViewModel.kt

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,62 @@
11
package app.futured.kmptemplate.feature.ui.first
22

3+
import app.futured.kmptemplate.feature.domain.CounterUseCase
4+
import app.futured.kmptemplate.feature.domain.CounterUseCaseArgs
5+
import app.futured.kmptemplate.feature.domain.SyncDataUseCase
36
import app.futured.kmptemplate.feature.navigation.signedin.tab.b.TabBNavigator
47
import app.futured.kmptemplate.resources.MR
58
import app.futured.kmptemplate.util.arch.SharedViewModel
69
import app.futured.kmptemplate.util.ext.update
710
import co.touchlab.kermit.Logger
811
import dev.icerock.moko.resources.desc.ResourceFormatted
912
import dev.icerock.moko.resources.desc.StringDesc
10-
import kotlin.time.Duration.Companion.milliseconds
11-
import kotlinx.coroutines.delay
1213
import kotlinx.coroutines.flow.MutableStateFlow
13-
import kotlinx.coroutines.isActive
1414
import org.koin.core.annotation.Factory
15+
import kotlin.time.Duration.Companion.milliseconds
1516

1617
@Factory
1718
internal class FirstViewModel(
1819
private val tabBNavigator: TabBNavigator,
20+
private val syncDataUseCase: SyncDataUseCase,
21+
private val counterUseCase: CounterUseCase,
1922
) : SharedViewModel<FirstViewState, FirstUiEvent>(),
2023
FirstScreen.Actions {
2124

25+
private val logger: Logger = Logger.withTag("FirstViewModel")
26+
2227
override val viewState: MutableStateFlow<FirstViewState> = MutableStateFlow(FirstViewState())
2328

2429
init {
25-
launchWithHandler {
26-
var counter = 0
27-
while (isActive) {
28-
update(viewState) {
29-
copy(
30-
text = StringDesc.ResourceFormatted(stringRes = MR.strings.first_screen_text, 1, counter++),
31-
)
32-
}
33-
34-
if (counter == 10) {
35-
Logger.withTag("FirstViewmodel").d { "Counter reached 10" }
36-
sendUiEvent(FirstUiEvent.ShowToast("Counter reached 10 🎉"))
37-
}
38-
39-
delay(200.milliseconds)
30+
syncData()
31+
observeCounter()
32+
}
33+
34+
private fun syncData() = syncDataUseCase.execute {
35+
onError { error ->
36+
logger.e(error) { error.message.toString() }
37+
}
38+
}
39+
40+
private fun observeCounter() = counterUseCase.execute(CounterUseCaseArgs(interval = 200.milliseconds)) {
41+
onNext { count ->
42+
updateCount(count)
43+
44+
if (count == 10L) {
45+
logger.d { "Conter reached 10" }
46+
sendUiEvent(FirstUiEvent.ShowToast("Counter reached 10 🎉"))
4047
}
4148
}
49+
onError { error ->
50+
logger.e(error) { "Counter error" }
51+
}
52+
}
53+
54+
private fun updateCount(count: Long) {
55+
update(viewState) {
56+
copy(
57+
text = StringDesc.ResourceFormatted(stringRes = MR.strings.first_screen_text, 1, count),
58+
)
59+
}
4260
}
4361

4462
override fun onBack() = tabBNavigator.pop()

shared/network/graphql/build.gradle.kts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,7 @@ kotlin {
4242

4343
commonTest {
4444
dependencies {
45-
implementation(libs.kotlin.testCommon)
46-
implementation(libs.kotlin.testAnnotationsCommon)
45+
implementation(libs.kotlin.test)
4746
}
4847
}
4948
}

shared/network/rest/build.gradle.kts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,7 @@ kotlin {
5757

5858
commonTest {
5959
dependencies {
60-
implementation(libs.kotlin.testCommon)
61-
implementation(libs.kotlin.testAnnotationsCommon)
60+
implementation(libs.kotlin.test)
6261
}
6362
}
6463
}

shared/persistence/build.gradle.kts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,7 @@ kotlin {
3535

3636
commonTest {
3737
dependencies {
38-
implementation(libs.kotlin.testCommon)
39-
implementation(libs.kotlin.testAnnotationsCommon)
38+
implementation(libs.kotlin.test)
4039
}
4140
}
4241
}

shared/platform/build.gradle.kts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,7 @@ kotlin {
2929

3030
commonTest {
3131
dependencies {
32-
implementation(libs.kotlin.testCommon)
33-
implementation(libs.kotlin.testAnnotationsCommon)
32+
implementation(libs.kotlin.test)
3433
}
3534
}
3635
}

shared/util/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ kotlin {
3232

3333
commonTest {
3434
dependencies {
35-
implementation(libs.kotlin.testCommon)
36-
implementation(libs.kotlin.testAnnotationsCommon)
35+
implementation(libs.kotlin.test)
36+
implementation(libs.kotlinx.coroutines.test)
3737
}
3838
}
3939
}

shared/util/src/commonMain/kotlin/app/futured/kmptemplate/util/domain/scope/FlowUseCaseExecutionScope.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,12 @@ interface FlowUseCaseExecutionScope : CoroutineScopeOwner {
5555
error is CancellationException -> {
5656
// ignore this exception
5757
}
58+
5859
error != null -> {
59-
// UseCaseErrorHandler.globalOnErrorLogger(error) todo
60+
UseCaseErrorHandler.globalOnErrorLogger(error)
6061
flowUseCaseConfig.onError(error)
6162
}
63+
6264
else -> flowUseCaseConfig.onComplete()
6365
}
6466
}
@@ -101,10 +103,12 @@ interface FlowUseCaseExecutionScope : CoroutineScopeOwner {
101103
error is CancellationException -> {
102104
// ignore this exception
103105
}
106+
104107
error != null -> {
105-
// UseCaseErrorHandler.globalOnErrorLogger(error) todo
108+
UseCaseErrorHandler.globalOnErrorLogger(error)
106109
flowUseCaseConfig.onError(error)
107110
}
111+
108112
else -> flowUseCaseConfig.onComplete()
109113
}
110114
}

shared/util/src/commonMain/kotlin/app/futured/kmptemplate/util/domain/scope/SingleUseCaseExecutionScope.kt

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,10 @@ package app.futured.kmptemplate.util.domain.scope
33
import app.futured.kmptemplate.util.domain.UseCase
44
import app.futured.kmptemplate.util.domain.error.UseCaseErrorHandler
55
import kotlinx.coroutines.CancellationException
6-
import kotlinx.coroutines.CoroutineDispatcher
76
import kotlinx.coroutines.CoroutineStart
7+
import kotlinx.coroutines.Dispatchers
88
import kotlinx.coroutines.async
99
import kotlinx.coroutines.launch
10-
import kotlinx.coroutines.withContext
1110

1211
interface SingleUseCaseExecutionScope : CoroutineScopeOwner {
1312

@@ -47,13 +46,20 @@ interface SingleUseCaseExecutionScope : CoroutineScopeOwner {
4746
}
4847

4948
useCaseConfig.onStart()
50-
deferred = viewModelScope.async(start = CoroutineStart.LAZY) {
51-
buildOnBg(args, getWorkerDispatcher())
52-
}
49+
deferred = viewModelScope
50+
.async(context = getWorkerDispatcher(), start = CoroutineStart.LAZY) {
51+
build(args)
52+
}
5353
.also {
54-
viewModelScope.launch {
55-
kotlin.runCatching { it.await() }
56-
.fold(useCaseConfig.onSuccess, useCaseConfig.onError)
54+
viewModelScope.launch(Dispatchers.Main) {
55+
try {
56+
useCaseConfig.onSuccess(it.await())
57+
} catch (cancellation: CancellationException) {
58+
// do nothing - this is normal way of suspend function interruption
59+
} catch (error: Throwable) {
60+
UseCaseErrorHandler.globalOnErrorLogger(error)
61+
useCaseConfig.onError(error)
62+
}
5763
}
5864
}
5965
}
@@ -79,12 +85,12 @@ interface SingleUseCaseExecutionScope : CoroutineScopeOwner {
7985
if (cancelPrevious) {
8086
deferred?.cancel()
8187
}
88+
8289
return try {
83-
val newDeferred = viewModelScope.async(start = CoroutineStart.LAZY) {
84-
buildOnBg(args, getWorkerDispatcher())
85-
}.also {
86-
deferred = it
87-
}
90+
val newDeferred = viewModelScope.async(getWorkerDispatcher(), CoroutineStart.LAZY) {
91+
build(args)
92+
}.also { deferred = it }
93+
8894
Result.success(newDeferred.await())
8995
} catch (exception: CancellationException) {
9096
throw exception
@@ -93,11 +99,6 @@ interface SingleUseCaseExecutionScope : CoroutineScopeOwner {
9399
}
94100
}
95101

96-
private suspend fun <ARGS, T : Any?> UseCase<ARGS, T>.buildOnBg(
97-
args: ARGS,
98-
workerDispatcher: CoroutineDispatcher,
99-
) = withContext(workerDispatcher) { build(args) }
100-
101102
/**
102103
* Holds references to lambdas and some basic configuration
103104
* used to process results of Coroutine use case.

0 commit comments

Comments
 (0)