diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt
index e427093c179d..de12018f880a 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt
@@ -36,6 +36,7 @@ import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import com.google.android.material.button.MaterialButton
+import com.google.android.material.checkbox.MaterialCheckBox
import com.google.android.material.textfield.TextInputLayout
import com.google.android.material.timepicker.MaterialTimePicker
import com.google.android.material.timepicker.TimeFormat
@@ -134,6 +135,7 @@ class AddEditReminderDialog : DialogFragment() {
setInitialDeckSelection()
setUpAdvancedDropdown()
setUpCardThresholdInput()
+ setUpOnlyNotifyIfNoReviewsCheckbox()
// For getting the result of the deck selection sub-dialog from ScheduleReminders
// See ScheduleReminders.onDeckSelected for more information
@@ -264,6 +266,20 @@ class AddEditReminderDialog : DialogFragment() {
}
}
+ private fun setUpOnlyNotifyIfNoReviewsCheckbox() {
+ val contentSection = contentView.findViewById(R.id.add_edit_reminder_only_notify_if_no_reviews_section)
+ val checkbox = contentView.findViewById(R.id.add_edit_reminder_only_notify_if_no_reviews_checkbox)
+ contentSection.setOnClickListener {
+ viewModel.toggleOnlyNotifyIfNoReviews()
+ }
+ checkbox.setOnClickListener {
+ viewModel.toggleOnlyNotifyIfNoReviews()
+ }
+ viewModel.onlyNotifyIfNoReviews.observe(this) { onlyNotifyIfNoReviews ->
+ checkbox.isChecked = onlyNotifyIfNoReviews
+ }
+ }
+
/**
* Show the time picker dialog for selecting a time with a given hour and minute.
* Does not automatically dismiss the old dialog.
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialogViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialogViewModel.kt
index 3a97ce54938a..9a1c035f37fe 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialogViewModel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialogViewModel.kt
@@ -89,26 +89,40 @@ class AddEditReminderDialogViewModel(
)
val cardTriggerThreshold: LiveData = _cardTriggerThreshold
+ private val _onlyNotifyIfNoReviews =
+ MutableLiveData(
+ when (dialogMode) {
+ is AddEditReminderDialog.DialogMode.Add -> INITIAL_ONLY_NOTIFY_IF_NO_REVIEWS
+ is AddEditReminderDialog.DialogMode.Edit -> dialogMode.reminderToBeEdited.onlyNotifyIfNoReviews
+ },
+ )
+ val onlyNotifyIfNoReviews: LiveData = _onlyNotifyIfNoReviews
+
private val _advancedSettingsOpen = MutableLiveData(INITIAL_ADVANCED_SETTINGS_OPEN)
val advancedSettingsOpen: LiveData = _advancedSettingsOpen
fun setTime(time: ReviewReminderTime) {
- Timber.d("Updated time to %s", time)
+ Timber.i("Updated time to %s", time)
_time.value = time
}
fun setDeckSelected(deckId: DeckId) {
- Timber.d("Updated deck selected to %s", deckId)
+ Timber.i("Updated deck selected to %s", deckId)
_deckSelected.value = deckId
}
fun setCardTriggerThreshold(threshold: Int) {
- Timber.d("Updated card trigger threshold to %s", threshold)
+ Timber.i("Updated card trigger threshold to %s", threshold)
_cardTriggerThreshold.value = threshold
}
+ fun toggleOnlyNotifyIfNoReviews() {
+ Timber.i("Toggled onlyNotifyIfNoReviews from %s", _onlyNotifyIfNoReviews.value)
+ _onlyNotifyIfNoReviews.value = !(_onlyNotifyIfNoReviews.value ?: false)
+ }
+
fun toggleAdvancedSettingsOpen() {
- Timber.d("Toggled advanced settings open from %s", _advancedSettingsOpen.value)
+ Timber.i("Toggled advanced settings open from %s", _advancedSettingsOpen.value)
_advancedSettingsOpen.value = !(_advancedSettingsOpen.value ?: false)
}
@@ -136,6 +150,7 @@ class AddEditReminderDialogViewModel(
is AddEditReminderDialog.DialogMode.Add -> true
is AddEditReminderDialog.DialogMode.Edit -> dialogMode.reminderToBeEdited.enabled
},
+ onlyNotifyIfNoReviews = onlyNotifyIfNoReviews.value ?: INITIAL_ONLY_NOTIFY_IF_NO_REVIEWS,
)
companion object {
@@ -148,6 +163,13 @@ class AddEditReminderDialogViewModel(
*/
private const val INITIAL_CARD_THRESHOLD: Int = 1
+ /**
+ * The default value for whether a notification should only be fired if no reviews have been done today
+ * for the corresponding deck / all decks. Since this is set to false, the default behaviour is that
+ * notifications will always be sent, regardless of whether reviews have been done today.
+ */
+ private const val INITIAL_ONLY_NOTIFY_IF_NO_REVIEWS = false
+
/**
* Whether the advanced settings dropdown is initially open.
* We start with it closed to avoid overwhelming the user.
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReviewReminder.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReviewReminder.kt
index c3b13f30c4ae..0bf200171768 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReviewReminder.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReviewReminder.kt
@@ -178,8 +178,6 @@ sealed class ReviewReminderScope : Parcelable {
* Preferably, also add some unit tests to ensure your migration works properly on all user devices once your update is rolled out.
* See ReviewRemindersDatabaseTest for examples on how to do this.
*
- * TODO: add remaining fields planned for GSoC 2025.
- *
* @param id Unique, auto-incremented ID of the review reminder.
* @param time See [ReviewReminderTime].
* @param cardTriggerThreshold See [ReviewReminderCardTriggerThreshold].
@@ -187,6 +185,7 @@ sealed class ReviewReminderScope : Parcelable {
* @param enabled Whether the review reminder's notifications are active or disabled.
* @param profileID ID representing the profile which created this review reminder, as review reminders for
* multiple profiles might be active simultaneously.
+ * @param onlyNotifyIfNoReviews If true, only notify the user if this scope has not been reviewed today yet.
*/
@Serializable
@Parcelize
@@ -198,6 +197,7 @@ data class ReviewReminder private constructor(
val scope: ReviewReminderScope,
var enabled: Boolean,
val profileID: String,
+ val onlyNotifyIfNoReviews: Boolean,
) : Parcelable,
ReviewReminderSchema {
companion object {
@@ -208,10 +208,11 @@ data class ReviewReminder private constructor(
*/
fun createReviewReminder(
time: ReviewReminderTime,
- cardTriggerThreshold: ReviewReminderCardTriggerThreshold,
+ cardTriggerThreshold: ReviewReminderCardTriggerThreshold = ReviewReminderCardTriggerThreshold(0),
scope: ReviewReminderScope = ReviewReminderScope.Global,
enabled: Boolean = true,
profileID: String = "",
+ onlyNotifyIfNoReviews: Boolean = false,
) = ReviewReminder(
id = ReviewReminderId.getAndIncrementNextFreeReminderId(),
time,
@@ -219,6 +220,7 @@ data class ReviewReminder private constructor(
scope,
enabled,
profileID,
+ onlyNotifyIfNoReviews,
)
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt b/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt
index ebe2acc443d1..ae094e1a43f8 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt
@@ -32,6 +32,7 @@ import com.ichi2.anki.R
import com.ichi2.anki.canUserAccessDeck
import com.ichi2.anki.common.annotations.LegacyNotifications
import com.ichi2.anki.libanki.Decks
+import com.ichi2.anki.libanki.EpochMilliseconds
import com.ichi2.anki.preferences.PENDING_NOTIFICATIONS_ONLY
import com.ichi2.anki.preferences.sharedPrefs
import com.ichi2.anki.reviewreminders.ReviewReminder
@@ -44,6 +45,7 @@ import com.ichi2.widget.WidgetStatus
import net.ankiweb.rsdroid.BackendException
import timber.log.Timber
import kotlin.time.Duration
+import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
@@ -94,6 +96,12 @@ class NotificationService : BroadcastReceiver() {
}
}
+ // Cancel if the user wants notifications to only fire if no reviews have been done today AND there has been a review today
+ if (reviewReminder.onlyNotifyIfNoReviews && wasScopeReviewedToday(reviewReminder.scope)) {
+ Timber.i("Aborting notification due to onlyNotifyIfNoReviews")
+ return
+ }
+
val dueCardsCount =
when (reviewReminder.scope) {
is ReviewReminderScope.Global -> withCol { sched.allDecksCounts() }
@@ -105,7 +113,7 @@ class NotificationService : BroadcastReceiver() {
}
val dueCardsTotal = dueCardsCount.count()
if (dueCardsTotal < reviewReminder.cardTriggerThreshold.threshold) {
- Timber.d("Aborting notification due to threshold: $dueCardsTotal < ${reviewReminder.cardTriggerThreshold.threshold}")
+ Timber.i("Aborting notification due to threshold: $dueCardsTotal < ${reviewReminder.cardTriggerThreshold.threshold}")
return
}
@@ -182,7 +190,7 @@ class NotificationService : BroadcastReceiver() {
val manager = context.getSystemService()
if (manager != null) {
- Timber.d("Sending notification with ID ${reviewReminder.id.value}")
+ Timber.i("Sending notification with ID ${reviewReminder.id.value}")
manager.notify(REVIEW_REMINDER_NOTIFICATION_TAG, reviewReminder.id.value, builder.build())
} else {
Timber.w("Failed to get NotificationManager system service, aborting review reminder notification")
@@ -215,6 +223,49 @@ class NotificationService : BroadcastReceiver() {
)
}
+ /**
+ * Checks if a deck, or any decks, have been reviewed since the latest day cutoff.
+ * Used for the "only notify me if no reviews have been done today" review reminder feature.
+ */
+ private suspend fun wasScopeReviewedToday(scope: ReviewReminderScope): Boolean {
+ // Handles the global and deck-specific scope cases separately to avoid the need for string concatenation,
+ // thus protecting against SQL injection. Checks for existence rather than counting to increase efficiency.
+ val queryResult =
+ when (scope) {
+ is ReviewReminderScope.Global ->
+ withCol {
+ val startOfToday: EpochMilliseconds = sched.dayCutoff * 1000 - 1.days.inWholeMilliseconds
+ // For each card in the user's collection, retrieve and JOIN information about its review history
+ // Then check if there exists at least one card with a review log entry after the start of today
+ val query = """
+ SELECT EXISTS (
+ SELECT 1
+ FROM cards
+ JOIN revlog ON revlog.cid = cards.id
+ WHERE revlog.id > ?
+ )
+ """
+ db.queryScalar(query, startOfToday)
+ }
+ is ReviewReminderScope.DeckSpecific ->
+ withCol {
+ val startOfToday: EpochMilliseconds = sched.dayCutoff * 1000 - 1.days.inWholeMilliseconds
+ // Essentially the same as above, but only check through cards with a deck ID matching the provided scope
+ val query = """
+ SELECT EXISTS (
+ SELECT 1
+ FROM cards
+ JOIN revlog ON revlog.cid = cards.id
+ WHERE revlog.id > ?
+ AND cards.did = ?
+ )
+ """
+ db.queryScalar(query, startOfToday, scope.did)
+ }
+ }
+ return (queryResult == 1)
+ }
+
/** The id of the notification for due cards. */
@LegacyNotifications("Each notification will have a unique ID")
private const val WIDGET_NOTIFY_ID = 1
diff --git a/AnkiDroid/src/main/res/layout/add_edit_reminder_dialog.xml b/AnkiDroid/src/main/res/layout/add_edit_reminder_dialog.xml
index d17dff33e870..c89ce0bec7dd 100644
--- a/AnkiDroid/src/main/res/layout/add_edit_reminder_dialog.xml
+++ b/AnkiDroid/src/main/res/layout/add_edit_reminder_dialog.xml
@@ -42,7 +42,7 @@
android:orientation="horizontal"
android:layout_marginBottom="8dp">
-
-
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReviewReminderTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReviewReminderTest.kt
index 83cd3da471b8..cfa8551c65e6 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReviewReminderTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReviewReminderTest.kt
@@ -43,12 +43,7 @@ class ReviewReminderTest : RobolectricTest() {
@Test
fun `getAndIncrementNextFreeReminderId should increment IDs correctly`() {
for (i in 0..10) {
- val reminder =
- ReviewReminder.createReviewReminder(
- ReviewReminderTime(12, 30),
- ReviewReminderCardTriggerThreshold(0),
- ReviewReminderScope.DeckSpecific(5),
- )
+ val reminder = ReviewReminder.createReviewReminder(time = ReviewReminderTime(hour = i, minute = i))
assertThat(reminder.id, equalTo(ReviewReminderId(i)))
}
}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/services/AlarmManagerServiceTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/services/AlarmManagerServiceTest.kt
index aaa9796bfc1c..4e5bd96d08e8 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/services/AlarmManagerServiceTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/services/AlarmManagerServiceTest.kt
@@ -31,9 +31,7 @@ import com.ichi2.anki.RobolectricTest
import com.ichi2.anki.common.time.MockTime
import com.ichi2.anki.common.time.TimeManager
import com.ichi2.anki.reviewreminders.ReviewReminder
-import com.ichi2.anki.reviewreminders.ReviewReminderCardTriggerThreshold
import com.ichi2.anki.reviewreminders.ReviewReminderId
-import com.ichi2.anki.reviewreminders.ReviewReminderScope
import com.ichi2.anki.reviewreminders.ReviewReminderTime
import com.ichi2.anki.reviewreminders.ReviewRemindersDatabase
import io.mockk.every
@@ -67,13 +65,7 @@ class AlarmManagerServiceTest : RobolectricTest() {
notificationManager = mockk(relaxed = true)
every { context.getSystemService() } returns alarmManager
every { context.getSystemService() } returns notificationManager
- reviewReminder =
- ReviewReminder.createReviewReminder(
- time = ReviewReminderTime(20, 0),
- cardTriggerThreshold = ReviewReminderCardTriggerThreshold(0),
- scope = ReviewReminderScope.Global,
- enabled = true,
- )
+ reviewReminder = ReviewReminder.createReviewReminder(time = ReviewReminderTime(20, 0))
TimeManager.resetWith(mockTime)
ReviewRemindersDatabase.remindersSharedPrefs.edit { clear() }
}
@@ -106,12 +98,7 @@ class AlarmManagerServiceTest : RobolectricTest() {
@Test
fun `scheduleReviewReminderNotifications for past time calls AlarmManager setWindow with future time`() {
val pastTimeReviewReminder =
- ReviewReminder.createReviewReminder(
- time = ReviewReminderTime(3, 0),
- cardTriggerThreshold = ReviewReminderCardTriggerThreshold(0),
- scope = ReviewReminderScope.Global,
- enabled = true,
- )
+ ReviewReminder.createReviewReminder(time = ReviewReminderTime(3, 0))
val expectedSchedulingTime = mockTime.calendar().clone() as Calendar
expectedSchedulingTime.apply {
set(Calendar.HOUR_OF_DAY, 3)
@@ -151,32 +138,12 @@ class AlarmManagerServiceTest : RobolectricTest() {
runTest {
val did1 = addDeck("Deck1")
val did2 = addDeck("Deck2")
- val reminder1 =
- ReviewReminder.createReviewReminder(
- time = ReviewReminderTime(9, 0),
- cardTriggerThreshold = ReviewReminderCardTriggerThreshold(0),
- scope = ReviewReminderScope.DeckSpecific(did1),
- enabled = true,
- )
- val reminder2 =
- ReviewReminder.createReviewReminder(
- time = ReviewReminderTime(10, 0),
- cardTriggerThreshold = ReviewReminderCardTriggerThreshold(0),
- scope = ReviewReminderScope.DeckSpecific(did2),
- enabled = true,
- )
- val reminder3 =
- ReviewReminder.createReviewReminder(
- time = ReviewReminderTime(11, 0),
- cardTriggerThreshold = ReviewReminderCardTriggerThreshold(0),
- scope = ReviewReminderScope.Global,
- enabled = true,
- )
+ val reminder1 = ReviewReminder.createReviewReminder(time = ReviewReminderTime(9, 0))
+ val reminder2 = ReviewReminder.createReviewReminder(time = ReviewReminderTime(10, 0))
+ val reminder3 = ReviewReminder.createReviewReminder(time = ReviewReminderTime(11, 0))
val disabledReminder =
ReviewReminder.createReviewReminder(
time = ReviewReminderTime(11, 0),
- cardTriggerThreshold = ReviewReminderCardTriggerThreshold(0),
- scope = ReviewReminderScope.Global,
enabled = false,
)
ReviewRemindersDatabase.editRemindersForDeck(did1) { mapOf(ReviewReminderId(0) to reminder1) }
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/services/NotificationServiceTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/services/NotificationServiceTest.kt
index 487317aeb797..f0791f601f1f 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/services/NotificationServiceTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/services/NotificationServiceTest.kt
@@ -22,8 +22,12 @@ import android.content.Context
import androidx.core.content.edit
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.ext.junit.runners.AndroidJUnit4
+import anki.scheduler.CardAnswer
import com.ichi2.anki.CollectionManager
import com.ichi2.anki.RobolectricTest
+import com.ichi2.anki.common.time.MockTime
+import com.ichi2.anki.common.time.TimeManager
+import com.ichi2.anki.libanki.DeckId
import com.ichi2.anki.reviewreminders.ReviewReminder
import com.ichi2.anki.reviewreminders.ReviewReminderCardTriggerThreshold
import com.ichi2.anki.reviewreminders.ReviewReminderId
@@ -44,15 +48,22 @@ import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
+import kotlin.time.Duration.Companion.days
@RunWith(AndroidJUnit4::class)
class NotificationServiceTest : RobolectricTest() {
+ companion object {
+ private val yesterday = MockTime(TimeManager.time.intTimeMS() - 1.days.inWholeMilliseconds)
+ private val today = MockTime(TimeManager.time.intTimeMS())
+ }
+
private lateinit var context: Context
private lateinit var notificationManager: NotificationManager
@Before
override fun setUp() {
super.setUp()
+ TimeManager.resetWith(today)
context = spyk(getApplicationContext())
notificationManager = mockk(relaxed = true)
every { context.getSystemService(Context.NOTIFICATION_SERVICE) } returns notificationManager
@@ -69,57 +80,62 @@ class NotificationServiceTest : RobolectricTest() {
ReviewRemindersDatabase.remindersSharedPrefs.edit { clear() }
}
+ private fun createAndSaveDummyDeckSpecificReminder(did: DeckId): ReviewReminder {
+ val reviewReminder = createTestReminder(deckId = did, thresholdInt = 1)
+ ReviewRemindersDatabase.editRemindersForDeck(did) { mapOf(ReviewReminderId(0) to reviewReminder) }
+ return reviewReminder
+ }
+
+ private fun createAndSaveDummyAppWideReminder(): ReviewReminder {
+ val reviewReminder = createTestReminder(thresholdInt = 1)
+ ReviewRemindersDatabase.editAllAppWideReminders { mapOf(ReviewReminderId(1) to reviewReminder) }
+ return reviewReminder
+ }
+
+ private fun triggerDummyReminderNotification(reviewReminder: ReviewReminder) {
+ val intent =
+ NotificationService.getIntent(
+ context,
+ reviewReminder,
+ NotificationService.NotificationServiceAction.ScheduleRecurringNotifications,
+ )
+ NotificationService().onReceive(context, intent)
+ }
+
@Test
fun `onReceive with less cards than card threshold should not fire notification but schedule next`() =
runTest {
- val did1 = addDeck("Deck")
+ val did1 = addDeck("Deck", setAsSelected = true)
addNotes(2).forEach {
it.firstCard().update { did = did1 }
}
- val reviewReminder =
- ReviewReminder.createReviewReminder(
- ReviewReminderTime(9, 0),
- ReviewReminderCardTriggerThreshold(3),
- ReviewReminderScope.DeckSpecific(did1),
- )
- ReviewRemindersDatabase.editRemindersForDeck(did1) { mapOf(ReviewReminderId(0) to reviewReminder) }
+ val reviewReminderDeckSpecific = createTestReminder(deckId = did1, thresholdInt = 3)
+ val reviewReminderAppWide = createTestReminder(thresholdInt = 3)
+ ReviewRemindersDatabase.editRemindersForDeck(did1) { mapOf(ReviewReminderId(0) to reviewReminderDeckSpecific) }
+ ReviewRemindersDatabase.editAllAppWideReminders { mapOf(ReviewReminderId(1) to reviewReminderAppWide) }
- val intent =
- NotificationService.getIntent(
- context,
- reviewReminder,
- NotificationService.NotificationServiceAction.ScheduleRecurringNotifications,
- )
- NotificationService().onReceive(context, intent)
+ triggerDummyReminderNotification(reviewReminderDeckSpecific)
+ triggerDummyReminderNotification(reviewReminderAppWide)
verify(exactly = 0) { notificationManager.notify(any(), any(), any()) }
verify(
exactly = 1,
- ) { AlarmManagerService.scheduleReviewReminderNotification(context, reviewReminder) }
+ ) { AlarmManagerService.scheduleReviewReminderNotification(context, reviewReminderDeckSpecific) }
+ verify(
+ exactly = 1,
+ ) { AlarmManagerService.scheduleReviewReminderNotification(context, reviewReminderAppWide) }
}
@Test
fun `onReceive with happy path for single deck should fire notification and schedule next`() =
runTest {
- val did1 = addDeck("Deck")
+ val did1 = addDeck("Deck", setAsSelected = true)
addNotes(2).forEach {
it.firstCard().update { did = did1 }
}
- val reviewReminder =
- ReviewReminder.createReviewReminder(
- ReviewReminderTime(9, 0),
- ReviewReminderCardTriggerThreshold(1),
- ReviewReminderScope.DeckSpecific(did1),
- )
- ReviewRemindersDatabase.editRemindersForDeck(did1) { mapOf(ReviewReminderId(0) to reviewReminder) }
+ val reviewReminder = createAndSaveDummyDeckSpecificReminder(did1)
- val intent =
- NotificationService.getIntent(
- context,
- reviewReminder,
- NotificationService.NotificationServiceAction.ScheduleRecurringNotifications,
- )
- NotificationService().onReceive(context, intent)
+ triggerDummyReminderNotification(reviewReminder)
verify(
exactly = 1,
@@ -140,20 +156,10 @@ class NotificationServiceTest : RobolectricTest() {
addNotes(2).forEach {
it.firstCard().update { did = did2 }
}
- val reviewReminder =
- ReviewReminder.createReviewReminder(
- ReviewReminderTime(9, 0),
- ReviewReminderCardTriggerThreshold(4),
- ReviewReminderScope.Global,
- )
+ val reviewReminder = createTestReminder(thresholdInt = 4)
+
+ triggerDummyReminderNotification(reviewReminder)
- val intent =
- NotificationService.getIntent(
- context,
- reviewReminder,
- NotificationService.NotificationServiceAction.ScheduleRecurringNotifications,
- )
- NotificationService().onReceive(context, intent)
verify(
exactly = 1,
) { notificationManager.notify(NotificationService.REVIEW_REMINDER_NOTIFICATION_TAG, reviewReminder.id.value, any()) }
@@ -165,20 +171,9 @@ class NotificationServiceTest : RobolectricTest() {
@Test
fun `onReceive with non-existent deck should not fire notification but schedule next`() =
runTest {
- val reviewReminder =
- ReviewReminder.createReviewReminder(
- ReviewReminderTime(9, 0),
- ReviewReminderCardTriggerThreshold(1),
- ReviewReminderScope.DeckSpecific(9999),
- )
+ val reviewReminder = createTestReminder(deckId = 9999)
- val intent =
- NotificationService.getIntent(
- context,
- reviewReminder,
- NotificationService.NotificationServiceAction.ScheduleRecurringNotifications,
- )
- NotificationService().onReceive(context, intent)
+ triggerDummyReminderNotification(reviewReminder)
verify(exactly = 0) { notificationManager.notify(any(), any(), any()) }
verify(
@@ -186,63 +181,175 @@ class NotificationServiceTest : RobolectricTest() {
) { AlarmManagerService.scheduleReviewReminderNotification(context, reviewReminder) }
}
+ @Test
+ fun `onReceive with reviews today and onlyNotifyIfNoReviews is true should not fire notification`() =
+ runTest {
+ val did1 = addDeck("Deck", setAsSelected = true)
+ addNotes(1).forEach {
+ it.firstCard().update { did = did1 }
+ }
+ col.sched.answerCard(col.sched.card!!, CardAnswer.Rating.GOOD)
+ val reviewReminderDeckSpecific = createTestReminder(deckId = did1, thresholdInt = 1, onlyNotifyIfNoReviews = true)
+ val reviewReminderAppWide = createTestReminder(thresholdInt = 1, onlyNotifyIfNoReviews = true)
+ ReviewRemindersDatabase.editRemindersForDeck(did1) { mapOf(ReviewReminderId(0) to reviewReminderDeckSpecific) }
+ ReviewRemindersDatabase.editAllAppWideReminders { mapOf(ReviewReminderId(1) to reviewReminderAppWide) }
+
+ triggerDummyReminderNotification(reviewReminderDeckSpecific)
+ triggerDummyReminderNotification(reviewReminderAppWide)
+ verify(exactly = 0) { notificationManager.notify(any(), any(), any()) }
+ }
+
+ @Test
+ fun `onReceive with no reviews ever and onlyNotifyIfNoReviews is true should fire notification`() =
+ runTest {
+ val did1 = addDeck("Deck", setAsSelected = true)
+ addNotes(1).forEach {
+ it.firstCard().update { did = did1 }
+ }
+ val reviewReminderDeckSpecific = createTestReminder(deckId = did1, thresholdInt = 1, onlyNotifyIfNoReviews = true)
+ val reviewReminderAppWide = createTestReminder(thresholdInt = 1, onlyNotifyIfNoReviews = true)
+ ReviewRemindersDatabase.editRemindersForDeck(did1) { mapOf(ReviewReminderId(0) to reviewReminderDeckSpecific) }
+ ReviewRemindersDatabase.editAllAppWideReminders { mapOf(ReviewReminderId(1) to reviewReminderAppWide) }
+
+ triggerDummyReminderNotification(reviewReminderDeckSpecific)
+ triggerDummyReminderNotification(reviewReminderAppWide)
+ verify(
+ exactly = 1,
+ ) {
+ notificationManager.notify(
+ NotificationService.REVIEW_REMINDER_NOTIFICATION_TAG,
+ reviewReminderDeckSpecific.id.value,
+ any(),
+ )
+ }
+ verify(
+ exactly = 1,
+ ) { notificationManager.notify(NotificationService.REVIEW_REMINDER_NOTIFICATION_TAG, reviewReminderAppWide.id.value, any()) }
+ }
+
+ @Test
+ fun `onReceive with review yesterday but none today and onlyNotifyIfNoReviews is true should fire notification`() =
+ runTest {
+ TimeManager.resetWith(yesterday) // Wind back time and perform the review
+ val did1 = addDeck("Deck", setAsSelected = true)
+ addNotes(1).forEach {
+ it.firstCard().update { did = did1 }
+ }
+ col.sched.answerCard(col.sched.card!!, CardAnswer.Rating.GOOD)
+ TimeManager.resetWith(today) // Reset time to present
+
+ val reviewReminderDeckSpecific = createTestReminder(deckId = did1, thresholdInt = 1, onlyNotifyIfNoReviews = true)
+ val reviewReminderAppWide = createTestReminder(thresholdInt = 1, onlyNotifyIfNoReviews = true)
+ ReviewRemindersDatabase.editRemindersForDeck(did1) { mapOf(ReviewReminderId(0) to reviewReminderDeckSpecific) }
+ ReviewRemindersDatabase.editAllAppWideReminders { mapOf(ReviewReminderId(1) to reviewReminderAppWide) }
+
+ triggerDummyReminderNotification(reviewReminderDeckSpecific)
+ triggerDummyReminderNotification(reviewReminderAppWide)
+ verify(
+ exactly = 1,
+ ) {
+ notificationManager.notify(
+ NotificationService.REVIEW_REMINDER_NOTIFICATION_TAG,
+ reviewReminderDeckSpecific.id.value,
+ any(),
+ )
+ }
+ verify(
+ exactly = 1,
+ ) { notificationManager.notify(NotificationService.REVIEW_REMINDER_NOTIFICATION_TAG, reviewReminderAppWide.id.value, any()) }
+ }
+
+ @Test
+ fun `onReceive with onlyNotifyIfNoReviews is false should always fire notification`() =
+ runTest {
+ val did1 = addDeck("Deck", setAsSelected = true)
+ addNotes(1).forEach {
+ it.firstCard().update { did = did1 }
+ }
+ val reviewReminderDeckSpecific = createAndSaveDummyDeckSpecificReminder(did1)
+ val reviewReminderAppWide = createAndSaveDummyAppWideReminder()
+
+ triggerDummyReminderNotification(reviewReminderDeckSpecific)
+ triggerDummyReminderNotification(reviewReminderAppWide)
+ col.sched.answerCard(col.sched.card!!, CardAnswer.Rating.GOOD)
+ triggerDummyReminderNotification(reviewReminderDeckSpecific)
+ triggerDummyReminderNotification(reviewReminderAppWide)
+
+ verify(
+ exactly = 2,
+ ) {
+ notificationManager.notify(
+ NotificationService.REVIEW_REMINDER_NOTIFICATION_TAG,
+ reviewReminderDeckSpecific.id.value,
+ any(),
+ )
+ }
+ verify(
+ exactly = 2,
+ ) { notificationManager.notify(NotificationService.REVIEW_REMINDER_NOTIFICATION_TAG, reviewReminderAppWide.id.value, any()) }
+ }
+
@Test
fun `onReceive with blocked collection should not fire notification but schedule next`() =
runTest {
- val did1 = addDeck("Deck")
+ val did1 = addDeck("Deck", setAsSelected = true)
addNotes(2).forEach {
it.firstCard().update { did = did1 }
}
- val reviewReminder =
- ReviewReminder.createReviewReminder(
- ReviewReminderTime(9, 0),
- ReviewReminderCardTriggerThreshold(1),
- ReviewReminderScope.DeckSpecific(did1),
- )
- ReviewRemindersDatabase.editRemindersForDeck(did1) { mapOf(ReviewReminderId(0) to reviewReminder) }
+ val reviewReminderDeckSpecific = createAndSaveDummyDeckSpecificReminder(did1)
+ val reviewReminderAppWide = createAndSaveDummyAppWideReminder()
CollectionManager.emulatedOpenFailure = CollectionManager.CollectionOpenFailure.LOCKED
- val intent =
- NotificationService.getIntent(
- context,
- reviewReminder,
- NotificationService.NotificationServiceAction.ScheduleRecurringNotifications,
- )
- NotificationService().onReceive(context, intent)
+ triggerDummyReminderNotification(reviewReminderDeckSpecific)
+ triggerDummyReminderNotification(reviewReminderAppWide)
verify(exactly = 0) { notificationManager.notify(any(), any(), any()) }
verify(
exactly = 1,
- ) { AlarmManagerService.scheduleReviewReminderNotification(context, reviewReminder) }
+ ) { AlarmManagerService.scheduleReviewReminderNotification(context, reviewReminderDeckSpecific) }
+ verify(
+ exactly = 1,
+ ) { AlarmManagerService.scheduleReviewReminderNotification(context, reviewReminderAppWide) }
}
@Test
fun `onReceive with snoozed notification should fire notification but not schedule next`() =
runTest {
- val did1 = addDeck("Deck")
+ val did1 = addDeck("Deck", setAsSelected = true)
addNotes(2).forEach {
it.firstCard().update { did = did1 }
}
- val reviewReminder =
- ReviewReminder.createReviewReminder(
- ReviewReminderTime(9, 0),
- ReviewReminderCardTriggerThreshold(1),
- ReviewReminderScope.DeckSpecific(did1),
- )
- ReviewRemindersDatabase.editRemindersForDeck(did1) { mapOf(ReviewReminderId(0) to reviewReminder) }
+ val reviewReminderDeckSpecific = createAndSaveDummyDeckSpecificReminder(did1)
+ val reviewReminderAppWide = createAndSaveDummyAppWideReminder()
- val intent =
+ val intentDeckSpecific =
+ NotificationService.getIntent(
+ context,
+ reviewReminderDeckSpecific,
+ NotificationService.NotificationServiceAction.SnoozeNotification,
+ )
+ val intentAppWide =
NotificationService.getIntent(
context,
- reviewReminder,
+ reviewReminderAppWide,
NotificationService.NotificationServiceAction.SnoozeNotification,
)
- NotificationService().onReceive(context, intent)
+ NotificationService().onReceive(context, intentDeckSpecific)
+ NotificationService().onReceive(context, intentAppWide)
verify(
exactly = 1,
- ) { notificationManager.notify(NotificationService.REVIEW_REMINDER_NOTIFICATION_TAG, reviewReminder.id.value, any()) }
- verify(exactly = 0) { AlarmManagerService.scheduleReviewReminderNotification(context, reviewReminder) }
+ ) {
+ notificationManager.notify(
+ NotificationService.REVIEW_REMINDER_NOTIFICATION_TAG,
+ reviewReminderDeckSpecific.id.value,
+ any(),
+ )
+ }
+ verify(
+ exactly = 1,
+ ) { notificationManager.notify(NotificationService.REVIEW_REMINDER_NOTIFICATION_TAG, reviewReminderAppWide.id.value, any()) }
+ verify(exactly = 0) { AlarmManagerService.scheduleReviewReminderNotification(context, any()) }
}
@Test
@@ -256,18 +363,8 @@ class NotificationServiceTest : RobolectricTest() {
addNotes(2).forEach {
it.firstCard().update { did = did2 }
}
- val reviewReminderOne =
- ReviewReminder.createReviewReminder(
- ReviewReminderTime(9, 0),
- ReviewReminderCardTriggerThreshold(1),
- ReviewReminderScope.DeckSpecific(did1),
- )
- val reviewReminderTwo =
- ReviewReminder.createReviewReminder(
- ReviewReminderTime(9, 0),
- ReviewReminderCardTriggerThreshold(1),
- ReviewReminderScope.DeckSpecific(did2),
- )
+ val reviewReminderOne = createTestReminder(deckId = did1, thresholdInt = 1)
+ val reviewReminderTwo = createTestReminder(deckId = did2, thresholdInt = 1)
ReviewRemindersDatabase.editRemindersForDeck(did1) { mapOf(ReviewReminderId(0) to reviewReminderOne) }
ReviewRemindersDatabase.editRemindersForDeck(did2) { mapOf(ReviewReminderId(1) to reviewReminderTwo) }
@@ -318,4 +415,22 @@ class NotificationServiceTest : RobolectricTest() {
)
assertThat(snoozeIntents.size, equalTo(4))
}
+
+ /**
+ * Helper method for creating a review reminder to minimize verbosity in this file.
+ *
+ * @param deckId If specified, the reminder will be deck-specific to this deck ID. If null, it will be app-wide.
+ * @param thresholdInt The card trigger threshold as an integer.
+ * @param onlyNotifyIfNoReviews Whether the reminder should only notify if there are no reviews today.
+ */
+ private fun createTestReminder(
+ deckId: DeckId? = null,
+ thresholdInt: Int = 1,
+ onlyNotifyIfNoReviews: Boolean = false,
+ ) = ReviewReminder.createReviewReminder(
+ time = ReviewReminderTime(hour = 12, minute = 0),
+ cardTriggerThreshold = ReviewReminderCardTriggerThreshold(thresholdInt),
+ scope = if (deckId != null) ReviewReminderScope.DeckSpecific(deckId) else ReviewReminderScope.Global,
+ onlyNotifyIfNoReviews = onlyNotifyIfNoReviews,
+ )
}