Skip to content

Commit 4aea888

Browse files
authored
refactor(concurrent): Introduce SequentialJob to manage service setup (#3983)
Signed-off-by: James Rich <[email protected]>
1 parent 6d827e9 commit 4aea888

File tree

5 files changed

+141
-12
lines changed

5 files changed

+141
-12
lines changed

app/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ dependencies {
254254
androidTestImplementation(libs.hilt.android.testing)
255255

256256
testImplementation(libs.junit)
257+
testImplementation(libs.kotlinx.coroutines.test)
257258

258259
dokkaPlugin(libs.dokka.android.documentation.plugin)
259260
}

app/src/main/java/com/geeksville/mesh/MeshServiceClient.kt

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,11 @@ import androidx.lifecycle.LifecycleOwner
2525
import androidx.lifecycle.lifecycleScope
2626
import com.geeksville.mesh.android.BindFailedException
2727
import com.geeksville.mesh.android.ServiceClient
28-
import com.geeksville.mesh.concurrent.handledLaunch
28+
import com.geeksville.mesh.concurrent.SequentialJob
2929
import com.geeksville.mesh.service.MeshService
3030
import com.geeksville.mesh.service.startService
3131
import dagger.hilt.android.qualifiers.ActivityContext
3232
import dagger.hilt.android.scopes.ActivityScoped
33-
import kotlinx.coroutines.Job
3433
import org.meshtastic.core.service.IMeshService
3534
import org.meshtastic.core.service.ServiceRepository
3635
import timber.log.Timber
@@ -43,14 +42,12 @@ class MeshServiceClient
4342
constructor(
4443
@ActivityContext private val context: Context,
4544
private val serviceRepository: ServiceRepository,
45+
private val serviceSetupJob: SequentialJob,
4646
) : ServiceClient<IMeshService>(IMeshService.Stub::asInterface),
4747
DefaultLifecycleObserver {
4848

4949
private val lifecycleOwner: LifecycleOwner = context as LifecycleOwner
5050

51-
// TODO Inject this for ease of testing
52-
private var serviceSetupJob: Job? = null
53-
5451
init {
5552
Timber.d("Adding self as LifecycleObserver for $lifecycleOwner")
5653
lifecycleOwner.lifecycle.addObserver(this)
@@ -59,16 +56,14 @@ constructor(
5956
// region ServiceClient overrides
6057

6158
override fun onConnected(service: IMeshService) {
62-
serviceSetupJob?.cancel()
63-
serviceSetupJob =
64-
lifecycleOwner.lifecycleScope.handledLaunch {
65-
serviceRepository.setMeshService(service)
66-
Timber.d("connected to mesh service, connectionState=${serviceRepository.connectionState.value}")
67-
}
59+
serviceSetupJob.launch(lifecycleOwner.lifecycleScope) {
60+
serviceRepository.setMeshService(service)
61+
Timber.d("connected to mesh service, connectionState=${serviceRepository.connectionState.value}")
62+
}
6863
}
6964

7065
override fun onDisconnected() {
71-
serviceSetupJob?.cancel()
66+
serviceSetupJob.cancel()
7267
serviceRepository.setMeshService(null)
7368
}
7469

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Copyright (c) 2025 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
18+
package com.geeksville.mesh.concurrent
19+
20+
import kotlinx.coroutines.CoroutineScope
21+
import kotlinx.coroutines.Job
22+
import java.util.concurrent.atomic.AtomicReference
23+
import javax.inject.Inject
24+
25+
/**
26+
* A helper class that manages a single [Job].
27+
*
28+
* When a new job is launched, the previous one is cancelled. This is useful for ensuring that only one operation of a
29+
* certain type is running at a time.
30+
*/
31+
class SequentialJob @Inject constructor() {
32+
private val job = AtomicReference<Job?>(null)
33+
34+
/**
35+
* Cancels the previous job (if any) and launches a new one in the given [scope].
36+
*
37+
* The new job uses [handledLaunch] to ensure exceptions are reported.
38+
*/
39+
fun launch(scope: CoroutineScope, block: suspend CoroutineScope.() -> Unit) {
40+
cancel()
41+
val newJob = scope.handledLaunch(block = block)
42+
job.set(newJob)
43+
44+
newJob.invokeOnCompletion { job.compareAndSet(newJob, null) }
45+
}
46+
47+
/** Cancels the current job. */
48+
fun cancel() {
49+
job.getAndSet(null)?.cancel()
50+
}
51+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
* Copyright (c) 2025 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
18+
package com.geeksville.mesh.concurrent
19+
20+
import kotlinx.coroutines.ExperimentalCoroutinesApi
21+
import kotlinx.coroutines.delay
22+
import kotlinx.coroutines.test.advanceTimeBy
23+
import kotlinx.coroutines.test.runTest
24+
import org.junit.Assert.assertTrue
25+
import org.junit.Test
26+
27+
@ExperimentalCoroutinesApi
28+
class SequentialJobTest {
29+
30+
private val sequentialJob = SequentialJob()
31+
32+
@Test
33+
fun `launch cancels previous job`() = runTest {
34+
var job1Active = false
35+
var job1Cancelled = false
36+
37+
// Launch first job
38+
sequentialJob.launch(this) {
39+
try {
40+
job1Active = true
41+
delay(1000)
42+
} finally {
43+
job1Cancelled = true
44+
}
45+
}
46+
47+
advanceTimeBy(100)
48+
assertTrue("Job 1 should be active", job1Active)
49+
50+
// Launch second job
51+
sequentialJob.launch(this) {
52+
// Do nothing
53+
}
54+
55+
advanceTimeBy(100)
56+
assertTrue("Job 1 should be cancelled", job1Cancelled)
57+
}
58+
59+
@Test
60+
fun `cancel stops the job`() = runTest {
61+
var jobActive = false
62+
var jobCancelled = false
63+
64+
sequentialJob.launch(this) {
65+
try {
66+
jobActive = true
67+
delay(1000)
68+
} finally {
69+
jobCancelled = true
70+
}
71+
}
72+
73+
advanceTimeBy(100)
74+
assertTrue("Job should be active", jobActive)
75+
76+
sequentialJob.cancel()
77+
78+
advanceTimeBy(100)
79+
assertTrue("Job should be cancelled", jobCancelled)
80+
}
81+
}

gradle/libs.versions.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ truth = { module = "com.google.truth:truth", version = "1.4.5" }
111111
kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
112112
kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version = "0.4.0" }
113113
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines-android" }
114+
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-android" }
114115
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
115116
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
116117

0 commit comments

Comments
 (0)