Skip to content

Commit 496aae3

Browse files
committed
feat(reminders): AlarmManagerService and NotificationService
GSoC 2025: Review Reminders - Added logic for review reminder notifications being sent to the user. Alarms for sending notifications are created by AlarmManagerService, and the actual notifications themselves are fired by NotificationService. - Moved catchAlarmManagerExceptions method that was previously in BootService to AlarmManagerService to ensure no regressions occur and old bugs are still solved. - `scheduleReviewReminderNotification` is the primary part of AlarmManagerService, setting the recurring notifications for a review reminder. `unschedule` and `scheduleAll` methods are also provided. - Snoozing is handled by AlarmManagerService via `scheduleSnoozedNotification`. AlarmManagerService must be a BroadcastReceiver so that it can receive snoozing requests via onReceive from PendingIntents created by NotificationService. - `sendReviewReminderNotification` in NotificationService does the bulk of the work for sending notifications, filling out content, etc. - `fireReviewReminderNotification` is a separate method that handles the actual OS call to fire the notification itself. - Marked old functionality in NotificationService as legacy notification code. - Had to create a NotificationServiceAction sealed class to mark the kinds of notification requests the NotificationService gets. These different requests must have different actions set, otherwise they collide with each other and interfere. This can cause snoozes to cancel normal notifications, normal notifications to cancel snoozes, etc., hence why we add this sealed class. - NotificationService must be a BroadcastReceiver because it needs to listen to PendingIntents triggered by AlarmManager alarms, which trigger the onReceive method. - Added unit tests for AlarmManagerService and NotificationService. - Added AlarmManagerService as a BroadcastReceiver to the AndroidManifest.xml file. - Added calls to set review reminder notifications on device boot-up and app start-up to AnkiDroidApp and BootService. - Renamed the inner field of ReviewReminderId to avoid confusing calls to `reviewReminder.id.id`.
1 parent bffdf9f commit 496aae3

File tree

8 files changed

+1046
-4
lines changed

8 files changed

+1046
-4
lines changed

AnkiDroid/src/main/AndroidManifest.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,11 @@
624624
android:enabled="true"
625625
android:exported="false"
626626
/>
627+
<receiver
628+
android:name=".services.AlarmManagerService"
629+
android:enabled="true"
630+
android:exported="false"
631+
/>
627632
<receiver
628633
android:name=".services.BootService"
629634
android:enabled="true"

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import com.ichi2.anki.preferences.SharedPreferencesProvider
5353
import com.ichi2.anki.preferences.sharedPrefs
5454
import com.ichi2.anki.servicelayer.DebugInfoService
5555
import com.ichi2.anki.servicelayer.ThrowableFilterService
56+
import com.ichi2.anki.services.AlarmManagerService
5657
import com.ichi2.anki.services.NotificationService
5758
import com.ichi2.anki.settings.Prefs
5859
import com.ichi2.anki.ui.dialogs.ActivityAgnosticDialogs
@@ -208,7 +209,9 @@ open class AnkiDroidApp :
208209

209210
if (Prefs.newReviewRemindersEnabled) {
210211
Timber.i("Setting review reminder notifications if they have not already been set")
211-
// TODO: GSoC 2025
212+
applicationScope.launch {
213+
AlarmManagerService.scheduleAllEnabledReviewReminderNotifications(applicationContext)
214+
}
212215
} else {
213216
// Register for notifications
214217
Timber.i("AnkiDroidApp: Starting Services")

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import kotlin.time.Duration.Companion.minutes
3535
@Serializable
3636
@Parcelize
3737
value class ReviewReminderId(
38-
val id: Int,
38+
val value: Int,
3939
) : Parcelable {
4040
companion object {
4141
/**
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
/*
2+
* Copyright (c) 2025 Eric Li <[email protected]>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11+
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License along with
14+
* this program. If not, see <http://www.gnu.org/licenses/>.
15+
*/
16+
17+
package com.ichi2.anki.services
18+
19+
import android.app.AlarmManager
20+
import android.app.NotificationManager
21+
import android.app.PendingIntent
22+
import android.content.BroadcastReceiver
23+
import android.content.Context
24+
import android.content.Intent
25+
import androidx.core.app.PendingIntentCompat
26+
import androidx.core.os.BundleCompat
27+
import com.ichi2.anki.R
28+
import com.ichi2.anki.common.time.TimeManager
29+
import com.ichi2.anki.reviewreminders.ReviewReminder
30+
import com.ichi2.anki.reviewreminders.ReviewRemindersDatabase
31+
import com.ichi2.anki.showThemedToast
32+
import timber.log.Timber
33+
import java.util.Calendar
34+
import kotlin.time.Duration
35+
import kotlin.time.Duration.Companion.minutes
36+
37+
/**
38+
* Schedules review reminder notifications, including scheduling snoozed instances of review reminders.
39+
* See [ReviewReminder] for the distinction between a "review reminder" and a "notification".
40+
* Actual notification firing is handled by [NotificationService].
41+
*/
42+
class AlarmManagerService : BroadcastReceiver() {
43+
companion object {
44+
/**
45+
* Extra key for sending a review reminder as an extra to this BroadcastReceiver.
46+
*/
47+
private const val EXTRA_REVIEW_REMINDER = "alarm_manager_service_review_reminder"
48+
49+
/**
50+
* Extra key for sending a snooze delay interval as an extra to this BroadcastReceiver.
51+
* The stored value is an integer number of minutes.
52+
*/
53+
private const val EXTRA_SNOOZE_INTERVAL = "alarm_manager_service_snooze_interval"
54+
55+
/**
56+
* Interval passed to [AlarmManager.setWindow], in milliseconds. The OS is allowed to delay AnkiDroid's notifications
57+
* by at much this amount of time. We set it to 10 minutes, which is the minimum allowable duration
58+
* according to [the docs](https://developer.android.com/reference/android/app/AlarmManager).
59+
*/
60+
private val WINDOW_LENGTH_MS: Long = 10.minutes.inWholeMilliseconds
61+
62+
/**
63+
* Shows error messages if an error occurs when scheduling review reminders via AlarmManager.
64+
* This function wraps all calls to AlarmManager in this class.
65+
*/
66+
private fun catchAlarmManagerExceptions(
67+
context: Context,
68+
block: () -> Unit,
69+
) {
70+
var error: Int? = null
71+
try {
72+
block()
73+
} catch (ex: SecurityException) {
74+
// #6332 - Too Many Alarms on Samsung Devices - this stops a fatal startup crash.
75+
// We warn the user if they breach this limit
76+
Timber.w(ex)
77+
error = R.string.boot_service_too_many_notifications
78+
} catch (e: Exception) {
79+
Timber.w(e)
80+
error = R.string.boot_service_failed_to_schedule_notifications
81+
}
82+
if (error != null) {
83+
showThemedToast(context, context.getString(error), false)
84+
}
85+
}
86+
87+
/**
88+
* Gets the pending intent of a review reminder's scheduled notifications, either the normal recurring ones
89+
* (if the action is set to [NotificationService.NotificationServiceAction.ScheduleRecurringNotifications])
90+
* or the one-time snoozed ones (if the action is set to [NotificationService.NotificationServiceAction.SnoozeNotification]).
91+
* This pending intent can then be used to either schedule those notifications or cancel them.
92+
*
93+
* If a review reminder with an identical ID has already had notifications scheduled via the pending intent
94+
* returned by this method, new notifications scheduled using this pending intent will update the existing
95+
* notifications rather than create duplicate new ones.
96+
*/
97+
private fun getReviewReminderNotificationPendingIntent(
98+
context: Context,
99+
reviewReminder: ReviewReminder,
100+
intentAction: NotificationService.NotificationServiceAction,
101+
): PendingIntent? {
102+
val intent = NotificationService.getIntent(context, reviewReminder, intentAction)
103+
return PendingIntentCompat.getBroadcast(
104+
context,
105+
reviewReminder.id.value,
106+
intent,
107+
PendingIntent.FLAG_UPDATE_CURRENT,
108+
false,
109+
)
110+
}
111+
112+
/**
113+
* Queues a review reminder to have its notification fired at its specified time. Does not check
114+
* if the review reminder is enabled or not, the caller must handle this.
115+
*
116+
* Note that this only schedules the next upcoming notification, using [AlarmManager.setWindow]
117+
* rather than [AlarmManager.setRepeating]. This is because [AlarmManager.setRepeating] sometimes
118+
* postpones alarm firings for long periods of time, with intervals as long as one hour observed
119+
* in testing. In contrast, [AlarmManager.setWindow] permits us to specify a maximum allowable
120+
* length of time the OS can delay the alarm for, leading to a better UX. Each time an alarm is fired,
121+
* triggering [NotificationService.sendReviewReminderNotification], this method is called again to
122+
* schedule the next upcoming notification. If for some reason the next day's alarm fails to be set by
123+
* the current day's notification, we fall back to setting alarms whenever the app is opened: see
124+
* [com.ichi2.anki.AnkiDroidApp]'s call to [scheduleAllEnabledReviewReminderNotifications].
125+
*
126+
* If an old version of this review reminder with the same review reminder ID has already had
127+
* its notifications scheduled, this will merely update the existing notifications. If, however,
128+
* an old version of this review reminder with a different review reminder ID has already had its
129+
* notifications scheduled, this will NOT delete the old scheduled notifications. They must be
130+
* manually deleted via [unscheduleReviewReminderNotifications].
131+
*/
132+
fun scheduleReviewReminderNotification(
133+
context: Context,
134+
reviewReminder: ReviewReminder,
135+
) {
136+
Timber.d("Beginning scheduleReviewReminderNotifications for ${reviewReminder.id}")
137+
Timber.v("Review reminder: $reviewReminder")
138+
val pendingIntent =
139+
getReviewReminderNotificationPendingIntent(
140+
context,
141+
reviewReminder,
142+
NotificationService.NotificationServiceAction.ScheduleRecurringNotifications,
143+
) ?: return
144+
Timber.v("Pending intent for ${reviewReminder.id} is $pendingIntent")
145+
146+
val currentTimestamp = TimeManager.time.calendar()
147+
val alarmTimestamp = currentTimestamp.clone() as Calendar
148+
alarmTimestamp.apply {
149+
set(Calendar.HOUR_OF_DAY, reviewReminder.time.hour)
150+
set(Calendar.MINUTE, reviewReminder.time.minute)
151+
set(Calendar.SECOND, 0)
152+
if (before(currentTimestamp)) {
153+
add(Calendar.DAY_OF_YEAR, 1)
154+
}
155+
}
156+
157+
catchAlarmManagerExceptions(context) {
158+
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
159+
alarmManager.setWindow(
160+
AlarmManager.RTC_WAKEUP,
161+
alarmTimestamp.timeInMillis,
162+
WINDOW_LENGTH_MS,
163+
pendingIntent,
164+
)
165+
Timber.d("Successfully scheduled review reminder notifications for ${reviewReminder.id}")
166+
}
167+
}
168+
169+
/**
170+
* Deletes any scheduled notifications for this review reminder. Does not actually delete the
171+
* review reminder itself from anywhere, only deletes any queued alarms for the review reminder.
172+
*/
173+
fun unscheduleReviewReminderNotifications(
174+
context: Context,
175+
reviewReminder: ReviewReminder,
176+
) {
177+
Timber.d("Beginning unscheduleReviewReminderNotifications for ${reviewReminder.id}")
178+
Timber.v("Review reminder: $reviewReminder")
179+
val pendingIntent =
180+
getReviewReminderNotificationPendingIntent(
181+
context,
182+
reviewReminder,
183+
NotificationService.NotificationServiceAction.ScheduleRecurringNotifications,
184+
) ?: return
185+
Timber.v("Pending intent for ${reviewReminder.id} is $pendingIntent")
186+
catchAlarmManagerExceptions(context) {
187+
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
188+
alarmManager.cancel(pendingIntent)
189+
Timber.d("Successfully unscheduled review reminder notifications for ${reviewReminder.id}")
190+
}
191+
}
192+
193+
/**
194+
* Schedules notifications for all currently-enabled review reminders. Reads from the [ReviewRemindersDatabase].
195+
*
196+
* If, for a review reminder in the database, an old version of a review reminder with the same review
197+
* reminder ID has already had its notifications scheduled, this will merely update the existing notifications.
198+
* If, however, an old version of a review reminder with a different review reminder ID has already had its
199+
* notifications scheduled, this will NOT delete the old scheduled notifications. They must be
200+
* manually deleted via [unscheduleReviewReminderNotifications].
201+
*/
202+
suspend fun scheduleAllEnabledReviewReminderNotifications(context: Context) {
203+
Timber.d("scheduleAllEnabledReviewReminderNotifications")
204+
val allReviewRemindersAsMap =
205+
ReviewRemindersDatabase.getAllAppWideReminders() + ReviewRemindersDatabase.getAllDeckSpecificReminders()
206+
val enabledReviewReminders = allReviewRemindersAsMap.values.filter { it.enabled }
207+
for (reviewReminder in enabledReviewReminders) {
208+
scheduleReviewReminderNotification(context, reviewReminder)
209+
}
210+
}
211+
212+
/**
213+
* Schedules a one-time notification for a review reminder after a set amount of minutes.
214+
* Used for snoozing functionality.
215+
*
216+
* We could instead use WorkManager and enqueue a OneTimeWorkRequest with an initial delay of [snoozeIntervalInMinutes],
217+
* but WorkManager work is sometimes deferred for long periods of time by the OS.
218+
* Setting an explicit alarm via AlarmManager, either via [AlarmManager.set] or [AlarmManager.setWindow],
219+
* tends to result in more timely snooze notification recurrences. Here, we use [AlarmManager.setWindow]
220+
* to ensure the OS does not delay the notification for longer than at most ten minutes.
221+
*/
222+
private fun scheduleSnoozedNotification(
223+
context: Context,
224+
reviewReminder: ReviewReminder,
225+
snoozeIntervalInMinutes: Int,
226+
) {
227+
Timber.d("Beginning scheduleSnoozedNotification for ${reviewReminder.id}")
228+
Timber.v("Review reminder: $reviewReminder")
229+
val pendingIntent =
230+
getReviewReminderNotificationPendingIntent(
231+
context,
232+
reviewReminder,
233+
NotificationService.NotificationServiceAction.SnoozeNotification,
234+
) ?: return
235+
Timber.v("Pending intent for ${reviewReminder.id} is $pendingIntent")
236+
237+
val alarmTimestamp = TimeManager.time.calendar()
238+
alarmTimestamp.add(Calendar.MINUTE, snoozeIntervalInMinutes)
239+
catchAlarmManagerExceptions(context) {
240+
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
241+
alarmManager.setWindow(
242+
AlarmManager.RTC_WAKEUP,
243+
alarmTimestamp.timeInMillis,
244+
WINDOW_LENGTH_MS,
245+
pendingIntent,
246+
)
247+
Timber.d("Successfully scheduled snoozed review reminder notifications for ${reviewReminder.id}")
248+
}
249+
}
250+
251+
/**
252+
* Method for getting an intent to snooze a review reminder for this service.
253+
*/
254+
fun getIntent(
255+
context: Context,
256+
reviewReminder: ReviewReminder,
257+
snoozeInterval: Duration,
258+
) = Intent(context, AlarmManagerService::class.java).apply {
259+
val snoozeIntervalInMinutes = snoozeInterval.inWholeMinutes.toInt()
260+
// Includes the snooze interval in the action string so that the pending intents for different snooze interval
261+
// buttons on review reminder notifications are different.
262+
action = "com.ichi2.anki.ACTION_START_REMINDER_SNOOZING_$snoozeIntervalInMinutes"
263+
putExtra(EXTRA_REVIEW_REMINDER, reviewReminder)
264+
putExtra(EXTRA_SNOOZE_INTERVAL, snoozeIntervalInMinutes)
265+
}
266+
}
267+
268+
/**
269+
* Begins snoozing a review reminder.
270+
* @see getIntent
271+
*/
272+
override fun onReceive(
273+
context: Context,
274+
intent: Intent,
275+
) {
276+
Timber.d("onReceive")
277+
// Get the request type
278+
val extras = intent.extras ?: return
279+
val reviewReminder =
280+
BundleCompat.getParcelable(
281+
extras,
282+
EXTRA_REVIEW_REMINDER,
283+
ReviewReminder::class.java,
284+
) ?: return
285+
// The following returns 0 if the key is not found, meaning the snooze interval is 0 minutes,
286+
// which is an acceptable error fallback case.
287+
val snoozeIntervalInMinutes = extras.getInt(EXTRA_SNOOZE_INTERVAL)
288+
289+
scheduleSnoozedNotification(
290+
context,
291+
reviewReminder,
292+
snoozeIntervalInMinutes,
293+
)
294+
// Dismiss the snoozed notification when the snooze button is clicked
295+
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
296+
manager.cancel(NotificationService.REVIEW_REMINDER_NOTIFICATION_TAG, reviewReminder.id.value)
297+
}
298+
}

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,13 @@ import com.ichi2.anki.common.time.TimeManager
3232
import com.ichi2.anki.libanki.Collection
3333
import com.ichi2.anki.preferences.PENDING_NOTIFICATIONS_ONLY
3434
import com.ichi2.anki.preferences.sharedPrefs
35+
import com.ichi2.anki.runGloballyWithTimeout
36+
import com.ichi2.anki.services.AlarmManagerService.Companion.scheduleAllEnabledReviewReminderNotifications
3537
import com.ichi2.anki.settings.Prefs
3638
import com.ichi2.anki.showThemedToast
3739
import timber.log.Timber
3840
import java.util.Calendar
41+
import kotlin.time.Duration.Companion.seconds
3942

4043
/**
4144
* BroadcastReceiver which listens to the Android system-level intent that fires when the device starts up.
@@ -67,7 +70,13 @@ class BootService : BroadcastReceiver() {
6770
}
6871
if (Prefs.newReviewRemindersEnabled) {
6972
Timber.i("Executing Boot Service - Review reminders")
70-
// TODO: GSoC 2025: Run schedule all notifications method
73+
runGloballyWithTimeout(SET_NOTIFICATION_TIMEOUT) {
74+
// We run this on the global scope for simplicity's sake, as BroadcastReceivers do not have CoroutineScopes.
75+
// Theoretically we could also use an expedited Worker, but AnkiDroid is only allotted a fixed number
76+
// of expedited Worker calls per day, and these expedited calls are also used by the sync service,
77+
// so it's best to conserve them.
78+
scheduleAllEnabledReviewReminderNotifications(context)
79+
}
7180
} else {
7281
// There are cases where the app is installed, and we have access, but nothing exist yet
7382
val col = getColSafe()
@@ -129,6 +138,11 @@ class BootService : BroadcastReceiver() {
129138
*/
130139
private var wasRun = false
131140

141+
/**
142+
* Timeout for the process of setting all stored review reminders' notifications.
143+
*/
144+
private val SET_NOTIFICATION_TIMEOUT = 10.seconds
145+
132146
@LegacyNotifications("Replaced by new review reminder scheduling logic")
133147
fun scheduleNotification(
134148
time: Time,

0 commit comments

Comments
 (0)