-
-
Notifications
You must be signed in to change notification settings - Fork 2.7k
feat(reminders): database overhaul #20325
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
07049a3
3a4b44b
80bf2f0
16e85ab
2e2345a
d23b368
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,119 @@ | ||||||||||
| /* | ||||||||||
| * Copyright (c) 2026 Eric Li <ericli3690@gmail.com> | ||||||||||
| * | ||||||||||
| * This program is free software; you can redistribute it and/or modify it under | ||||||||||
| * the terms of the GNU General Public License as published by the Free Software | ||||||||||
| * Foundation; either version 3 of the License, or (at your option) any later | ||||||||||
| * version. | ||||||||||
| * | ||||||||||
| * This program is distributed in the hope that it will be useful, but WITHOUT ANY | ||||||||||
| * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A | ||||||||||
| * PARTICULAR PURPOSE. See the GNU General Public License for more details. | ||||||||||
| * | ||||||||||
| * You should have received a copy of the GNU General Public License along with | ||||||||||
| * this program. If not, see <http://www.gnu.org/licenses/>. | ||||||||||
| */ | ||||||||||
|
|
||||||||||
| package com.ichi2.anki.reviewreminders | ||||||||||
|
|
||||||||||
| import kotlinx.serialization.SerializationException | ||||||||||
| import kotlinx.serialization.json.Json | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * A group of review reminders, all for the same [ReviewReminderScope], | ||||||||||
| * persisted as a single JSON string in SharedPreferences. | ||||||||||
| * | ||||||||||
| * Essentially a wrapper around a HashMap of [ReviewReminderId] to [ReviewReminder], | ||||||||||
| * explicitly defined to restrict what can be done with the interface and to eliminate the | ||||||||||
| * need to verbosely type out "HashMap<ReviewReminderId, ReviewReminder>" everywhere. | ||||||||||
| * | ||||||||||
| * A HashMap is used to allow for O(1) access to individual reminders by [ReviewReminderId]. | ||||||||||
| */ | ||||||||||
| class ReviewReminderGroup( | ||||||||||
| private val underlyingMap: HashMap<ReviewReminderId, ReviewReminder>, | ||||||||||
| ) { | ||||||||||
| constructor() : this(HashMap()) | ||||||||||
|
|
||||||||||
| constructor(map: Map<ReviewReminderId, ReviewReminder>) : this(HashMap(map)) | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * Manually construct a [ReviewReminderGroup] from key-value pairs. | ||||||||||
| */ | ||||||||||
| constructor(vararg pairs: Pair<ReviewReminderId, ReviewReminder>) : this( | ||||||||||
| buildMap { pairs.forEach { put(it.first, it.second) } }, | ||||||||||
| ) | ||||||||||
|
Comment on lines
+42
to
+44
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think...
Suggested change
|
||||||||||
|
|
||||||||||
| /** | ||||||||||
| * Merge multiple [ReviewReminderGroup]s into one. | ||||||||||
| * [ReviewReminderId] should be unique, so there should be no collisions. | ||||||||||
| */ | ||||||||||
| constructor(vararg groups: ReviewReminderGroup) : this( | ||||||||||
| buildMap { groups.forEach { putAll(it.underlyingMap) } }, | ||||||||||
| ) | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * Constructs a [ReviewReminderGroup] from a serialized JSON string. | ||||||||||
| * | ||||||||||
| * @throws SerializationException If the stored string is not a valid JSON string. | ||||||||||
| * @throws IllegalArgumentException If the decoded reminders map is not a HashMap<[ReviewReminderId], [ReviewReminder]>. | ||||||||||
| */ | ||||||||||
| constructor(serializedString: String) : this( | ||||||||||
| Json.decodeFromString<HashMap<ReviewReminderId, ReviewReminder>>(serializedString), | ||||||||||
| ) | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * Serializes this [ReviewReminderGroup] to a JSON string for storage. | ||||||||||
| */ | ||||||||||
| fun serializeToString(): String = Json.encodeToString(underlyingMap) | ||||||||||
|
Comment on lines
+54
to
+67
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Doesn't this have the same problem that if the version changes, then the serialization fails? Maybe define this as a factory method instead of a constructor |
||||||||||
|
|
||||||||||
| /** | ||||||||||
| * Merge two [ReviewReminderGroup]s into one. | ||||||||||
| * [ReviewReminderId] should be unique, so there should be no collisions. | ||||||||||
| */ | ||||||||||
| operator fun plus(other: ReviewReminderGroup): ReviewReminderGroup = ReviewReminderGroup(underlyingMap + other.underlyingMap) | ||||||||||
|
|
||||||||||
| operator fun get(id: ReviewReminderId) = underlyingMap[id] | ||||||||||
|
|
||||||||||
| operator fun set( | ||||||||||
| id: ReviewReminderId, | ||||||||||
| reminder: ReviewReminder, | ||||||||||
| ) { | ||||||||||
| underlyingMap[id] = reminder | ||||||||||
| } | ||||||||||
|
|
||||||||||
| fun isEmpty(): Boolean = underlyingMap.isEmpty() | ||||||||||
|
|
||||||||||
| fun remove(id: ReviewReminderId) { | ||||||||||
| underlyingMap.remove(id) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| fun forEach(action: (ReviewReminderId, ReviewReminder) -> Unit) { | ||||||||||
| underlyingMap.forEach { (id, reminder) -> action(id, reminder) } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * Get the values of the underlying map, removing the [ReviewReminderId] keys. | ||||||||||
| */ | ||||||||||
| fun getRemindersList(): List<ReviewReminder> = underlyingMap.values.toList() | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * Toggles whether a [ReviewReminder] is enabled. | ||||||||||
| */ | ||||||||||
| fun toggleEnabled(id: ReviewReminderId) { | ||||||||||
| underlyingMap[id]?.apply { enabled = !enabled } | ||||||||||
| } | ||||||||||
|
Comment on lines
+76
to
+104
|
||||||||||
|
|
||||||||||
| override fun equals(other: Any?): Boolean = other is ReviewReminderGroup && other.underlyingMap == this.underlyingMap | ||||||||||
|
|
||||||||||
| override fun hashCode(): Int = underlyingMap.hashCode() | ||||||||||
| } | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * Convenience extension constructor for merging a list of [ReviewReminderGroup]s into one. | ||||||||||
| */ | ||||||||||
| fun List<ReviewReminderGroup>.mergeAll() = ReviewReminderGroup(*this.toTypedArray()) | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * Convenience typealias for the mutation functions passed to editors of [ReviewReminderGroup]. | ||||||||||
| */ | ||||||||||
| typealias ReviewReminderGroupEditor = (ReviewReminderGroup) -> ReviewReminderGroup | ||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I normally have these at the top of the file - I may be mistaken here |
||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,184 @@ | ||
| /* | ||
| * Copyright (c) 2026 Eric Li <ericli3690@gmail.com> | ||
| * | ||
| * This program is free software; you can redistribute it and/or modify it under | ||
| * the terms of the GNU General Public License as published by the Free Software | ||
| * Foundation; either version 3 of the License, or (at your option) any later | ||
| * version. | ||
| * | ||
| * This program is distributed in the hope that it will be useful, but WITHOUT ANY | ||
| * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A | ||
| * PARTICULAR PURPOSE. See the GNU General Public License for more details. | ||
| * | ||
| * You should have received a copy of the GNU General Public License along with | ||
| * this program. If not, see <http://www.gnu.org/licenses/>. | ||
| */ | ||
|
|
||
| package com.ichi2.anki.reviewreminders | ||
|
|
||
| import com.ichi2.anki.libanki.DeckId | ||
| import com.ichi2.anki.reviewreminders.ReviewRemindersDatabase.StoredReviewReminderGroup | ||
| import kotlinx.serialization.Serializable | ||
|
|
||
| /** | ||
| * Inline value class for review reminder schema versions. | ||
| * @see [StoredReviewReminderGroup] | ||
| * @see [ReviewReminder] | ||
| */ | ||
| @JvmInline | ||
| @Serializable | ||
| value class ReviewReminderSchemaVersion( | ||
| val value: Int, | ||
| ) { | ||
| init { | ||
| require(value >= 1) { "Review reminder schema version must be >= 1" } | ||
| // We do not check that it is <= SCHEMA_VERSION here because then declaring SCHEMA_VERSION would be circular | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * When [ReviewReminder] is updated by a developer, implement this interface in a new data class which | ||
| * has the same fields as the old version of [ReviewReminder], then implement the [migrate] method which | ||
| * transforms old [ReviewReminder]s to new [ReviewReminder]s. Also ensure that the previous [ReviewReminderSchema] | ||
| * in the migration version chain ([ReviewRemindersDatabase.oldReviewReminderSchemasForMigration]) has its [migrate] method | ||
| * edited to return instances of the newly-created [ReviewReminderSchema]. Then, increment [ReviewRemindersDatabase.schemaVersion]. | ||
| * | ||
| * Data classes implementing this interface should be marked as @Serializable. Any new types defined for ReviewReminderSchemas | ||
| * should also be marked as @Serializable. | ||
| * | ||
| * @see [ReviewRemindersDatabase.performSchemaMigration]. | ||
| * @see [ReviewReminder] | ||
| */ | ||
| @Serializable | ||
| sealed interface ReviewReminderSchema { | ||
| /** | ||
| * All review reminders must have an identifying ID. | ||
| * This is necessary to facilitate migrations. See the implementation of [ReviewRemindersDatabase.performSchemaMigration] for details. | ||
| */ | ||
| val id: ReviewReminderId | ||
|
|
||
| /** | ||
| * Transforms this [ReviewReminderSchema] to the next version of the [ReviewReminderSchema]. | ||
| */ | ||
| fun migrate(): ReviewReminderSchema | ||
| } | ||
|
|
||
| /** | ||
| * Version 1 of [ReviewReminderSchema]. Updated to Version 2 by adding [ReviewReminder.onlyNotifyIfNoReviews]. | ||
| */ | ||
| @Serializable | ||
| data class ReviewReminderSchemaV1( | ||
| override val id: ReviewReminderId, | ||
| val time: ReviewReminderTime, | ||
| val cardTriggerThreshold: ReviewReminderCardTriggerThreshold, | ||
| val scope: ReviewReminderScope, | ||
| var enabled: Boolean, | ||
| val profileID: String, | ||
| val onlyNotifyIfNoReviews: Boolean = false, | ||
| ) : ReviewReminderSchema { | ||
| override fun migrate(): ReviewReminderSchema = | ||
| ReviewReminderSchemaV2( | ||
| id = id, | ||
| time = time, | ||
| cardTriggerThreshold = cardTriggerThreshold, | ||
| scope = scope, | ||
| enabled = enabled, | ||
| profileID = profileID, | ||
| onlyNotifyIfNoReviews = onlyNotifyIfNoReviews, | ||
| ) | ||
| } | ||
|
|
||
| /** | ||
| * Version 2 of [ReviewReminderSchema]. Updated to Version 3 by adding [ReviewReminder.latestNotifTime]. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: consider moving the changes under a |
||
| */ | ||
| @Serializable | ||
| data class ReviewReminderSchemaV2( | ||
| override val id: ReviewReminderId, | ||
| val time: ReviewReminderTime, | ||
| val cardTriggerThreshold: ReviewReminderCardTriggerThreshold, | ||
| val scope: ReviewReminderScope, | ||
| var enabled: Boolean, | ||
| val profileID: String, | ||
| val onlyNotifyIfNoReviews: Boolean, | ||
| ) : ReviewReminderSchema { | ||
| override fun migrate(): ReviewReminderSchema = | ||
| ReviewReminder.createReviewReminder( | ||
| time = time, | ||
| cardTriggerThreshold = cardTriggerThreshold, | ||
| scope = scope, | ||
| enabled = enabled, | ||
| profileID = profileID, | ||
| onlyNotifyIfNoReviews = onlyNotifyIfNoReviews, | ||
| ) | ||
|
Comment on lines
+104
to
+112
|
||
| } | ||
|
|
||
| /** | ||
| * Schema migration settings for testing purposes. | ||
| * Consult this as an example of how to save old schemas and define their [ReviewReminderSchema.migrate] methods. | ||
| */ | ||
| object TestingReviewReminderMigrationSettings { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can these be moved to a test file? If not, explain why. |
||
| /** | ||
| * A sample old review reminder schema. Perhaps this was how the [ReviewReminder] data class was originally implemented. | ||
| * We would like to test the code that checks if review reminders stored on the device adhere to an old, outdated schema. | ||
| * In particular, does the code correctly migrate the serialized data class strings to the updated, current version of [ReviewReminder]? | ||
| */ | ||
| @Serializable | ||
| data class ReviewReminderTestSchemaVersionOne( | ||
| override val id: ReviewReminderId, | ||
| val hour: Int, | ||
| val minute: Int, | ||
| val cardTriggerThreshold: Int, | ||
| val did: DeckId, | ||
| val enabled: Boolean = true, | ||
| ) : ReviewReminderSchema { | ||
| override fun migrate(): ReviewReminderSchema = | ||
| ReviewReminderTestSchemaVersionTwo( | ||
| id = this.id, | ||
| time = VersionTwoDataClasses.ReviewReminderTime(hour, minute), | ||
| snoozeAmount = 1, | ||
| cardTriggerThreshold = this.cardTriggerThreshold, | ||
| did = this.did, | ||
| enabled = enabled, | ||
| ) | ||
| } | ||
|
|
||
| /** | ||
| * Here's an example of how you can handle renamed fields in a data class stored as part of a [ReviewReminder]. | ||
| * Otherwise, there's a namespace collision with [ReviewReminderTime]. | ||
| * | ||
| * This class will be serialized into "ReviewReminderTime(timeHour=#, timeMinute=#)", which otherwise might conflict | ||
| * with the updated definition of [ReviewReminderTime], which is serialized as "ReviewReminderTime(hour=#, minute=#)". | ||
| * When we read the outdated schema from the disk, we need to tell the deserializer that it is reading a | ||
| * [VersionTwoDataClasses.ReviewReminderTime] rather than a [ReviewReminderTime], even though the names are the same. | ||
| * | ||
| * @see ReviewReminderTestSchemaVersionTwo | ||
| */ | ||
| object VersionTwoDataClasses { | ||
| @Serializable | ||
| data class ReviewReminderTime( | ||
| val timeHour: Int, | ||
| val timeMinute: Int, | ||
| ) | ||
| } | ||
|
|
||
| /** | ||
| * Another example of an old review reminder schema. See [ReviewReminderTestSchemaVersionOne] for more details. | ||
| */ | ||
| @Serializable | ||
| data class ReviewReminderTestSchemaVersionTwo( | ||
| override val id: ReviewReminderId, | ||
| val time: VersionTwoDataClasses.ReviewReminderTime, | ||
| val snoozeAmount: Int, | ||
| val cardTriggerThreshold: Int, | ||
| val did: DeckId, | ||
| val enabled: Boolean = true, | ||
| ) : ReviewReminderSchema { | ||
| override fun migrate(): ReviewReminder = | ||
| ReviewReminder.createReviewReminder( | ||
| time = ReviewReminderTime(this.time.timeHour, this.time.timeMinute), | ||
| cardTriggerThreshold = ReviewReminderCardTriggerThreshold(this.cardTriggerThreshold), | ||
| scope = if (this.did == -1L) ReviewReminderScope.Global else ReviewReminderScope.DeckSpecific(this.did), | ||
| enabled = enabled, | ||
| ) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
might want to extract these into a method - it's another 'initial screen' concern