Skip to content

Commit 424951b

Browse files
ericli3690criticalAY
authored andcommitted
fix: use goAsync in runGloballyWithTimeout
This is required to guarantee the OS does not kill the process after `onReceive` completes. See https://developer.android.com/reference/android/content/BroadcastReceiver#goAsync() A slight reorganization of NotificationService is then needed, as `goAsync` does not work properly in tests. Also, the primary logic in `onReceive` is starting to get bloated, so it's better to move it to a separate, more testable function.
1 parent 75247c1 commit 424951b

3 files changed

Lines changed: 107 additions & 73 deletions

File tree

AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package com.ichi2.anki
1818

1919
import android.app.Activity
2020
import android.app.Dialog
21+
import android.content.BroadcastReceiver
2122
import android.content.Context
2223
import android.content.DialogInterface
2324
import android.database.sqlite.SQLiteDatabaseCorruptException
@@ -80,6 +81,7 @@ import timber.log.Timber
8081
import kotlin.coroutines.CoroutineContext
8182
import kotlin.coroutines.EmptyCoroutineContext
8283
import kotlin.time.Duration
84+
import kotlin.time.Duration.Companion.seconds
8385

8486
/** Overridable reference to [Dispatchers.IO]. Useful if tests can't use it */
8587
// COULD_BE_BETTER: this shouldn't be necessary, but TestClass::runWith needs it
@@ -612,21 +614,44 @@ private fun Activity.showError(
612614
) = showError(throwable.toString(), throwable.toCrashReportData(context = this, reportException))
613615

614616
/**
615-
* Launches a coroutine which is guaranteed to terminate within the [timeout] duration, which means
616-
* it is safe to call on the global coroutine scope. We handle the global scope carefully here to ensure
617-
* that the coroutine eventually terminates and does not cause a memory leak.
617+
* Since BroadcastReceiver onReceive methods are expected to finish quickly, this helper function
618+
* is required to run a suspending function from an onReceive method. [BroadcastReceiver.goAsync]
619+
* extends the lifetime of the onReceive method and tells the OS not to kill the process prematurely.
620+
*
621+
* Do not call [BroadcastReceiver.goAsync] directly before calling this function.
622+
*
623+
* @param timeout Just in case the block hangs. Cannot exceed 8 seconds, because an ANR may occur if a
624+
* BroadcastReceiver's onReceive method runs for longer than 10 seconds.
625+
* See [the docs](https://developer.android.com/reference/android/content/BroadcastReceiver#goAsync()).
626+
* @param block The suspending function to run.
618627
*/
619-
fun runGloballyWithTimeout(
628+
fun BroadcastReceiver.runGloballyWithTimeout(
620629
timeout: Duration,
621630
block: suspend () -> Unit,
622631
) {
632+
val pendingResult = goAsync()
633+
if (pendingResult == null) {
634+
// pendingResult should never be null, so this should never happen.
635+
// According to the implementation of goAsync, if it is, that indicates goAsync was called twice for the same onReceive.
636+
Timber.w("goAsync returned null, cannot run block")
637+
CrashReportService.sendExceptionReport(
638+
message =
639+
"goAsync returned null for BroadcastReceiver: " +
640+
"This should never happen and indicates goAsync was called twice for the same onReceive",
641+
origin = "CoroutineHelpers:BroadcastReceiver.runGloballyWithTimeout",
642+
)
643+
return
644+
}
645+
623646
AnkiDroidApp.applicationScope.launch {
624647
try {
625-
withTimeout(timeout) {
648+
withTimeout(minOf(timeout, 8.seconds)) {
626649
block()
627650
}
628651
} catch (e: TimeoutCancellationException) {
629652
Timber.w(e, "runGloballyWithTimeout timed out after $timeout")
653+
} finally {
654+
pendingResult.finish()
630655
}
631656
}
632657
}

AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt

Lines changed: 52 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import com.ichi2.anki.utils.remainingTime
4747
import com.ichi2.widget.WidgetStatus
4848
import net.ankiweb.rsdroid.BackendException
4949
import timber.log.Timber
50+
import kotlin.coroutines.cancellation.CancellationException
5051
import kotlin.time.Duration
5152
import kotlin.time.Duration.Companion.days
5253
import kotlin.time.Duration.Companion.hours
@@ -79,8 +80,49 @@ class NotificationService : BroadcastReceiver() {
7980

8081
/**
8182
* Timeout for the process of sending a review reminder notification.
83+
* Should be below 10 seconds as BroadcastReceivers may ANR when onReceive takes longer than 10 seconds.
84+
* See [the docs](https://developer.android.com/reference/android/content/BroadcastReceiver#goAsync()).
8285
*/
83-
private val SEND_REVIEW_REMINDER_TIMEOUT = 10.seconds
86+
private val SEND_REVIEW_REMINDER_TIMEOUT = 8.seconds
87+
88+
/**
89+
* Triggered by [onReceive]. Does some bookkeeping if the triggered notification is of the recurring type
90+
* or does nothing if it is a snoozed one, and then begins the process of sending the notification.
91+
*/
92+
@VisibleForTesting
93+
suspend fun handleReviewReminderNotification(
94+
context: Context,
95+
reviewReminder: ReviewReminder,
96+
isRecurringNotification: Boolean,
97+
) {
98+
if (isRecurringNotification) {
99+
// Record this latest routine notification-firing attempt's timestamp
100+
reviewReminder.updateLatestNotifTime()
101+
when (val scope = reviewReminder.scope) {
102+
is ReviewReminderScope.DeckSpecific ->
103+
ReviewRemindersDatabase.editRemindersForDeck(
104+
scope.did,
105+
upsertReminder(reviewReminder),
106+
)
107+
is ReviewReminderScope.Global -> ReviewRemindersDatabase.editAllAppWideReminders(upsertReminder(reviewReminder))
108+
}
109+
110+
// Schedule the next routine notification-firing
111+
Timber.d("Scheduling next review reminder notification")
112+
AlarmManagerService.scheduleReviewReminderNotification(context, reviewReminder)
113+
}
114+
115+
// Send the notification
116+
try {
117+
sendReviewReminderNotification(context, reviewReminder)
118+
} catch (e: BackendException) {
119+
// This may occur if the collection is blocked, in which case we should fail gracefully
120+
Timber.w(e, "Aborted review reminder notification due to backend exception")
121+
} catch (e: CancellationException) {
122+
Timber.w(e, "Aborted review reminder notification due to cancellation exception")
123+
throw e // Rethrow to propagate to parent coroutine
124+
}
125+
}
84126

85127
/**
86128
* Sends a notification for a review reminder.
@@ -421,35 +463,16 @@ class NotificationService : BroadcastReceiver() {
421463
) ?: return
422464
Timber.d("onReceive: ${reviewReminder.id}")
423465

424-
// If this is a recurring notification...
425-
if (action == NotificationServiceAction.ScheduleRecurringNotifications.actionString) {
426-
// Record this latest routine notification-firing attempt's timestamp
427-
reviewReminder.updateLatestNotifTime()
428-
when (val scope = reviewReminder.scope) {
429-
is ReviewReminderScope.DeckSpecific ->
430-
ReviewRemindersDatabase.editRemindersForDeck(
431-
scope.did,
432-
upsertReminder(reviewReminder),
433-
)
434-
is ReviewReminderScope.Global -> ReviewRemindersDatabase.editAllAppWideReminders(upsertReminder(reviewReminder))
435-
}
436-
437-
// Schedule the next routine notification-firing
438-
Timber.d("Scheduling next review reminder notification")
439-
AlarmManagerService.scheduleReviewReminderNotification(context, reviewReminder)
440-
}
441-
466+
// We must run some suspending functions. Hence we mark this onReceive function as long-running
467+
// and use the global scope for simplicity's sake. Theoretically, we could also use an expedited Worker,
468+
// but AnkiDroid is only allotted a fixed number of expedited Worker calls per day
469+
// and those expedited calls are also used by the sync service, so it's best to conserve them.
442470
runGloballyWithTimeout(SEND_REVIEW_REMINDER_TIMEOUT) {
443-
// We run this on the global scope for simplicity's sake, as BroadcastReceivers do not have CoroutineScopes.
444-
// Theoretically we could also use an expedited Worker, but AnkiDroid is only allotted a fixed number
445-
// of expedited Worker calls per day, and these expedited calls are also used by the sync service,
446-
// so it's best to conserve them.
447-
try {
448-
sendReviewReminderNotification(context, reviewReminder)
449-
} catch (e: BackendException) {
450-
// This may occur if the collection is blocked, in which case we should fail gracefully
451-
Timber.w(e, "Aborted review reminder notification due to backend exception")
452-
}
471+
handleReviewReminderNotification(
472+
context,
473+
reviewReminder,
474+
isRecurringNotification = (action == NotificationServiceAction.ScheduleRecurringNotifications.actionString),
475+
)
453476
}
454477
} else {
455478
triggerNotificationFor(context)

AnkiDroid/src/test/java/com/ichi2/anki/services/NotificationServiceTest.kt

Lines changed: 25 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ class NotificationServiceTest : RobolectricTest() {
8585
}
8686

8787
@Test
88-
fun `onReceive with less cards than card threshold should not fire notification but schedule next`() =
88+
fun `triggering with less cards than card threshold should not fire notification but schedule next`() =
8989
runTest {
9090
val did1 = addDeck("Deck", setAsSelected = true).withNotes(count = 2)
9191
val reviewReminderDeckSpecific = createTestReminder(deckId = did1, thresholdInt = 3)
@@ -105,7 +105,7 @@ class NotificationServiceTest : RobolectricTest() {
105105
}
106106

107107
@Test
108-
fun `onReceive with happy path for single deck should fire notification and schedule next`() =
108+
fun `triggering with happy path for single deck should fire notification and schedule next`() =
109109
runTest {
110110
val did1 = addDeck("Deck", setAsSelected = true).withNotes(count = 2)
111111
val reviewReminder = createAndSaveDummyDeckSpecificReminder(did1)
@@ -119,7 +119,7 @@ class NotificationServiceTest : RobolectricTest() {
119119
}
120120

121121
@Test
122-
fun `onReceive with happy path for global reminder should fire notification and schedule next`() =
122+
fun `triggering with happy path for global reminder should fire notification and schedule next`() =
123123
runTest {
124124
addDeck("Deck1").withNotes(count = 2)
125125
addDeck("Deck2").withNotes(count = 2)
@@ -134,9 +134,10 @@ class NotificationServiceTest : RobolectricTest() {
134134
}
135135

136136
@Test
137-
fun `onReceive with non-existent deck should not fire notification but schedule next`() =
137+
fun `triggering with non-existent deck should not fire notification but schedule next`() =
138138
runTest {
139-
val reviewReminder = createTestReminder(deckId = 9999)
139+
val did1 = addDeck("Deck")
140+
val reviewReminder = createTestReminder(deckId = did1 + 1)
140141
val creationTime = reviewReminder.latestNotifTime
141142

142143
triggerDummyReminderNotification(reviewReminder)
@@ -147,7 +148,7 @@ class NotificationServiceTest : RobolectricTest() {
147148
}
148149

149150
@Test
150-
fun `onReceive with reviews today and onlyNotifyIfNoReviews is true should not fire notification`() =
151+
fun `triggering with reviews today and onlyNotifyIfNoReviews is true should not fire notification`() =
151152
runTest {
152153
val did1 = addDeck("Deck", setAsSelected = true).withNote()
153154
col.sched.answerCard(col.sched.card!!, CardAnswer.Rating.GOOD)
@@ -162,7 +163,7 @@ class NotificationServiceTest : RobolectricTest() {
162163
}
163164

164165
@Test
165-
fun `onReceive with no reviews ever and onlyNotifyIfNoReviews is true should fire notification`() =
166+
fun `triggering with no reviews ever and onlyNotifyIfNoReviews is true should fire notification`() =
166167
runTest {
167168
val did1 = addDeck("Deck", setAsSelected = true).withNote()
168169
val reviewReminderDeckSpecific = createTestReminder(deckId = did1, thresholdInt = 1, onlyNotifyIfNoReviews = true)
@@ -177,7 +178,7 @@ class NotificationServiceTest : RobolectricTest() {
177178
}
178179

179180
@Test
180-
fun `onReceive with review yesterday but none today and onlyNotifyIfNoReviews is true should fire notification`() =
181+
fun `triggering with review yesterday but none today and onlyNotifyIfNoReviews is true should fire notification`() =
181182
runTest {
182183
TimeManager.resetWith(yesterday) // Wind back time and perform the review
183184
val did1 = addDeck("Deck", setAsSelected = true).withNote()
@@ -201,7 +202,7 @@ class NotificationServiceTest : RobolectricTest() {
201202
}
202203

203204
@Test
204-
fun `onReceive with onlyNotifyIfNoReviews is false should always fire notification`() =
205+
fun `triggering with onlyNotifyIfNoReviews is false should always fire notification`() =
205206
runTest {
206207
val did1 = addDeck("Deck", setAsSelected = true).withNote()
207208
val reviewReminderDeckSpecific = createAndSaveDummyDeckSpecificReminder(did1)
@@ -218,7 +219,7 @@ class NotificationServiceTest : RobolectricTest() {
218219
}
219220

220221
@Test
221-
fun `onReceive with blocked collection should not fire notification but schedule next`() =
222+
fun `triggering with blocked collection should not fire notification but schedule next`() =
222223
runTest {
223224
val did1 = addDeck("Deck", setAsSelected = true).withNotes(count = 2)
224225
val reviewReminderDeckSpecific = createAndSaveDummyDeckSpecificReminder(did1)
@@ -238,18 +239,16 @@ class NotificationServiceTest : RobolectricTest() {
238239
}
239240

240241
@Test
241-
fun `onReceive with snoozed notification should fire notification but not schedule next`() =
242+
fun `triggering with snoozed notification should fire notification but not schedule next`() =
242243
runTest {
243244
val did1 = addDeck("Deck", setAsSelected = true).withNotes(count = 2)
244245
val reviewReminderDeckSpecific = createAndSaveDummyDeckSpecificReminder(did1)
245246
val reviewReminderAppWide = createAndSaveDummyAppWideReminder()
246247
val deckSpecificCreationTime = reviewReminderDeckSpecific.latestNotifTime
247248
val appWideCreationTime = reviewReminderAppWide.latestNotifTime
248249

249-
val intentDeckSpecific = reviewReminderDeckSpecific.getNotifIntent(NotifIntent.SNOOZE)
250-
val intentAppWide = reviewReminderAppWide.getNotifIntent(NotifIntent.SNOOZE)
251-
NotificationService().onReceive(context, intentDeckSpecific)
252-
NotificationService().onReceive(context, intentAppWide)
250+
triggerDummyReminderNotification(reviewReminderDeckSpecific, isRecurring = false)
251+
triggerDummyReminderNotification(reviewReminderAppWide, isRecurring = false)
253252

254253
verifyNotifSent(reviewReminderDeckSpecific)
255254
verifyNotifSent(reviewReminderAppWide)
@@ -270,11 +269,8 @@ class NotificationServiceTest : RobolectricTest() {
270269
val slotOne = slot<Notification>()
271270
val slotTwo = slot<Notification>()
272271

273-
val intentOne = reviewReminderOne.getNotifIntent(NotifIntent.RECURRING)
274-
NotificationService().onReceive(context, intentOne)
275-
276-
val intentTwo = reviewReminderTwo.getNotifIntent(NotifIntent.RECURRING)
277-
NotificationService().onReceive(context, intentTwo)
272+
triggerDummyReminderNotification(reviewReminderOne)
273+
triggerDummyReminderNotification(reviewReminderTwo)
278274

279275
verifyNotifSent(reviewReminderOne, slot = slotOne)
280276
verifyNotifSent(reviewReminderTwo, slot = slotTwo)
@@ -301,9 +297,15 @@ class NotificationServiceTest : RobolectricTest() {
301297
return reviewReminder
302298
}
303299

304-
private fun triggerDummyReminderNotification(reviewReminder: ReviewReminder) {
305-
val intent = reviewReminder.getNotifIntent(NotifIntent.RECURRING)
306-
NotificationService().onReceive(context, intent)
300+
private suspend fun triggerDummyReminderNotification(
301+
reviewReminder: ReviewReminder,
302+
isRecurring: Boolean = true,
303+
) {
304+
NotificationService.handleReviewReminderNotification(
305+
context,
306+
reviewReminder,
307+
isRecurringNotification = isRecurring,
308+
)
307309
}
308310

309311
/**
@@ -367,20 +369,4 @@ class NotificationServiceTest : RobolectricTest() {
367369
assertThat(previousTime, equalTo(storedReminder.latestNotifTime))
368370
}
369371
}
370-
371-
/**
372-
* Convenience enum class to minimize verbosity in test methods when using [getNotifIntent].
373-
*/
374-
private enum class NotifIntent {
375-
RECURRING,
376-
SNOOZE,
377-
}
378-
379-
private fun ReviewReminder.getNotifIntent(action: NotifIntent) =
380-
when (action) {
381-
NotifIntent.RECURRING -> NotificationService.NotificationServiceAction.ScheduleRecurringNotifications
382-
NotifIntent.SNOOZE -> NotificationService.NotificationServiceAction.SnoozeNotification
383-
}.let { action ->
384-
NotificationService.getIntent(context, this, action)
385-
}
386372
}

0 commit comments

Comments
 (0)