Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -264,6 +266,20 @@ class AddEditReminderDialog : DialogFragment() {
}
}

private fun setUpOnlyNotifyIfNoReviewsCheckbox() {
val contentSection = contentView.findViewById<LinearLayout>(R.id.add_edit_reminder_only_notify_if_no_reviews_section)
val checkbox = contentView.findViewById<MaterialCheckBox>(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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,26 +89,40 @@ class AddEditReminderDialogViewModel(
)
val cardTriggerThreshold: LiveData<Int> = _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<Boolean> = _onlyNotifyIfNoReviews

private val _advancedSettingsOpen = MutableLiveData(INITIAL_ADVANCED_SETTINGS_OPEN)
val advancedSettingsOpen: LiveData<Boolean> = _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)
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,15 +178,14 @@ 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].
* @param scope See [ReviewReminderScope].
* @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
Expand All @@ -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 {
Expand All @@ -208,17 +208,19 @@ 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,
cardTriggerThreshold,
scope,
enabled,
profileID,
onlyNotifyIfNoReviews,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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() }
Expand All @@ -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
}

Expand Down Expand Up @@ -182,7 +190,7 @@ class NotificationService : BroadcastReceiver() {

val manager = context.getSystemService<NotificationManager>()
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")
Expand Down Expand Up @@ -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
Expand Down
33 changes: 27 additions & 6 deletions AnkiDroid/src/main/res/layout/add_edit_reminder_dialog.xml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
android:orientation="horizontal"
android:layout_marginBottom="8dp">

<TextView
<com.ichi2.ui.FixedTextView
android:id="@+id/add_edit_reminder_time_label"
android:layout_width="0dp"
android:layout_height="match_parent"
Expand Down Expand Up @@ -71,7 +71,7 @@
android:layout_height="wrap_content"
android:orientation="horizontal">

<TextView
<com.ichi2.ui.FixedTextView
android:id="@+id/add_edit_reminder_deck_label"
android:layout_width="0dp"
android:layout_height="match_parent"
Expand All @@ -81,7 +81,7 @@
android:textSize="18sp"
tools:ignore="HardcodedText" />

<TextView
<com.ichi2.ui.FixedTextView
android:id="@+id/add_edit_reminder_deck_name"
android:layout_width="0dp"
android:layout_weight="3"
Expand Down Expand Up @@ -119,7 +119,7 @@
android:layout_gravity="center_vertical"
android:layout_marginEnd="8dp" />

<TextView
<com.ichi2.ui.FixedTextView
android:id="@+id/add_edit_reminder_advanced_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
Expand Down Expand Up @@ -152,7 +152,7 @@
android:paddingVertical="4dp"
android:orientation="vertical">

<TextView
<com.ichi2.ui.FixedTextView
android:id="@+id/add_edit_reminder_card_threshold_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
Expand All @@ -161,7 +161,7 @@
android:text="Card threshold:"
tools:ignore="HardcodedText" />

<TextView
<com.ichi2.ui.FixedTextView
android:id="@+id/add_edit_reminder_card_threshold_description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
Expand Down Expand Up @@ -189,6 +189,27 @@

</LinearLayout>

<LinearLayout
android:id="@+id/add_edit_reminder_only_notify_if_no_reviews_section"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">

<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/add_edit_reminder_only_notify_if_no_reviews_checkbox"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />

<com.ichi2.ui.FixedTextView
android:id="@+id/add_edit_reminder_only_notify_if_no_reviews_label"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Only notify me if no reviews have been done today"
tools:ignore="HardcodedText" />

</LinearLayout>

</LinearLayout>

</LinearLayout>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
}
}
Expand Down
Loading