Skip to content

Commit 9f44e2d

Browse files
committed
feat(reminders): only notify if no reviews
GSoC 2025: Review Reminders Adds an advanced review reminder option. When this setting is enabled, the review reminder only triggers notifications if the deck the review reminder is for has not been reviewed yet today. Adds a checkbox to the AddEditReminderDialog to toggle this setting on or off. Adds a method to NotificationService to check if a deck (or all decks) have been reviewed yet today. The check is accomplished via a database query of the `revlog` and `cards` tables. In my experience there is no noticeable latency, the query I've written should be fairly efficient. Adds a boolean field to store the state of this setting to ReviewReminder. Adds unit tests.
1 parent 7f9325a commit 9f44e2d

File tree

6 files changed

+199
-4
lines changed

6 files changed

+199
-4
lines changed

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import androidx.fragment.app.viewModels
3939
import androidx.lifecycle.viewmodel.initializer
4040
import androidx.lifecycle.viewmodel.viewModelFactory
4141
import com.google.android.material.button.MaterialButton
42+
import com.google.android.material.checkbox.MaterialCheckBox
4243
import com.google.android.material.timepicker.MaterialTimePicker
4344
import com.google.android.material.timepicker.TimeFormat
4445
import com.ichi2.anki.DeckSpinnerSelection
@@ -98,6 +99,7 @@ class AddEditReminderDialog : DialogFragment() {
9899
is ReviewReminderScope.DeckSpecific -> mode.schedulerScope.did
99100
},
100101
initialCardTriggerThreshold = INITIAL_CARD_THRESHOLD,
102+
initialOnlyNotifyIfNoReviews = INITIAL_ONLY_NOTIFY_IF_NO_REVIEWS,
101103
initialAdvancedSettingsOpen = INITIAL_ADVANCED_SETTINGS_OPEN,
102104
)
103105
is DialogMode.Edit ->
@@ -109,6 +111,7 @@ class AddEditReminderDialog : DialogFragment() {
109111
is ReviewReminderScope.DeckSpecific -> mode.reminderToBeEdited.scope.did
110112
},
111113
initialCardTriggerThreshold = mode.reminderToBeEdited.cardTriggerThreshold.threshold,
114+
initialOnlyNotifyIfNoReviews = mode.reminderToBeEdited.onlyNotifyIfNoReviews,
112115
initialAdvancedSettingsOpen = INITIAL_ADVANCED_SETTINGS_OPEN,
113116
)
114117
}
@@ -164,6 +167,7 @@ class AddEditReminderDialog : DialogFragment() {
164167
setUpDeckSpinner()
165168
setUpAdvancedDropdown()
166169
setUpCardThresholdInput()
170+
setUpOnlyNotifyIfNoReviewsCheckbox()
167171

168172
// For getting the result of the deck selection sub-dialog from ScheduleReminders
169173
// See ScheduleReminders.onDeckSelected for more information
@@ -250,6 +254,20 @@ class AddEditReminderDialog : DialogFragment() {
250254
}
251255
}
252256

257+
private fun setUpOnlyNotifyIfNoReviewsCheckbox() {
258+
val contentSection = contentView.findViewById<LinearLayout>(R.id.add_edit_reminder_only_notify_if_no_reviews_section)
259+
val checkbox = contentView.findViewById<MaterialCheckBox>(R.id.add_edit_reminder_only_notify_if_no_reviews_checkbox)
260+
contentSection.setOnClickListener {
261+
viewModel.toggleOnlyNotifyIfNoReviews()
262+
}
263+
checkbox.setOnClickListener {
264+
viewModel.toggleOnlyNotifyIfNoReviews()
265+
}
266+
viewModel.onlyNotifyIfNoReviews.observe(this) { onlyNotifyIfNoReviews ->
267+
checkbox.isChecked = onlyNotifyIfNoReviews
268+
}
269+
}
270+
253271
/**
254272
* Show the time picker dialog for selecting a time with a given hour and minute.
255273
* Does not automatically dismiss the old dialog.
@@ -327,6 +345,7 @@ class AddEditReminderDialog : DialogFragment() {
327345
is DialogMode.Add -> true
328346
is DialogMode.Edit -> mode.reminderToBeEdited.enabled
329347
},
348+
onlyNotifyIfNoReviews = viewModel.onlyNotifyIfNoReviews.value ?: INITIAL_ONLY_NOTIFY_IF_NO_REVIEWS,
330349
)
331350

332351
Timber.d("Reminder to be returned: %s", reminderToBeReturned)
@@ -391,6 +410,13 @@ class AddEditReminderDialog : DialogFragment() {
391410
*/
392411
private const val INITIAL_CARD_THRESHOLD: Int = 1
393412

413+
/**
414+
* The default value for whether a notification should only be fired if no reviews have been done today
415+
* for the corresponding deck / all decks. Since this is set to false, the default behaviour is that
416+
* notifications will always be sent, regardless of whether reviews have been done today.
417+
*/
418+
private const val INITIAL_ONLY_NOTIFY_IF_NO_REVIEWS = false
419+
394420
/**
395421
* Whether the advanced settings dropdown is initially open.
396422
* We start with it closed to avoid overwhelming the user.

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ class AddEditReminderDialogViewModel(
3232
initialTime: ReviewReminderTime,
3333
initialDeckSelected: DeckId,
3434
initialCardTriggerThreshold: Int,
35+
initialOnlyNotifyIfNoReviews: Boolean,
3536
initialAdvancedSettingsOpen: Boolean,
3637
) : ViewModel() {
3738
private val _time = MutableLiveData(initialTime)
@@ -48,6 +49,9 @@ class AddEditReminderDialogViewModel(
4849
private val _cardTriggerThreshold = MutableLiveData(initialCardTriggerThreshold)
4950
val cardTriggerThreshold: LiveData<Int> = _cardTriggerThreshold
5051

52+
private val _onlyNotifyIfNoReviews = MutableLiveData(initialOnlyNotifyIfNoReviews)
53+
val onlyNotifyIfNoReviews: LiveData<Boolean> = _onlyNotifyIfNoReviews
54+
5155
private val _advancedSettingsOpen = MutableLiveData(initialAdvancedSettingsOpen)
5256
val advancedSettingsOpen: LiveData<Boolean> = _advancedSettingsOpen
5357

@@ -66,6 +70,11 @@ class AddEditReminderDialogViewModel(
6670
_cardTriggerThreshold.value = threshold
6771
}
6872

73+
fun toggleOnlyNotifyIfNoReviews() {
74+
Timber.d("Toggled onlyNotifyIfNoReviews from %s", _onlyNotifyIfNoReviews.value)
75+
_onlyNotifyIfNoReviews.value = !(_onlyNotifyIfNoReviews.value ?: false)
76+
}
77+
6978
fun toggleAdvancedSettingsOpen() {
7079
Timber.d("Toggled advanced settings open from %s", _advancedSettingsOpen.value)
7180
_advancedSettingsOpen.value = !(_advancedSettingsOpen.value ?: false)

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,13 +162,13 @@ sealed class ReviewReminderScope : Parcelable {
162162
* Preferably, also add some unit tests to ensure your migration works properly on all user devices once your update is rolled out.
163163
* See ReviewRemindersDatabaseTest for examples on how to do this.
164164
*
165-
* TODO: add remaining fields planned for GSoC 2025.
166-
*
167165
* @param id Unique, auto-incremented ID of the review reminder.
168166
* @param time See [ReviewReminderTime].
169167
* @param cardTriggerThreshold See [ReviewReminderCardTriggerThreshold].
170168
* @param scope See [ReviewReminderScope].
171169
* @param enabled Whether the review reminder's notifications are active or disabled.
170+
* @param onlyNotifyIfNoReviews If true, the reminder will not trigger a notification if the deck the review reminder is for has
171+
* already been reviewed at least once today.
172172
*/
173173
@Serializable
174174
@Parcelize
@@ -179,6 +179,7 @@ data class ReviewReminder private constructor(
179179
val cardTriggerThreshold: ReviewReminderCardTriggerThreshold,
180180
val scope: ReviewReminderScope,
181181
var enabled: Boolean,
182+
val onlyNotifyIfNoReviews: Boolean,
182183
) : Parcelable,
183184
ReviewReminderSchema {
184185
companion object {
@@ -192,12 +193,14 @@ data class ReviewReminder private constructor(
192193
cardTriggerThreshold: ReviewReminderCardTriggerThreshold,
193194
scope: ReviewReminderScope = ReviewReminderScope.Global,
194195
enabled: Boolean = true,
196+
onlyNotifyIfNoReviews: Boolean = false,
195197
) = ReviewReminder(
196198
id = ReviewReminderId.getAndIncrementNextFreeReminderId(),
197199
time,
198200
cardTriggerThreshold,
199201
scope,
200202
enabled,
203+
onlyNotifyIfNoReviews,
201204
)
202205
}
203206

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import com.ichi2.anki.IntentHandler
3131
import com.ichi2.anki.R
3232
import com.ichi2.anki.common.annotations.LegacyNotifications
3333
import com.ichi2.anki.libanki.Decks
34+
import com.ichi2.anki.libanki.EpochSeconds
3435
import com.ichi2.anki.preferences.PENDING_NOTIFICATIONS_ONLY
3536
import com.ichi2.anki.preferences.sharedPrefs
3637
import com.ichi2.anki.reviewreminders.ReviewReminder
@@ -43,6 +44,7 @@ import com.ichi2.anki.utils.remainingTime
4344
import com.ichi2.widget.WidgetStatus
4445
import timber.log.Timber
4546
import kotlin.time.Duration
47+
import kotlin.time.Duration.Companion.days
4648
import kotlin.time.Duration.Companion.minutes
4749
import kotlin.time.Duration.Companion.seconds
4850

@@ -98,6 +100,12 @@ class NotificationService : BroadcastReceiver() {
98100
}
99101
}
100102

103+
// Cancel if the user wants notifications to only fire if no reviews have been done today AND there has been a review today
104+
if (reviewReminder.onlyNotifyIfNoReviews && wasScopeReviewedToday(reviewReminder.scope)) {
105+
Timber.d("Aborting notification due to onlyNotifyIfNoReviews")
106+
return
107+
}
108+
101109
val dueCardsCount =
102110
when (reviewReminder.scope) {
103111
is ReviewReminderScope.Global -> withCol { sched.allDecksCounts() }
@@ -217,6 +225,34 @@ class NotificationService : BroadcastReceiver() {
217225
)
218226
}
219227

228+
/**
229+
* Checks if a deck, or any decks, have been reviewed since the latest day cutoff, accomplished by joining the
230+
* cards and revlog tables of the collection database. Used for the "only notify me if no reviews have
231+
* been done today" review reminder feature. Checks for existence rather than counting to increase efficiency.
232+
*/
233+
private suspend fun wasScopeReviewedToday(scope: ReviewReminderScope): Boolean {
234+
val extraWhereClause =
235+
when (scope) {
236+
is ReviewReminderScope.Global -> ""
237+
is ReviewReminderScope.DeckSpecific -> "AND cards.did = ${scope.did}"
238+
}
239+
val queryResult =
240+
withCol {
241+
val startOfToday: EpochSeconds = sched.dayCutoff - 1.days.inWholeSeconds
242+
val query = """
243+
SELECT EXISTS (
244+
SELECT 1
245+
FROM cards
246+
JOIN revlog ON revlog.cid = cards.id
247+
WHERE revlog.id > $startOfToday
248+
$extraWhereClause
249+
)
250+
"""
251+
db.queryScalar(query)
252+
}
253+
return (queryResult == 1)
254+
}
255+
220256
/** The id of the notification for due cards. */
221257
@LegacyNotifications("Each notification will have a unique ID")
222258
private const val WIDGET_NOTIFY_ID = 1

AnkiDroid/src/main/res/layout/add_edit_reminder_dialog.xml

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,7 @@
134134
android:id="@+id/add_edit_reminder_card_threshold_section"
135135
android:layout_width="match_parent"
136136
android:layout_height="wrap_content"
137-
android:orientation="horizontal"
138-
tools:ignore="UselessParent">
137+
android:orientation="horizontal">
139138

140139
<LinearLayout
141140
android:id="@+id/add_edit_reminder_card_threshold_label_container"
@@ -175,6 +174,27 @@
175174

176175
</LinearLayout>
177176

177+
<LinearLayout
178+
android:id="@+id/add_edit_reminder_only_notify_if_no_reviews_section"
179+
android:layout_width="match_parent"
180+
android:layout_height="wrap_content"
181+
android:orientation="horizontal">
182+
183+
<com.google.android.material.checkbox.MaterialCheckBox
184+
android:id="@+id/add_edit_reminder_only_notify_if_no_reviews_checkbox"
185+
android:layout_width="wrap_content"
186+
android:layout_height="wrap_content" />
187+
188+
<TextView
189+
android:id="@+id/add_edit_reminder_only_notify_if_no_reviews_label"
190+
android:layout_width="0dp"
191+
android:layout_height="wrap_content"
192+
android:layout_weight="1"
193+
android:text="Only notify me if no reviews have been done today"
194+
tools:ignore="HardcodedText" />
195+
196+
</LinearLayout>
197+
178198
</LinearLayout>
179199

180200
</LinearLayout>

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

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import android.content.Context
2222
import androidx.core.content.edit
2323
import androidx.test.core.app.ApplicationProvider.getApplicationContext
2424
import androidx.test.ext.junit.runners.AndroidJUnit4
25+
import anki.scheduler.CardAnswer
2526
import com.ichi2.anki.CollectionManager.withCol
2627
import com.ichi2.anki.RobolectricTest
2728
import com.ichi2.anki.reviewreminders.ReviewReminder
@@ -111,6 +112,106 @@ class NotificationServiceTest : RobolectricTest() {
111112
verify(exactly = 0) { notificationManager.notify(any(), any(), any()) }
112113
}
113114

115+
@Test
116+
fun `onReceive with reviews today and onlyNotifyIfNoReviews is true should not fire notification`() =
117+
runTest {
118+
val did1 = addDeck("Deck")
119+
addNotes(1).forEach {
120+
it.firstCard().update { did = did1 }
121+
}
122+
withCol {
123+
decks.select(did1)
124+
sched.answerCard(sched.card!!, CardAnswer.Rating.GOOD)
125+
}
126+
val reviewReminderDeckSpecific =
127+
ReviewReminder.createReviewReminder(
128+
ReviewReminderTime(9, 0),
129+
ReviewReminderCardTriggerThreshold(1),
130+
ReviewReminderScope.DeckSpecific(did1),
131+
onlyNotifyIfNoReviews = true,
132+
)
133+
val reviewReminderAppWide =
134+
ReviewReminder.createReviewReminder(
135+
ReviewReminderTime(9, 0),
136+
ReviewReminderCardTriggerThreshold(1),
137+
ReviewReminderScope.Global,
138+
onlyNotifyIfNoReviews = true,
139+
)
140+
ReviewRemindersDatabase.editRemindersForDeck(did1) { mapOf(ReviewReminderId(0) to reviewReminderDeckSpecific) }
141+
ReviewRemindersDatabase.editAllAppWideReminders { mapOf(ReviewReminderId(1) to reviewReminderAppWide) }
142+
143+
NotificationService.sendReviewReminderNotification(context, reviewReminderDeckSpecific)
144+
NotificationService.sendReviewReminderNotification(context, reviewReminderAppWide)
145+
verify(exactly = 0) { notificationManager.notify(any(), any(), any()) }
146+
}
147+
148+
@Test
149+
fun `onReceive with no reviews today and onlyNotifyIfNoReviews is true should fire notification`() =
150+
runTest {
151+
val did1 = addDeck("Deck")
152+
addNotes(1).forEach {
153+
it.firstCard().update { did = did1 }
154+
}
155+
val reviewReminderDeckSpecific =
156+
ReviewReminder.createReviewReminder(
157+
ReviewReminderTime(9, 0),
158+
ReviewReminderCardTriggerThreshold(1),
159+
ReviewReminderScope.DeckSpecific(did1),
160+
onlyNotifyIfNoReviews = true,
161+
)
162+
val reviewReminderAppWide =
163+
ReviewReminder.createReviewReminder(
164+
ReviewReminderTime(9, 0),
165+
ReviewReminderCardTriggerThreshold(1),
166+
ReviewReminderScope.Global,
167+
onlyNotifyIfNoReviews = true,
168+
)
169+
ReviewRemindersDatabase.editRemindersForDeck(did1) { mapOf(ReviewReminderId(0) to reviewReminderDeckSpecific) }
170+
ReviewRemindersDatabase.editAllAppWideReminders { mapOf(ReviewReminderId(1) to reviewReminderAppWide) }
171+
172+
NotificationService.sendReviewReminderNotification(context, reviewReminderDeckSpecific)
173+
NotificationService.sendReviewReminderNotification(context, reviewReminderAppWide)
174+
verify(
175+
exactly = 1,
176+
) {
177+
notificationManager.notify(
178+
NotificationService.REVIEW_REMINDER_NOTIFICATION_TAG,
179+
reviewReminderDeckSpecific.id.value,
180+
any(),
181+
)
182+
}
183+
verify(
184+
exactly = 1,
185+
) { notificationManager.notify(NotificationService.REVIEW_REMINDER_NOTIFICATION_TAG, reviewReminderAppWide.id.value, any()) }
186+
}
187+
188+
@Test
189+
fun `onReceive with onlyNotifyIfNoReviews is false should always fire notification`() =
190+
runTest {
191+
val did1 = addDeck("Deck")
192+
addNotes(1).forEach {
193+
it.firstCard().update { did = did1 }
194+
}
195+
val reviewReminder =
196+
ReviewReminder.createReviewReminder(
197+
ReviewReminderTime(9, 0),
198+
ReviewReminderCardTriggerThreshold(1),
199+
ReviewReminderScope.DeckSpecific(did1),
200+
onlyNotifyIfNoReviews = false,
201+
)
202+
ReviewRemindersDatabase.editRemindersForDeck(did1) { mapOf(ReviewReminderId(0) to reviewReminder) }
203+
204+
NotificationService.sendReviewReminderNotification(context, reviewReminder)
205+
withCol {
206+
decks.select(did1)
207+
sched.answerCard(sched.card!!, CardAnswer.Rating.GOOD)
208+
}
209+
NotificationService.sendReviewReminderNotification(context, reviewReminder)
210+
verify(
211+
exactly = 2,
212+
) { notificationManager.notify(NotificationService.REVIEW_REMINDER_NOTIFICATION_TAG, reviewReminder.id.value, any()) }
213+
}
214+
114215
@Test
115216
fun `onReceive with happy path for single deck should fire notification`() =
116217
runTest {

0 commit comments

Comments
 (0)