Skip to content
Open
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
5 changes: 5 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ import com.ichi2.anki.preferences.AdvancedSettingsFragment
import com.ichi2.anki.preferences.PreferencesActivity
import com.ichi2.anki.preferences.sharedPrefs
import com.ichi2.anki.receiver.SdCardReceiver
import com.ichi2.anki.reviewreminders.ReviewRemindersDatabase
import com.ichi2.anki.servicelayer.PreferenceUpgradeService
import com.ichi2.anki.servicelayer.ScopedStorageService
import com.ichi2.anki.settings.Prefs
Expand Down Expand Up @@ -600,6 +601,10 @@ open class DeckPicker :

checkWebviewVersion(this)

// If a review reminder deserialization error has recently occurred
// (ex. on app boot, when the app opened, etc.), inform the user via a dialog
ReviewRemindersDatabase.checkDeserializationErrors(this)
Comment on lines 602 to +606
Copy link
Member

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


setFragmentResultListener(REQUEST_KEY) { _, bundle ->
when (CustomStudyAction.fromBundle(bundle)) {
CustomStudyAction.CUSTOM_STUDY_SESSION -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import android.text.format.DateFormat
import com.ichi2.anki.CollectionManager.withCol
import com.ichi2.anki.common.time.TimeManager
import com.ichi2.anki.libanki.DeckId
import com.ichi2.anki.libanki.EpochMilliseconds
import com.ichi2.anki.settings.Prefs
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
Expand Down Expand Up @@ -185,6 +186,9 @@ sealed class ReviewReminderScope : Parcelable {
* @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 latestNotifTime The time at which this review reminder last attempted to fire a routine daily (non-snooze)
* notification, in epoch milliseconds, or the time at which it was created if no notification has ever been fired.
* See [shouldImmediatelyFire].
* @param onlyNotifyIfNoReviews If true, only notify the user if this scope has not been reviewed today yet.
*/
@Serializable
Expand All @@ -196,8 +200,9 @@ data class ReviewReminder private constructor(
val cardTriggerThreshold: ReviewReminderCardTriggerThreshold,
val scope: ReviewReminderScope,
var enabled: Boolean,
var latestNotifTime: EpochMilliseconds,
val profileID: String,
val onlyNotifyIfNoReviews: Boolean = false,
val onlyNotifyIfNoReviews: Boolean,
) : Parcelable,
ReviewReminderSchema {
companion object {
Expand All @@ -219,11 +224,42 @@ data class ReviewReminder private constructor(
cardTriggerThreshold,
scope,
enabled,
latestNotifTime = TimeManager.time.calendar().timeInMillis,
profileID,
onlyNotifyIfNoReviews,
)
}

/**
* Updates [latestNotifTime] to the current time.
* This should be called whenever this review reminder attempts to fire a routine daily (non-snooze) notification.
*/
fun updateLatestNotifTime() {
latestNotifTime = TimeManager.time.calendar().timeInMillis
}

/**
* Checks if this review reminder has tried to fire a routine daily (non-snooze) notification in the time between
* its latest scheduled firing time and now. If not, this method returns true, indicating that a notification
* should be immediately fired for this review reminder.
*/
fun shouldImmediatelyFire(): Boolean {
val (hour, minute) = this.time

val currentTimestamp = TimeManager.time.calendar()
val latestScheduledTimestamp = currentTimestamp.clone() as Calendar
latestScheduledTimestamp.apply {
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 0)
if (after(currentTimestamp)) {
add(Calendar.DAY_OF_YEAR, -1)
}
}

return latestNotifTime < latestScheduledTimestamp.timeInMillis
}

/**
* This is the up-to-date schema, we cannot migrate to a newer version.
*/
Expand Down
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think...

Suggested change
constructor(vararg pairs: Pair<ReviewReminderId, ReviewReminder>) : this(
buildMap { pairs.forEach { put(it.first, it.second) } },
)
constructor(vararg pairs: Pair<ReviewReminderId, ReviewReminder>) : this(hashMapOf(*pairs))


/**
* 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
Copy link
Member

Choose a reason for hiding this comment

The 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
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ReviewReminderGroup class exposes a mutable underlying HashMap through methods like set(), remove(), and toggleEnabled(). However, this class is not thread-safe, and the review reminder system has multiple points where reminders are read and written (AlarmManagerService, NotificationService, ScheduleReminders UI).

Since SharedPreferences operations are atomic but the read-modify-write pattern used with editRemindersForDeck and editAllAppWideReminders is not, there's potential for race conditions where concurrent updates could overwrite each other. Consider either:

  1. Adding explicit synchronization around database operations
  2. Documenting that all access must happen on a single thread
  3. Making ReviewReminderGroup immutable with copy-on-write semantics

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally feel like adding explicit synchronization to the review reminder database file is overkill. Reviewers, please let me know if you disagree!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add this to the KDoc.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, bad comment

Comment on lines +102 to +104
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about:

  fun toggleEnabled(id: ReviewReminderId): Boolean? {
        val reminder = underlyingMap[id]
        if (reminder == null) {
            Timber.w("Attempted to toggle non-existent ReviewReminderId: %s", id)
            return null
        }
        reminder.enabled = !reminder.enabled
        return reminder.enabled
    }

i.e. callers no longer have to guess what happened


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
Copy link
Member

Choose a reason for hiding this comment

The 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].
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: consider moving the changes under a **Updates** heading on a separate line

*/
@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
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When migrating from V2 to V3, the migrate() method calls ReviewReminder.createReviewReminder() which initializes latestNotifTime to the current time (migration time). This can cause a missed notification if the migration happens after the reminder's scheduled time for today.

For example, if a user has a reminder scheduled for 9:00 AM and upgrades the app at 10:00 AM:

  1. Migration sets latestNotifTime = 10:00 AM (current time)
  2. shouldImmediatelyFire() calculates latestScheduledTimestamp = 9:00 AM today
  3. Since latestNotifTime (10:00 AM) > latestScheduledTimestamp (9:00 AM), it returns false
  4. The reminder will not fire today, even though it should have

Consider initializing latestNotifTime to 0 or a very old timestamp during migration (e.g., TimeManager.time.calendar().apply { add(Calendar.YEAR, -1) }.timeInMillis) to ensure any past-due notifications fire immediately after upgrade.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A good point, but if we set the latestNotifTime to 0 or 1 year ago or something like that during the migration, then every reminder will fire all at once once the migration is complete. Not sure if that's desirable. In my opinion dropping one notification during an update is fine -- this problem will only affect users who update the app exactly during the 10-minute firing window of a review reminder.

}

/**
* 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 {
Copy link
Member

Choose a reason for hiding this comment

The 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,
)
}
}
Loading
Loading