Skip to content

Commit 4128846

Browse files
authored
Fix: crash on Android 9–11 when expedited sync workers run (#78) (#79)
On API < 31, WorkManager backs an expedited work request with a foreground service and calls CoroutineWorker.getForegroundInfo() to get the notification to display. The default implementation throws IllegalStateException("Not implemented"), which crashed the app — most visibly at startup, where LibraryMirrorPushScheduler enqueues an expedited library-mirror push. On API 31+ expedited work uses JobScheduler, so getForegroundInfo() is never called and the crash never surfaced there. Override getForegroundInfo() in both expedited CoroutineWorkers (SyncWorker, LibraryMirrorPushWorker) to return a ForegroundInfo backed by a silent, low-importance notification on a dedicated channel. The notification only appears on Android ≤ 11 while an expedited worker runs and auto-clears on completion; behavior on Android 12+ is unchanged (no foreground service, no notification). No manifest changes needed: FOREGROUND_SERVICE is already merged transitively from work-runtime, and the foreground-service path only executes on API ≤ 30, so targetSdk 34's foreground-service-type and DATA_SYNC permission requirements don't apply. Adds regression tests in both modules under @config(sdk = [28]) asserting getForegroundInfo() returns a valid notification instead of throwing.
1 parent 87c3cc0 commit 4128846

5 files changed

Lines changed: 126 additions & 0 deletions

File tree

data/library/src/main/java/io/theficos/ereader/data/library/sync/LibraryMirrorPushWorker.kt

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
package io.theficos.ereader.data.library.sync
22

3+
import android.app.NotificationChannel
4+
import android.app.NotificationManager
35
import android.content.Context
46
import android.util.Log
7+
import androidx.core.app.NotificationCompat
58
import androidx.work.CoroutineWorker
9+
import androidx.work.ForegroundInfo
610
import androidx.work.WorkerParameters
711
import io.theficos.ereader.data.library.LibraryClient
812
import io.theficos.ereader.data.library.LibraryHttpException
@@ -61,6 +65,37 @@ class LibraryMirrorPushWorker(
6165
params: WorkerParameters,
6266
) : CoroutineWorker(appContext, params) {
6367

68+
/**
69+
* Required for expedited work on API < 31. The app-start trigger
70+
* ([LibraryMirrorPushScheduler.enqueueOneTime] with `expedited = true`)
71+
* runs as a foreground service on Android 9–11, where WorkManager calls
72+
* this to get the notification to display. The default `CoroutineWorker`
73+
* implementation throws `IllegalStateException("Not implemented")`,
74+
* which crashed the app at startup (issue #78). On API 31+ expedited
75+
* work uses JobScheduler and this is never called.
76+
*/
77+
override suspend fun getForegroundInfo(): ForegroundInfo =
78+
createForegroundInfo(applicationContext)
79+
80+
private fun createForegroundInfo(context: Context): ForegroundInfo {
81+
// minSdk is 26, so the channel API is always available.
82+
val manager = context.getSystemService(NotificationManager::class.java)
83+
val channel = NotificationChannel(
84+
CHANNEL_ID,
85+
"Library sync",
86+
NotificationManager.IMPORTANCE_LOW,
87+
).apply { setShowBadge(false) }
88+
manager.createNotificationChannel(channel)
89+
90+
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
91+
.setContentTitle("Syncing your library")
92+
.setSmallIcon(android.R.drawable.stat_notify_sync)
93+
.setOngoing(true)
94+
.setPriority(NotificationCompat.PRIORITY_LOW)
95+
.build()
96+
return ForegroundInfo(NOTIFICATION_ID, notification)
97+
}
98+
6499
override suspend fun doWork(): Result {
65100
val deps = LibraryMirrorPushDependencies.holder ?: run {
66101
// DI not initialised (Application.onCreate() not run yet, or
@@ -190,6 +225,8 @@ class LibraryMirrorPushWorker(
190225

191226
private companion object {
192227
const val TAG = "LibraryMirrorPush"
228+
const val CHANNEL_ID = "quire-library-sync"
229+
const val NOTIFICATION_ID = 4202
193230
// Mirrors `library_sync_max_items` (default 500) in the server's
194231
// Settings — we chunk client-side rather than catching a 422 on
195232
// oversize batches.

data/library/src/test/java/io/theficos/ereader/data/library/sync/LibraryMirrorPushWorkerTest.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,24 @@ class LibraryMirrorPushWorkerTest {
8383
private fun buildWorker(): LibraryMirrorPushWorker =
8484
TestListenableWorkerBuilder<LibraryMirrorPushWorker>(context).build()
8585

86+
// -------------------- expedited foreground (issue #78) --------------------
87+
88+
/**
89+
* Regression for #78: on API < 31 WorkManager runs the app-start
90+
* expedited push as a foreground service and calls `getForegroundInfo()`.
91+
* The default `CoroutineWorker` implementation throws
92+
* `IllegalStateException("Not implemented")` and crashed the app on
93+
* Android 9. The override must return a valid [ForegroundInfo].
94+
*/
95+
@Test
96+
@Config(sdk = [28])
97+
fun `getForegroundInfo returns a notification on Android 9`() = runTest {
98+
val info = buildWorker().getForegroundInfo()
99+
100+
assertThat(info.notificationId).isNotEqualTo(0)
101+
assertThat(info.notification).isNotNull()
102+
}
103+
86104
// -------------------- success / wire shape --------------------
87105

88106
@Test

data/sync/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,5 @@ dependencies {
3737
testImplementation(libs.okhttp.mockwebserver)
3838
testImplementation(libs.androidx.test.core)
3939
testImplementation(libs.room.testing)
40+
testImplementation(libs.work.testing)
4041
}

data/sync/src/main/java/io/theficos/ereader/data/sync/SyncWorker.kt

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
package io.theficos.ereader.data.sync
22

3+
import android.app.NotificationChannel
4+
import android.app.NotificationManager
35
import android.content.Context
46
import android.util.Log
7+
import androidx.core.app.NotificationCompat
58
import androidx.work.CoroutineWorker
69
import androidx.work.Constraints
710
import androidx.work.ExistingWorkPolicy
11+
import androidx.work.ForegroundInfo
812
import androidx.work.NetworkType
913
import androidx.work.OneTimeWorkRequestBuilder
1014
import androidx.work.OutOfQuotaPolicy
@@ -16,6 +20,36 @@ class SyncWorker(
1620
params: WorkerParameters,
1721
) : CoroutineWorker(appContext, params) {
1822

23+
/**
24+
* Required for expedited work on API < 31. There, WorkManager backs an
25+
* expedited request with a foreground service and calls this to obtain
26+
* the notification to display; the default `CoroutineWorker`
27+
* implementation throws `IllegalStateException("Not implemented")`,
28+
* which crashed the app on Android 9–11 (issue #78). On API 31+
29+
* expedited work uses JobScheduler and this is never called.
30+
*/
31+
override suspend fun getForegroundInfo(): ForegroundInfo =
32+
createForegroundInfo(applicationContext)
33+
34+
private fun createForegroundInfo(context: Context): ForegroundInfo {
35+
// minSdk is 26, so the channel API is always available.
36+
val manager = context.getSystemService(NotificationManager::class.java)
37+
val channel = NotificationChannel(
38+
CHANNEL_ID,
39+
"Sync",
40+
NotificationManager.IMPORTANCE_LOW,
41+
).apply { setShowBadge(false) }
42+
manager.createNotificationChannel(channel)
43+
44+
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
45+
.setContentTitle("Syncing reading progress")
46+
.setSmallIcon(android.R.drawable.stat_notify_sync)
47+
.setOngoing(true)
48+
.setPriority(NotificationCompat.PRIORITY_LOW)
49+
.build()
50+
return ForegroundInfo(NOTIFICATION_ID, notification)
51+
}
52+
1953
override suspend fun doWork(): Result {
2054
val deps = SyncDependencies.holder ?: run {
2155
Log.w(TAG, "doWork: dependencies not initialised")
@@ -34,6 +68,8 @@ class SyncWorker(
3468

3569
private companion object {
3670
const val TAG = "QuireSync"
71+
const val CHANNEL_ID = "quire-sync"
72+
const val NOTIFICATION_ID = 4201
3773
}
3874
}
3975

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package io.theficos.ereader.data.sync
2+
3+
import androidx.test.core.app.ApplicationProvider
4+
import androidx.work.testing.TestListenableWorkerBuilder
5+
import com.google.common.truth.Truth.assertThat
6+
import kotlinx.coroutines.test.runTest
7+
import org.junit.Test
8+
import org.junit.runner.RunWith
9+
import org.robolectric.RobolectricTestRunner
10+
import org.robolectric.annotation.Config
11+
12+
/**
13+
* Regression for #78: on API < 31 WorkManager runs an expedited
14+
* [SyncWorker] as a foreground service and calls `getForegroundInfo()`.
15+
* The default `CoroutineWorker` implementation throws
16+
* `IllegalStateException("Not implemented")`, which crashed the app on
17+
* Android 9. The override must return a valid `ForegroundInfo`.
18+
*/
19+
@RunWith(RobolectricTestRunner::class)
20+
@Config(sdk = [28])
21+
class SyncWorkerForegroundTest {
22+
23+
private val context get() = ApplicationProvider.getApplicationContext<android.content.Context>()
24+
25+
@Test
26+
fun `getForegroundInfo returns a notification on Android 9`() = runTest {
27+
val worker = TestListenableWorkerBuilder<SyncWorker>(context).build()
28+
29+
val info = worker.getForegroundInfo()
30+
31+
assertThat(info.notificationId).isNotEqualTo(0)
32+
assertThat(info.notification).isNotNull()
33+
}
34+
}

0 commit comments

Comments
 (0)