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, + ) }