Skip to content

Commit 9a8fcb7

Browse files
committed
feat(reminders): fire immediately if notification is late
Review Reminders We currently schedule all stored review reminders for their next upcoming alarm time when the application process starts up, such as when the app initially launches (we also have a BOOT_COMPLETED receiver). However, a notification "firing" is not just a single moment in time; since we don't have permissions to use proper alarm-clock-style alarms, the best we can do is a 10 minute `setWindow`, which means "firing" a notification is a non-deterministic event that happens some time in the ten minutes after a review reminder's scheduled notification time. Consider the following set of actions: 1. The user creates a review reminder for 1:00 pm, its first notification is scheduled for 1:00 pm on Monday 2. At 1:00, we enter the `setWindow` period 3. At 1:03, the notification still has not fired; curious as to why, the user opens up their app 4. This causes `scheduleAllNotifications` to trigger, which reschedules the review reminder notification for the next upcoming time, which is 1:00 pm on Tuesday 5. ... We've skipped a notification! To solution for this is to copy most notification systems: we record when the latest notification time was ourselves (it's impossible to read AlarmManager's currently-scheduled alarms programmatically) and check whenever scheduling a notification if the review reminder's notification has fired since its most recent scheduled time. If not, we immediately fire a notification. This also helps with missed notifications in general: if, for example, the user has had their device off for 6 hours and then turns it on, all alarms that fired during that interval should immediately show. This commit also creates unit tests surrounding this feature and enhances the readability of NotificationServiceTest. The ReviewReminderSchema is migrated from v2 to v3.
1 parent 0de8f69 commit 9a8fcb7

11 files changed

Lines changed: 804 additions & 261 deletions

File tree

AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReviewReminder.kt

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import android.text.format.DateFormat
2222
import com.ichi2.anki.CollectionManager.withCol
2323
import com.ichi2.anki.common.time.TimeManager
2424
import com.ichi2.anki.libanki.DeckId
25+
import com.ichi2.anki.libanki.EpochMilliseconds
2526
import com.ichi2.anki.settings.Prefs
2627
import kotlinx.parcelize.IgnoredOnParcel
2728
import kotlinx.parcelize.Parcelize
@@ -185,6 +186,9 @@ sealed class ReviewReminderScope : Parcelable {
185186
* @param enabled Whether the review reminder's notifications are active or disabled.
186187
* @param profileID ID representing the profile which created this review reminder, as review reminders for
187188
* multiple profiles might be active simultaneously.
189+
* @param latestNotifTime The time at which this review reminder last attempted to fire a routine daily (non-snooze)
190+
* notification, in epoch milliseconds, or the time at which it was created if no notification has ever been fired.
191+
* See [shouldImmediatelyFire].
188192
* @param onlyNotifyIfNoReviews If true, only notify the user if this scope has not been reviewed today yet.
189193
*/
190194
@Serializable
@@ -196,6 +200,7 @@ data class ReviewReminder private constructor(
196200
val cardTriggerThreshold: ReviewReminderCardTriggerThreshold,
197201
val scope: ReviewReminderScope,
198202
var enabled: Boolean,
203+
var latestNotifTime: EpochMilliseconds,
199204
val profileID: String,
200205
val onlyNotifyIfNoReviews: Boolean,
201206
) : Parcelable,
@@ -219,11 +224,42 @@ data class ReviewReminder private constructor(
219224
cardTriggerThreshold,
220225
scope,
221226
enabled,
227+
latestNotifTime = TimeManager.time.calendar().timeInMillis,
222228
profileID,
223229
onlyNotifyIfNoReviews,
224230
)
225231
}
226232

233+
/**
234+
* Updates [latestNotifTime] to the current time.
235+
* This should be called whenever this review reminder attempts to fire a routine daily (non-snooze) notification.
236+
*/
237+
fun updateLatestNotifTime() {
238+
latestNotifTime = TimeManager.time.calendar().timeInMillis
239+
}
240+
241+
/**
242+
* Checks if this review reminder has tried to fire a routine daily (non-snooze) notification in the time between
243+
* its latest scheduled firing time and now. If not, this method returns true, indicating that a notification
244+
* should be immediately fired for this review reminder.
245+
*/
246+
fun shouldImmediatelyFire(): Boolean {
247+
val (hour, minute) = this.time
248+
249+
val currentTimestamp = TimeManager.time.calendar()
250+
val latestScheduledTimestamp = currentTimestamp.clone() as Calendar
251+
latestScheduledTimestamp.apply {
252+
set(Calendar.HOUR_OF_DAY, hour)
253+
set(Calendar.MINUTE, minute)
254+
set(Calendar.SECOND, 0)
255+
if (after(currentTimestamp)) {
256+
add(Calendar.DAY_OF_YEAR, -1)
257+
}
258+
}
259+
260+
return latestNotifTime < latestScheduledTimestamp.timeInMillis
261+
}
262+
227263
/**
228264
* This is the up-to-date schema, we cannot migrate to a newer version.
229265
*/

AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReviewReminderSchema.kt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,31 @@ data class ReviewReminderSchemaV1(
7575
var enabled: Boolean,
7676
val profileID: String,
7777
val onlyNotifyIfNoReviews: Boolean = false,
78+
) : ReviewReminderSchema {
79+
override fun migrate(): ReviewReminderSchema =
80+
ReviewReminderSchemaV2(
81+
id = id,
82+
time = time,
83+
cardTriggerThreshold = cardTriggerThreshold,
84+
scope = scope,
85+
enabled = enabled,
86+
profileID = profileID,
87+
onlyNotifyIfNoReviews = onlyNotifyIfNoReviews,
88+
)
89+
}
90+
91+
/**
92+
* Version 2 of [ReviewReminderSchema]. Updated to Version 3 by adding [ReviewReminder.latestNotifTime].
93+
*/
94+
@Serializable
95+
data class ReviewReminderSchemaV2(
96+
override val id: ReviewReminderId,
97+
val time: ReviewReminderTime,
98+
val cardTriggerThreshold: ReviewReminderCardTriggerThreshold,
99+
val scope: ReviewReminderScope,
100+
var enabled: Boolean,
101+
val profileID: String,
102+
val onlyNotifyIfNoReviews: Boolean,
78103
) : ReviewReminderSchema {
79104
override fun migrate(): ReviewReminderSchema =
80105
ReviewReminder.createReviewReminder(

AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReviewRemindersDatabase.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,12 +107,13 @@ object ReviewRemindersDatabase {
107107
*
108108
* Version 1: 3 August 2025 - Initial version
109109
* Version 2: 25 January 2026 - Added [ReviewReminder.onlyNotifyIfNoReviews]
110+
* Version 3: 8 February 2026 - Added [ReviewReminder.latestNotifTime]
110111
*
111112
* @see [oldReviewReminderSchemasForMigration]
112113
* @see [ReviewReminder]
113114
*/
114115
@VisibleForTesting
115-
var schemaVersion = ReviewReminderSchemaVersion(2)
116+
var schemaVersion = ReviewReminderSchemaVersion(3)
116117

117118
/**
118119
* A map of all old [ReviewReminderSchema]s that [ReviewRemindersDatabase.performSchemaMigration] will attempt to migrate old
@@ -130,7 +131,8 @@ object ReviewRemindersDatabase {
130131
var oldReviewReminderSchemasForMigration: Map<ReviewReminderSchemaVersion, KClass<out ReviewReminderSchema>> =
131132
mapOf(
132133
ReviewReminderSchemaVersion(1) to ReviewReminderSchemaV1::class,
133-
ReviewReminderSchemaVersion(2) to ReviewReminder::class, // Most up to date version
134+
ReviewReminderSchemaVersion(2) to ReviewReminderSchemaV2::class,
135+
ReviewReminderSchemaVersion(3) to ReviewReminder::class, // Most up to date version
134136
)
135137

136138
/**

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

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,16 @@ import android.app.PendingIntent
2222
import android.content.BroadcastReceiver
2323
import android.content.Context
2424
import android.content.Intent
25+
import androidx.annotation.VisibleForTesting
2526
import androidx.core.app.PendingIntentCompat
2627
import androidx.core.content.getSystemService
2728
import androidx.core.os.BundleCompat
2829
import com.ichi2.anki.R
2930
import com.ichi2.anki.common.time.TimeManager
3031
import com.ichi2.anki.reviewreminders.ReviewReminder
32+
import com.ichi2.anki.reviewreminders.ReviewReminderScope
3133
import com.ichi2.anki.reviewreminders.ReviewRemindersDatabase
34+
import com.ichi2.anki.reviewreminders.upsertReminder
3235
import com.ichi2.anki.showThemedToast
3336
import timber.log.Timber
3437
import java.util.Calendar
@@ -65,7 +68,8 @@ class AlarmManagerService : BroadcastReceiver() {
6568
* by at much this amount of time. We set it to 10 minutes, which is the minimum allowable duration
6669
* according to [the docs](https://developer.android.com/reference/android/app/AlarmManager).
6770
*/
68-
private val WINDOW_LENGTH_MS: Long = 10.minutes.inWholeMilliseconds
71+
@VisibleForTesting
72+
val WINDOW_LENGTH_MS: Long = 10.minutes.inWholeMilliseconds
6973

7074
/**
7175
* Shows error messages if an error occurs when scheduling review reminders via AlarmManager.
@@ -132,6 +136,11 @@ class AlarmManagerService : BroadcastReceiver() {
132136
* Queues a review reminder to have its notification fired at its specified time. Does not check
133137
* if the review reminder is enabled or not, the caller must handle this.
134138
*
139+
* If the review reminder has failed to fire a notification at its most recent specified time for some
140+
* reason (ex. if the device was off, or if the OS delayed the notification for some reason),
141+
* a notification will be fired immediately and no alarm will be immediately scheduled, as the
142+
* notification should automatically trigger the scheduling of the next alarm.
143+
*
135144
* Note that this only schedules the next upcoming notification, using [AlarmManager.setWindow]
136145
* rather than [AlarmManager.setRepeating]. This is because [AlarmManager.setRepeating] sometimes
137146
* postpones alarm firings for long periods of time, with intervals as long as one hour observed
@@ -164,6 +173,13 @@ class AlarmManagerService : BroadcastReceiver() {
164173
) ?: return
165174
Timber.v("Pending intent for ${reviewReminder.id} is $pendingIntent")
166175

176+
if (reviewReminder.shouldImmediatelyFire()) {
177+
immediatelyFireNotification(context, reviewReminder)
178+
// Once the notification has fired, it will automatically trigger the setting of the next alarm
179+
// so we can return immediately
180+
return
181+
}
182+
167183
val currentTimestamp = TimeManager.time.calendar()
168184
val alarmTimestamp = currentTimestamp.clone() as Calendar
169185
alarmTimestamp.apply {
@@ -186,6 +202,37 @@ class AlarmManagerService : BroadcastReceiver() {
186202
}
187203
}
188204

205+
/**
206+
* Immediately fires a review reminder notification for a review reminder, which in turn then schedules the next notification.
207+
* Used when a review reminder's notification has been delayed and failed to fire for some reason.
208+
*/
209+
private fun immediatelyFireNotification(
210+
context: Context,
211+
reviewReminder: ReviewReminder,
212+
) {
213+
Timber.d("Review reminder ${reviewReminder.id} should have fired already, sending notification immediately")
214+
215+
// Immediately (redundantly) record this latest routine notification-firing attempt's timestamp
216+
// to prevent this from being triggered multiple times in rapid succession
217+
reviewReminder.updateLatestNotifTime()
218+
when (val scope = reviewReminder.scope) {
219+
is ReviewReminderScope.DeckSpecific ->
220+
ReviewRemindersDatabase.editRemindersForDeck(
221+
scope.did,
222+
upsertReminder(reviewReminder),
223+
)
224+
is ReviewReminderScope.Global -> ReviewRemindersDatabase.editAllAppWideReminders(upsertReminder(reviewReminder))
225+
}
226+
227+
val immediateNotificationIntent =
228+
NotificationService.getIntent(
229+
context,
230+
reviewReminder,
231+
NotificationService.NotificationServiceAction.ScheduleRecurringNotifications,
232+
)
233+
context.sendBroadcast(immediateNotificationIntent)
234+
}
235+
189236
/**
190237
* Deletes any scheduled notifications for this review reminder. Does not actually delete the
191238
* review reminder itself from anywhere, only deletes any queued alarms for the review reminder.

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import android.content.BroadcastReceiver
2020
import android.content.Context
2121
import android.content.Intent
2222
import android.graphics.Color
23+
import androidx.annotation.VisibleForTesting
2324
import androidx.core.app.NotificationCompat
2425
import androidx.core.app.PendingIntentCompat
2526
import androidx.core.content.getSystemService
@@ -37,6 +38,8 @@ import com.ichi2.anki.preferences.PENDING_NOTIFICATIONS_ONLY
3738
import com.ichi2.anki.preferences.sharedPrefs
3839
import com.ichi2.anki.reviewreminders.ReviewReminder
3940
import com.ichi2.anki.reviewreminders.ReviewReminderScope
41+
import com.ichi2.anki.reviewreminders.ReviewRemindersDatabase
42+
import com.ichi2.anki.reviewreminders.upsertReminder
4043
import com.ichi2.anki.runGloballyWithTimeout
4144
import com.ichi2.anki.settings.Prefs
4245
import com.ichi2.anki.utils.ext.allDecksCounts
@@ -71,7 +74,8 @@ class NotificationService : BroadcastReceiver() {
7174
/**
7275
* Extra key for sending a review reminder as an extra to this broadcast receiver.
7376
*/
74-
private const val EXTRA_REVIEW_REMINDER = "notification_service_review_reminder"
77+
@VisibleForTesting
78+
const val EXTRA_REVIEW_REMINDER = "notification_service_review_reminder"
7579

7680
/**
7781
* Timeout for the process of sending a review reminder notification.
@@ -417,8 +421,20 @@ class NotificationService : BroadcastReceiver() {
417421
) ?: return
418422
Timber.d("onReceive: ${reviewReminder.id}")
419423

420-
// Schedule the next instance of this review reminder notification if this is a recurring notification
424+
// If this is a recurring notification...
421425
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
422438
Timber.d("Scheduling next review reminder notification")
423439
AlarmManagerService.scheduleReviewReminderNotification(context, reviewReminder)
424440
}

0 commit comments

Comments
 (0)