Skip to content

Commit 9d0e81e

Browse files
committed
Created ReviewRemindersDatabase and ReviewReminder
GSoC 2025: Review Reminders - Created `ReviewRemindersDatabase`, which contains methods for storing review reminder data in SharedPreferences - Created `ReviewReminder`, which defines the review reminder data class - Added `ReviewRemindersDatabaseTest` and `ReviewReminderTest` to test these two classes - Added a new string preference to preferences.xml for saving the next free review reminder ID
1 parent b7cc2ec commit 9d0e81e

File tree

5 files changed

+748
-0
lines changed

5 files changed

+748
-0
lines changed
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/*
2+
* Copyright (c) 2025 Eric Li <[email protected]>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11+
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License along with
14+
* this program. If not, see <http://www.gnu.org/licenses/>.
15+
*/
16+
17+
package com.ichi2.anki.reviewreminders
18+
19+
import com.ichi2.anki.R
20+
import com.ichi2.anki.settings.Prefs
21+
import com.ichi2.libanki.DeckId
22+
import kotlinx.serialization.Serializable
23+
import timber.log.Timber
24+
import java.time.LocalTime
25+
import java.time.format.DateTimeFormatter
26+
import java.time.format.FormatStyle
27+
import java.util.Locale
28+
import kotlin.time.Duration
29+
import kotlin.time.Duration.Companion.minutes
30+
31+
internal typealias ReviewReminderId = Int
32+
33+
/**
34+
* A "review reminder" is a recurring scheduled notification that reminds the user
35+
* to review their Anki cards. Individual instances of a review reminder firing and showing up
36+
* on the user's phone are called "notifications".
37+
*
38+
* Below, a public way of creating review reminders is exposed via a companion object so that
39+
* reminders with invalid IDs are never created. This class is annotated
40+
* with @ConsistentCopyVisibility to ensure copy() is private too and does not leak the constructor.
41+
*
42+
* TODO: add remaining fields planned for GSoC 2025.
43+
*
44+
* @param id Unique, auto-incremented ID of the review reminder.
45+
* @param time See [ReviewReminderTime].
46+
* @param snoozeAmount See [SnoozeAmount].
47+
* @param cardTriggerThreshold If, at the time of the reminder, less than this many cards are due, the notification is not triggered.
48+
* @param did The deck this reminder is associated with, or [APP_WIDE_REMINDER_DECK_ID] if it is an app-wide reminder.
49+
*/
50+
@Serializable
51+
@ConsistentCopyVisibility
52+
data class ReviewReminder private constructor(
53+
val id: ReviewReminderId,
54+
val time: ReviewReminderTime,
55+
val snoozeAmount: SnoozeAmount,
56+
val cardTriggerThreshold: Int,
57+
val did: DeckId,
58+
var enabled: Boolean,
59+
) {
60+
init {
61+
require(cardTriggerThreshold >= 0) { "Card trigger threshold must be >= 0" }
62+
}
63+
64+
/**
65+
* The time of day at which reminders will send a notification.
66+
*/
67+
@Serializable
68+
data class ReviewReminderTime(
69+
val hour: Int,
70+
val minute: Int,
71+
) {
72+
init {
73+
require(hour in 0..23) { "Hour must be between 0 and 23" }
74+
require(minute in 0..59) { "Minute must be between 0 and 59" }
75+
}
76+
77+
override fun toString(): String =
78+
LocalTime
79+
.of(hour, minute)
80+
.format(
81+
DateTimeFormatter
82+
.ofLocalizedTime(FormatStyle.SHORT)
83+
.withLocale(Locale.getDefault()),
84+
)
85+
86+
fun toMinutesFromMidnight(): Int = hour * 60 + minute
87+
}
88+
89+
/**
90+
* Types of snooze behaviour that can be present on notifications sent by review reminders.
91+
*/
92+
@Serializable
93+
sealed class SnoozeAmount {
94+
/**
95+
* The snooze button will never appear on notifications set by this review reminder.
96+
*/
97+
@Serializable
98+
data object Disabled : SnoozeAmount()
99+
100+
/**
101+
* The snooze button will always be available on notifications sent by this review reminder.
102+
*/
103+
@Serializable
104+
data class Infinite(
105+
val timeInterval: Duration,
106+
) : SnoozeAmount() {
107+
init {
108+
require(timeInterval >= 0.minutes) { "Snooze time interval must be >= 0 minutes" }
109+
}
110+
}
111+
112+
/**
113+
* The snooze button can be pressed a maximum amount of times on notifications sent by this review reminder.
114+
* After it has been pressed that many times, the button will no longer appear.
115+
*/
116+
@Serializable
117+
data class SetAmount(
118+
val timeInterval: Duration,
119+
val maxSnoozes: Int,
120+
) : SnoozeAmount() {
121+
init {
122+
require(timeInterval >= 0.minutes) { "Snooze time interval must be >= 0 minutes" }
123+
require(maxSnoozes >= 0) { "Max snoozes must be >= 0" }
124+
}
125+
}
126+
}
127+
128+
companion object {
129+
/**
130+
* The "deck ID" field for review reminders that are app-wide rather than deck-specific.
131+
*/
132+
const val APP_WIDE_REMINDER_DECK_ID = -1L
133+
134+
/**
135+
* IDs start at this value and climb upwards by one each time.
136+
*/
137+
private const val FIRST_REMINDER_ID = 0
138+
139+
/**
140+
* Create a new review reminder. This will allocate a new ID for the reminder.
141+
* @return A new [ReviewReminder] object.
142+
* @see [ReviewReminder]
143+
*/
144+
fun createReviewReminder(
145+
time: ReviewReminderTime,
146+
snoozeAmount: SnoozeAmount,
147+
cardTriggerThreshold: Int,
148+
did: DeckId = APP_WIDE_REMINDER_DECK_ID,
149+
enabled: Boolean = true,
150+
) = ReviewReminder(
151+
id = getAndIncrementNextFreeReminderId(),
152+
time,
153+
snoozeAmount,
154+
cardTriggerThreshold,
155+
did,
156+
enabled,
157+
)
158+
159+
/**
160+
* Get and return the next free reminder ID which can be associated with a new review reminder.
161+
* Also increment the next free reminder ID stored in SharedPreferences.
162+
* Since there are 4 billion IDs available, this should not overflow in practice.
163+
* @return The next free reminder ID.
164+
*/
165+
private fun getAndIncrementNextFreeReminderId(): ReviewReminderId {
166+
val nextFreeId = Prefs.getInt(R.string.review_reminders_next_free_id, FIRST_REMINDER_ID)
167+
Prefs.putInt(R.string.review_reminders_next_free_id, nextFreeId + 1)
168+
Timber.d("Generated next free review reminder ID: $nextFreeId")
169+
return nextFreeId
170+
}
171+
}
172+
}
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
/*
2+
* Copyright (c) 2025 Eric Li <[email protected]>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11+
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License along with
14+
* this program. If not, see <http://www.gnu.org/licenses/>.
15+
*/
16+
17+
package com.ichi2.anki.reviewreminders
18+
19+
import android.content.Context
20+
import androidx.annotation.VisibleForTesting
21+
import androidx.core.content.edit
22+
import com.ichi2.anki.AnkiDroidApp
23+
import com.ichi2.anki.showError
24+
import com.ichi2.libanki.DeckId
25+
import kotlinx.serialization.SerializationException
26+
import kotlinx.serialization.json.Json
27+
28+
/**
29+
* Manages the storage and retrieval of [ReviewReminder]s in SharedPreferences.
30+
*
31+
* [ReviewReminder]s can either be tied to a specific deck and trigger based on the number of cards
32+
* due in that deck, or they can be app-wide reminders that trigger based on the total number
33+
* of cards due across all decks.
34+
*/
35+
class ReviewRemindersDatabase(
36+
val context: Context,
37+
) {
38+
companion object {
39+
/**
40+
* Key in SharedPreferences for retrieving deck-specific reminders.
41+
* Should have deck ID appended to its end, ex. "review_reminders_deck_12345".
42+
* Its value is a HashMap<[ReviewReminderId], [ReviewReminder]> serialized as a JSON String.
43+
*/
44+
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
45+
const val DECK_SPECIFIC_KEY = "review_reminders_deck_"
46+
47+
/**
48+
* Key in SharedPreferences for retrieving app-wide reminders.
49+
* Its value is a HashMap<[ReviewReminderId], [ReviewReminder]> serialized as a JSON String.
50+
*/
51+
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
52+
const val APP_WIDE_KEY = "review_reminders_app_wide"
53+
}
54+
55+
/**
56+
* Decode an encoded HashMap<[ReviewReminderId], [ReviewReminder]> JSON string.
57+
* It is possible for Json.decodeFromString to throw [SerializationException]s if a serialization
58+
* error is encountered, or [IllegalArgumentException]s if the decoded object is not a HashMap<[ReviewReminderId], [ReviewReminder]>.
59+
* @see Json.decodeFromString
60+
*/
61+
private fun decodeJson(jsonString: String): HashMap<ReviewReminderId, ReviewReminder> =
62+
try {
63+
// If, during development, the review reminders storage schema (i.e. ReviewReminder) is modified,
64+
// this class will attempt to read review reminders stored in the old schema via the new schema, causing a SerializationException.
65+
// To delete all review reminder keys in SharedPreferences, uncomment the following line.
66+
// AnkiDroidApp.sharedPrefs().edit { AnkiDroidApp.sharedPrefs().all.keys.filter { it.startsWith("review_reminders_") }.forEach { remove(it) } }
67+
68+
Json.decodeFromString<HashMap<ReviewReminderId, ReviewReminder>>(jsonString)
69+
} catch (e: SerializationException) {
70+
showError(
71+
context,
72+
"Something went wrong. A serialization error was encountered while retrieving review reminders.",
73+
)
74+
hashMapOf()
75+
} catch (e: IllegalArgumentException) {
76+
showError(
77+
context,
78+
"Something went wrong. An unexpected data type was read while retrieving review reminders.",
79+
)
80+
hashMapOf()
81+
}
82+
83+
/**
84+
* Encode a Map<[ReviewReminderId], [ReviewReminder]> as a JSON string.
85+
* it is possible for Json.encodeToString to throw [SerializationException]s if a serialization
86+
* error is encountered.
87+
* @see Json.encodeToString
88+
*/
89+
private fun encodeJson(reminders: Map<ReviewReminderId, ReviewReminder>): String =
90+
try {
91+
Json.encodeToString(reminders)
92+
} catch (e: SerializationException) {
93+
showError(
94+
context,
95+
"Something went wrong. A serialization error was encountered while saving review reminders.",
96+
)
97+
"{}"
98+
}
99+
100+
/**
101+
* Get the [ReviewReminder]s for a specific key.
102+
*/
103+
private fun getRemindersForKey(key: String): HashMap<ReviewReminderId, ReviewReminder> {
104+
val jsonString = AnkiDroidApp.sharedPrefs().getString(key, null) ?: return hashMapOf()
105+
return decodeJson(jsonString)
106+
}
107+
108+
/**
109+
* Get the [ReviewReminder]s for a specific deck.
110+
*/
111+
fun getRemindersForDeck(did: DeckId): HashMap<ReviewReminderId, ReviewReminder> = getRemindersForKey(DECK_SPECIFIC_KEY + did)
112+
113+
/**
114+
* Get the app-wide [ReviewReminder]s.
115+
*/
116+
fun getAllAppWideReminders(): HashMap<ReviewReminderId, ReviewReminder> = getRemindersForKey(APP_WIDE_KEY)
117+
118+
/**
119+
* Get all [ReviewReminder]s that are associated with a specific deck, grouped by deck ID.
120+
*/
121+
private fun getAllDeckSpecificRemindersGrouped(): Map<DeckId, HashMap<ReviewReminderId, ReviewReminder>> {
122+
return AnkiDroidApp
123+
.sharedPrefs()
124+
.all
125+
.filterKeys { it.startsWith(DECK_SPECIFIC_KEY) }
126+
.mapNotNull { (key, value) ->
127+
val did = key.removePrefix(DECK_SPECIFIC_KEY).toLongOrNull() ?: return@mapNotNull null
128+
val reminders = decodeJson(value.toString())
129+
did to reminders
130+
}.toMap()
131+
}
132+
133+
/**
134+
* Get all [ReviewReminder]s that are associated with a specific deck, all in a single flattened map.
135+
*/
136+
fun getAllDeckSpecificReminders(): HashMap<ReviewReminderId, ReviewReminder> = getAllDeckSpecificRemindersGrouped().flatten()
137+
138+
/**
139+
* Edit the [ReviewReminder]s for a specific key.
140+
* @param key
141+
* @param reminderEditor A lambda that takes the current map and returns the updated map.
142+
*/
143+
private fun editRemindersForKey(
144+
key: String,
145+
reminderEditor: (HashMap<ReviewReminderId, ReviewReminder>) -> Map<ReviewReminderId, ReviewReminder>,
146+
) {
147+
val existingReminders = getRemindersForKey(key)
148+
val updatedReminders = reminderEditor(existingReminders)
149+
AnkiDroidApp.sharedPrefs().edit {
150+
putString(key, encodeJson(updatedReminders))
151+
}
152+
}
153+
154+
/**
155+
* Edit the [ReviewReminder]s for a specific deck.
156+
* @param did
157+
* @param reminderEditor A lambda that takes the current map and returns the updated map.
158+
*/
159+
fun editRemindersForDeck(
160+
did: DeckId,
161+
reminderEditor: (HashMap<ReviewReminderId, ReviewReminder>) -> Map<ReviewReminderId, ReviewReminder>,
162+
) = editRemindersForKey(DECK_SPECIFIC_KEY + did, reminderEditor)
163+
164+
/**
165+
* Edit the app-wide [ReviewReminder]s.
166+
* @param reminderEditor A lambda that takes the current map and returns the updated map.
167+
*/
168+
fun editAllAppWideReminders(reminderEditor: (HashMap<ReviewReminderId, ReviewReminder>) -> Map<ReviewReminderId, ReviewReminder>) =
169+
editRemindersForKey(APP_WIDE_KEY, reminderEditor)
170+
171+
/**
172+
* Edit all [ReviewReminder]s that are associated with a specific deck by operating on a single mutable map.
173+
*/
174+
fun editAllDeckSpecificReminders(reminderEditor: (HashMap<ReviewReminderId, ReviewReminder>) -> Map<ReviewReminderId, ReviewReminder>) {
175+
val existingRemindersGrouped = getAllDeckSpecificRemindersGrouped()
176+
val existingRemindersFlattened = existingRemindersGrouped.flatten()
177+
178+
val updatedRemindersFlattened = reminderEditor(existingRemindersFlattened)
179+
val updatedRemindersGrouped = updatedRemindersFlattened.groupByDeckId()
180+
181+
val existingKeys = existingRemindersGrouped.keys.map { DECK_SPECIFIC_KEY + it }
182+
183+
AnkiDroidApp.sharedPrefs().edit {
184+
// Clear existing review reminder keys in SharedPreferences
185+
existingKeys.forEach { remove(it) }
186+
// Add the updated ones back in
187+
updatedRemindersGrouped.forEach { (did, reminders) ->
188+
putString(DECK_SPECIFIC_KEY + did, encodeJson(reminders))
189+
}
190+
}
191+
}
192+
193+
/**
194+
* Utility function for flattening maps of [ReviewReminder]s grouped by deck ID into a single map.
195+
*/
196+
private fun Map<DeckId, HashMap<ReviewReminderId, ReviewReminder>>.flatten(): HashMap<ReviewReminderId, ReviewReminder> =
197+
hashMapOf<ReviewReminderId, ReviewReminder>().apply {
198+
this@flatten.forEach { (_, reminders) ->
199+
putAll(reminders)
200+
}
201+
}
202+
203+
/**
204+
* Utility function for grouping maps of [ReviewReminder]s by deck ID.
205+
*/
206+
private fun Map<ReviewReminderId, ReviewReminder>.groupByDeckId(): Map<DeckId, HashMap<ReviewReminderId, ReviewReminder>> =
207+
hashMapOf<DeckId, HashMap<ReviewReminderId, ReviewReminder>>().apply {
208+
this@groupByDeckId.forEach { (id, reminder) ->
209+
getOrPut(reminder.did) { hashMapOf() }[id] = reminder
210+
}
211+
}
212+
}

AnkiDroid/src/main/res/values/preferences.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@
193193
<string name="pref_notifications_blink_key">widgetBlink</string>
194194
<!-- Review reminders -->
195195
<string name="pref_review_reminders_screen_key">reviewRemindersScreen</string>
196+
<string name="review_reminders_next_free_id">reviewRemindersNextFreeId</string>
196197
<!-- Developer options -->
197198
<string name="pref_dev_options_screen_key">devOptionsKey</string>
198199
<string name="pref_trigger_crash_key">trigger_crash_preference</string>

0 commit comments

Comments
 (0)