Skip to content

Commit 1f79657

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 bafdcd6 commit 1f79657

File tree

4 files changed

+365
-0
lines changed

4 files changed

+365
-0
lines changed
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 kotlinx.serialization.Serializable
23+
24+
/**
25+
* A "review reminder" is a recurring scheduled notification that reminds the user
26+
* to review their Anki cards. Individual instances of a review reminder firing and showing up
27+
* on the user's phone are called "notifications".
28+
*
29+
* Below, a public way of creating review reminders is exposed via a companion object so that
30+
* reminders with invalid IDs are never created. This class is annotated
31+
* with @ConsistentCopyVisibility to ensure copy() is private too and does not leak the constructor.
32+
*
33+
* TODO: add remaining fields planned for GSoC 2025.
34+
*
35+
* @param id Unique, auto-incremented ID of the review reminder.
36+
* @param type See [ReviewReminderTypes].
37+
* @param hour
38+
* @param minute
39+
* @param cardTriggerThreshold If, at the time of the reminder, less than this many cards are due, the notification is not triggered
40+
*/
41+
@Serializable
42+
@ConsistentCopyVisibility
43+
data class ReviewReminder private constructor(
44+
val id: Int,
45+
val type: ReviewReminderTypes,
46+
val hour: Int,
47+
val minute: Int,
48+
val cardTriggerThreshold: Int,
49+
) {
50+
init {
51+
require(hour in 0..23) { "Hour must be between 0 and 23" }
52+
require(minute in 0..59) { "Minute must be between 0 and 59" }
53+
require(cardTriggerThreshold >= 0) { "Card trigger threshold must be >= 0" }
54+
}
55+
56+
companion object {
57+
/**
58+
* IDs start at this value and climb upwards by one each time.
59+
*/
60+
private const val FIRST_REMINDER_ID = 0
61+
62+
/**
63+
* Key in SharedPreferences for the next free reminder ID, with its value as an Int.
64+
*/
65+
private const val DATASTORE_NEXT_FREE_ID_KEY = "next_free_id"
66+
67+
/**
68+
* Create a new review reminder. This will allocate a new ID for the reminder.
69+
* @return A new [ReviewReminder] object.
70+
* @see [ReviewReminder]
71+
*/
72+
fun createReviewReminder(
73+
context: Context,
74+
type: ReviewReminderTypes,
75+
hour: Int,
76+
minute: Int,
77+
cardTriggerThreshold: Int,
78+
) = ReviewReminder(
79+
id = getAndIncrementNextFreeReminderId(context),
80+
type = type,
81+
hour = hour,
82+
minute = minute,
83+
cardTriggerThreshold = cardTriggerThreshold,
84+
)
85+
86+
/**
87+
* Get and return the next free reminder ID which can be associated with a new review reminder.
88+
* Also increment the next free reminder ID stored in SharedPreferences.
89+
* Since there are 4 billion IDs available, this should not overflow in practice.
90+
*/
91+
private fun getAndIncrementNextFreeReminderId(context: Context): Int {
92+
val nextFreeId =
93+
context.sharedPrefs().getInt(
94+
DATASTORE_NEXT_FREE_ID_KEY,
95+
FIRST_REMINDER_ID,
96+
)
97+
context.sharedPrefs().edit {
98+
putInt(DATASTORE_NEXT_FREE_ID_KEY, nextFreeId + 1)
99+
}
100+
return nextFreeId
101+
}
102+
}
103+
}
104+
105+
/**
106+
* Types of [ReviewReminder]s. Possibly extensible in the future.
107+
*
108+
* - [SINGLE]: Sends a notification once a day at a specific time.
109+
* - [PERSISTENT]: Sends a notification repeatedly between a start and end time every day.
110+
*/
111+
enum class ReviewReminderTypes {
112+
SINGLE,
113+
PERSISTENT,
114+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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+
* Its value is a MutableList<[ReviewReminder]> serialized as a JSON String.
39+
*/
40+
private const val DATASTORE_DECK_SPECIFIC_KEY = "deck_%s"
41+
42+
/**
43+
* Key in SharedPreferences for retrieving app-wide reminders.
44+
* Its value is a MutableList<[ReviewReminder]> serialized as a JSON String.
45+
*/
46+
private const val DATASTORE_APP_WIDE_KEY = "app_wide"
47+
}
48+
49+
/**
50+
* Get the [ReviewReminder]s for a specific key.
51+
*/
52+
private fun getRemindersForKey(key: String): MutableList<ReviewReminder> {
53+
val jsonString = context.sharedPrefs().getString(key, null) ?: return mutableListOf()
54+
return Json.decodeFromString<MutableList<ReviewReminder>>(jsonString)
55+
}
56+
57+
/**
58+
* Get the [ReviewReminder]s for a specific deck.
59+
*/
60+
fun getRemindersForDeck(did: DeckId): List<ReviewReminder> = getRemindersForKey(String.format(DATASTORE_DECK_SPECIFIC_KEY, did))
61+
62+
/**
63+
* Get the app-wide [ReviewReminder]s.
64+
*/
65+
fun getAppWideReminders(): List<ReviewReminder> = getRemindersForKey(DATASTORE_APP_WIDE_KEY)
66+
67+
/**
68+
* Edit the [ReviewReminder]s for a specific key.
69+
* @param key
70+
* @param reminderEditor A lambda that takes the current list and returns the updated list.
71+
*/
72+
private fun editRemindersForKey(
73+
key: String,
74+
reminderEditor: (MutableList<ReviewReminder>) -> MutableList<ReviewReminder>,
75+
) {
76+
val reminders = getRemindersForKey(key)
77+
val updatedReminders = reminderEditor(reminders)
78+
context.sharedPrefs().edit {
79+
putString(key, Json.encodeToString(updatedReminders))
80+
}
81+
}
82+
83+
/**
84+
* Edit the [ReviewReminder]s for a specific deck.
85+
* @param did
86+
* @param reminderEditor A lambda that takes the current list and returns the updated list.
87+
*/
88+
fun editRemindersForDeck(
89+
did: DeckId,
90+
reminderEditor: (MutableList<ReviewReminder>) -> MutableList<ReviewReminder>,
91+
) = editRemindersForKey(String.format(DATASTORE_DECK_SPECIFIC_KEY, did), reminderEditor)
92+
93+
/**
94+
* Edit the app-wide [ReviewReminder]s.
95+
* @param reminderEditor A lambda that takes the current list and returns the updated list.
96+
*/
97+
fun editAppWideReminders(reminderEditor: (MutableList<ReviewReminder>) -> MutableList<ReviewReminder>) =
98+
editRemindersForKey(DATASTORE_APP_WIDE_KEY, reminderEditor)
99+
}
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+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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 ReviewRemindersDatabaseTest : RobolectricTest() {
32+
private lateinit var activity: Activity
33+
private lateinit var reviewRemindersDatabase: ReviewRemindersDatabase
34+
35+
@Before
36+
override fun setUp() {
37+
super.setUp()
38+
// We need a valid context for the database, any valid context will do
39+
activity = startRegularActivity<AnkiActivity>()
40+
reviewRemindersDatabase = ReviewRemindersDatabase(activity)
41+
}
42+
43+
@After
44+
override fun tearDown() {
45+
super.tearDown()
46+
// Reset the database after each test
47+
activity.sharedPrefs().edit {
48+
clear()
49+
}
50+
}
51+
52+
@Test
53+
fun `getRemindersForDeck should return empty list when no reminders exist`() {
54+
val did = 12345L
55+
val reminders = reviewRemindersDatabase.getRemindersForDeck(did)
56+
assert(reminders.isEmpty())
57+
}
58+
59+
@Test
60+
fun `editRemindersForDeck and getRemindersForDeck should read and write reminders correctly`() {
61+
val did = 12345L
62+
val newReminders =
63+
listOf(
64+
ReviewReminder.createReviewReminder(activity, ReviewReminderTypes.SINGLE, 9, 0, 5),
65+
ReviewReminder.createReviewReminder(activity, ReviewReminderTypes.PERSISTENT, 10, 30, 10),
66+
)
67+
reviewRemindersDatabase.editRemindersForDeck(did) { reminders ->
68+
reminders.addAll(newReminders)
69+
reminders
70+
}
71+
val storedReminders = reviewRemindersDatabase.getRemindersForDeck(did)
72+
assert(storedReminders == newReminders)
73+
}
74+
75+
@Test
76+
fun `getAppWideReminders should return empty list when no reminders exist`() {
77+
val reminders = reviewRemindersDatabase.getAppWideReminders()
78+
assert(reminders.isEmpty())
79+
}
80+
81+
@Test
82+
fun `setAppWideReminders and getAppWideReminders should read and write reminders correctly`() {
83+
val newReminders =
84+
listOf(
85+
ReviewReminder.createReviewReminder(activity, ReviewReminderTypes.SINGLE, 9, 0, 5),
86+
ReviewReminder.createReviewReminder(activity, ReviewReminderTypes.PERSISTENT, 10, 30, 10),
87+
)
88+
reviewRemindersDatabase.editAppWideReminders { reminders ->
89+
reminders.addAll(newReminders)
90+
reminders
91+
}
92+
val storedReminders = reviewRemindersDatabase.getAppWideReminders()
93+
assert(storedReminders == newReminders)
94+
}
95+
}

0 commit comments

Comments
 (0)