Skip to content

Commit cb8bb87

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
1 parent 3155af4 commit cb8bb87

File tree

4 files changed

+417
-0
lines changed

4 files changed

+417
-0
lines changed
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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.core.content.edit
21+
import com.ichi2.anki.preferences.sharedPrefs
22+
import com.ichi2.libanki.DeckId
23+
import kotlinx.serialization.Serializable
24+
25+
/**
26+
* A "review reminder" is a recurring scheduled notification that reminds the user
27+
* to review their Anki cards. Individual instances of a review reminder firing and showing up
28+
* on the user's phone are called "notifications".
29+
*
30+
* Below, a public way of creating review reminders is exposed via a companion object so that
31+
* reminders with invalid IDs are never created. This class is annotated
32+
* with @ConsistentCopyVisibility to ensure copy() is private too and does not leak the constructor.
33+
*
34+
* TODO: add remaining fields planned for GSoC 2025.
35+
*
36+
* @param id Unique, auto-incremented ID of the review reminder.
37+
* @param type See [ReviewReminderTypes].
38+
* @param hour
39+
* @param minute
40+
* @param cardTriggerThreshold If, at the time of the reminder, less than this many cards are due, the notification is not triggered
41+
* @param did The deck this reminder is associated with, or -1 if it is an app-wide reminder.
42+
*/
43+
@Serializable
44+
@ConsistentCopyVisibility
45+
data class ReviewReminder private constructor(
46+
val id: Int,
47+
val type: ReviewReminderTypes,
48+
val hour: Int,
49+
val minute: Int,
50+
val cardTriggerThreshold: Int,
51+
val did: DeckId,
52+
) {
53+
init {
54+
require(hour in 0..23) { "Hour must be between 0 and 23" }
55+
require(minute in 0..59) { "Minute must be between 0 and 59" }
56+
require(cardTriggerThreshold >= 0) { "Card trigger threshold must be >= 0" }
57+
}
58+
59+
companion object {
60+
/**
61+
* IDs start at this value and climb upwards by one each time.
62+
*/
63+
private const val FIRST_REMINDER_ID = 0
64+
65+
/**
66+
* Key in SharedPreferences for the next free reminder ID, with its value as an Int.
67+
*/
68+
private const val NEXT_FREE_ID_KEY = "review_reminders_next_free_id"
69+
70+
/**
71+
* Create a new review reminder. This will allocate a new ID for the reminder.
72+
* @return A new [ReviewReminder] object.
73+
* @see [ReviewReminder]
74+
*/
75+
fun createReviewReminder(
76+
context: Context,
77+
type: ReviewReminderTypes,
78+
hour: Int,
79+
minute: Int,
80+
cardTriggerThreshold: Int,
81+
did: DeckId = -1L,
82+
) = ReviewReminder(
83+
id = getAndIncrementNextFreeReminderId(context),
84+
type = type,
85+
hour = hour,
86+
minute = minute,
87+
cardTriggerThreshold = cardTriggerThreshold,
88+
did = did,
89+
)
90+
91+
/**
92+
* Get and return the next free reminder ID which can be associated with a new review reminder.
93+
* Also increment the next free reminder ID stored in SharedPreferences.
94+
* Since there are 4 billion IDs available, this should not overflow in practice.
95+
*/
96+
private fun getAndIncrementNextFreeReminderId(context: Context): Int {
97+
val nextFreeId =
98+
context.sharedPrefs().getInt(
99+
NEXT_FREE_ID_KEY,
100+
FIRST_REMINDER_ID,
101+
)
102+
context.sharedPrefs().edit {
103+
putInt(NEXT_FREE_ID_KEY, nextFreeId + 1)
104+
}
105+
return nextFreeId
106+
}
107+
}
108+
}
109+
110+
/**
111+
* Types of [ReviewReminder]s. Possibly extensible in the future.
112+
*
113+
* - [SINGLE]: Sends a notification once a day at a specific time.
114+
* - [PERSISTENT]: Sends a notification repeatedly between a start and end time every day.
115+
*/
116+
enum class ReviewReminderTypes {
117+
SINGLE,
118+
PERSISTENT,
119+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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.core.content.edit
21+
import com.ichi2.anki.preferences.sharedPrefs
22+
import com.ichi2.libanki.DeckId
23+
import kotlinx.serialization.json.Json
24+
25+
/**
26+
* Manages the storage and retrieval of [ReviewReminder]s in SharedPreferences.
27+
*
28+
* [ReviewReminder]s can either be tied to a specific deck and trigger based on the number of cards
29+
* due in that deck, or they can be app-wide reminders that trigger based on the total number
30+
* of cards due across all decks.
31+
*/
32+
class ReviewRemindersDatabase(
33+
val context: Context,
34+
) {
35+
companion object {
36+
/**
37+
* Key in SharedPreferences for retrieving deck-specific reminders.
38+
* Should have deck ID appended to its end, ex. "review_reminders_deck_12345".
39+
* Its value is a MutableList<[ReviewReminder]> serialized as a JSON String.
40+
*/
41+
private const val DECK_SPECIFIC_KEY = "review_reminders_deck_"
42+
43+
/**
44+
* Key in SharedPreferences for retrieving app-wide reminders.
45+
* Its value is a MutableList<[ReviewReminder]> serialized as a JSON String.
46+
*/
47+
private const val APP_WIDE_KEY = "review_reminders_app_wide"
48+
}
49+
50+
/**
51+
* Get the [ReviewReminder]s for a specific key.
52+
*/
53+
private fun getRemindersForKey(key: String): MutableList<ReviewReminder> {
54+
val jsonString = context.sharedPrefs().getString(key, null) ?: return mutableListOf()
55+
return Json.decodeFromString<MutableList<ReviewReminder>>(jsonString)
56+
}
57+
58+
/**
59+
* Get the [ReviewReminder]s for a specific deck.
60+
*/
61+
fun getRemindersForDeck(did: DeckId): MutableList<ReviewReminder> = getRemindersForKey(DECK_SPECIFIC_KEY + did)
62+
63+
/**
64+
* Get the app-wide [ReviewReminder]s.
65+
*/
66+
fun getAllAppWideReminders(): MutableList<ReviewReminder> = getRemindersForKey(APP_WIDE_KEY)
67+
68+
/**
69+
* Get all [ReviewReminder]s that are associated with a specific deck, all in a single mutable list.
70+
*/
71+
fun getAllDeckSpecificReminders(): MutableList<ReviewReminder> {
72+
val allReminders = mutableListOf<ReviewReminder>()
73+
context.sharedPrefs().all.forEach { (key, value) ->
74+
if (key.startsWith(DECK_SPECIFIC_KEY)) {
75+
val reminders = Json.decodeFromString<MutableList<ReviewReminder>>(value.toString())
76+
allReminders.addAll(reminders)
77+
}
78+
}
79+
return allReminders
80+
}
81+
82+
/**
83+
* Edit the [ReviewReminder]s for a specific key.
84+
* @param key
85+
* @param reminderEditor A lambda that takes the current list and returns the updated list.
86+
*/
87+
private fun editRemindersForKey(
88+
key: String,
89+
reminderEditor: (MutableList<ReviewReminder>) -> MutableList<ReviewReminder>,
90+
) {
91+
val reminders = getRemindersForKey(key)
92+
val updatedReminders = reminderEditor(reminders)
93+
context.sharedPrefs().edit {
94+
putString(key, Json.encodeToString(updatedReminders))
95+
}
96+
}
97+
98+
/**
99+
* Edit the [ReviewReminder]s for a specific deck.
100+
* @param did
101+
* @param reminderEditor A lambda that takes the current list and returns the updated list.
102+
*/
103+
fun editRemindersForDeck(
104+
did: DeckId,
105+
reminderEditor: (MutableList<ReviewReminder>) -> MutableList<ReviewReminder>,
106+
) = editRemindersForKey(DECK_SPECIFIC_KEY + did, reminderEditor)
107+
108+
/**
109+
* Edit the app-wide [ReviewReminder]s.
110+
* @param reminderEditor A lambda that takes the current list and returns the updated list.
111+
*/
112+
fun editAllAppWideReminders(reminderEditor: (MutableList<ReviewReminder>) -> MutableList<ReviewReminder>) =
113+
editRemindersForKey(APP_WIDE_KEY, reminderEditor)
114+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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.app.Activity
20+
import androidx.core.content.edit
21+
import androidx.test.ext.junit.runners.AndroidJUnit4
22+
import com.ichi2.anki.AnkiActivity
23+
import com.ichi2.anki.RobolectricTest
24+
import com.ichi2.anki.preferences.sharedPrefs
25+
import org.junit.After
26+
import org.junit.Before
27+
import org.junit.Test
28+
import org.junit.runner.RunWith
29+
30+
@RunWith(AndroidJUnit4::class)
31+
class ReviewReminderTest : RobolectricTest() {
32+
private lateinit var activity: Activity
33+
34+
@Before
35+
override fun setUp() {
36+
super.setUp()
37+
// We need a valid context to allocate reminder IDs, any valid context will do
38+
activity = startRegularActivity<AnkiActivity>()
39+
}
40+
41+
@After
42+
override fun tearDown() {
43+
super.tearDown()
44+
// Reset the database after each test
45+
activity.sharedPrefs().edit {
46+
clear()
47+
}
48+
}
49+
50+
@Test
51+
fun `getAndIncrementNextFreeReminderId should increment IDs correctly`() {
52+
for (i in 0..10) {
53+
val reminder = ReviewReminder.createReviewReminder(activity, ReviewReminderTypes.SINGLE, 12, 30, 5)
54+
assert(reminder.id == i)
55+
}
56+
}
57+
}

0 commit comments

Comments
 (0)