Skip to content

Commit 07401c5

Browse files
ericli3690mikehardy
authored andcommitted
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. `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. Added AlarmManagerService as a BroadcastReceiver to the AndroidManifest.xml file. There's a method called `catchAlarmManagerExceptions` which was previously in BootService. It was added to solve some bugs. I've moved it to AlarmManagerService and kept it around to ensure no regressions occur. Marked old functionality in NotificationService as legacy notification code. I 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 I added this sealed class. You might wonder why NotificationService is a BroadcastReceiver. 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.
1 parent ac92dbd commit 07401c5

File tree

5 files changed

+1155
-1
lines changed

5 files changed

+1155
-1
lines changed

AnkiDroid/src/main/AndroidManifest.xml

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

0 commit comments

Comments
 (0)