Skip to content

Created ReviewRemindersDatabase and ReviewReminder #18364

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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

ericli3690
Copy link
Contributor

@ericli3690 ericli3690 commented May 24, 2025

Purpose / Description

Review Reminders database system.

  • Created ReviewRemindersDatabase, which contains methods for reading and writing to a Preferences Datastore instance for storing review reminder data
  • Created ReviewReminder, which defines the review reminder data class
  • Created Robolectric unit tests for ReviewRemindersDatabase and ReviewReminder

Fixes

  • For GSoC 2025: Review Reminders

Approach

Using Preferences Datastore was the original method I proposed for storing review reminder data in my original project proposal: see [1]

Why did I initially choose Preferences Datastore for storing review reminder data?

  1. Google recommends using it over Shared Preferences: see [2]
  2. It has been used before. Past attempts at creating a review reminders system (see [3]) used Preferences Datastore.

However, we've now chosen to use SharedPreferences instead. It's consistent with what the rest of AnkiDroid uses and doesn't necessitate a whole new import. It's also a lot simpler and straightforward.

How Has This Been Tested?

  • ReviewRemindersDatabaseTest directly or indirectly exercises all methods within ReviewRemindersDatabase.
  • ReviewReminderTest exercises the ID allocation method of ReviewReminder
  • On a physical Samsung S23, API 34., buttons to add, edit, and delete reminders (coming soon in a later PR) cause expected changes to the database and the database changes persist between app sessions.

Learning

I've tried my best to make my code clean and safe. In particular, allocating and deallocating IDs is handled internally and any developer creating a new ReviewReminder should be unable to manually forge an ID.

Originally, I planned to create different types of review reminders. I've now abandoned that in favour of a simpler approach involving snooze functionality.

Checklist

  • You have a descriptive commit message with a short title (first line, max 50 chars).
  • You have commented your code, particularly in hard-to-understand areas
  • You have performed a self-review of your own code
  • UI changes: include screenshots of all affected screens (in particular showing any new or changed strings)
  • UI Changes: You have tested your change using the Google Accessibility Scanner

Sorry, something went wrong.

@ericli3690
Copy link
Contributor Author

Hi, here's a first draft of what I'd like the review reminder storage local database storage system to be like. Looking forward to constructive criticism. For now, I’ve put these files in anki/notifications since that seemed a reasonable place to put them. Let me know if I should move them elsewhere, such as by creating a new anki/reviewreminders folder.

@lukstbit lukstbit added Needs Review GSoC Pull requests authored by a Google Summer of Code participant [Candidate/Selected], for GSoC mentors labels May 24, 2025
Comment on lines 129 to 130
val nextFreeIdKey = intPreferencesKey("next_free_id")
val previouslyUsedIdsKey = stringPreferencesKey("previously_used_ids")
Copy link
Member

Choose a reason for hiding this comment

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

Define constants for the key names.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, good point, hardcoding it was silly of me. Fixed.

Pair(nextFreeId, previouslyUsedIds)
} ?: Pair(FIRST_REMINDER_ID, "[]")
val previouslyUsedIdsList = Json.decodeFromString<MutableList<Int>>(previouslyUsedIds)
// Use previously used IDs if available, else increment next free ID
Copy link
Member

Choose a reason for hiding this comment

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

Can have a line break before the comment to improve readability.

@@ -70,6 +70,7 @@ commonsCompress = "1.27.1"
# https://commons.apache.org/proper/commons-io/changes-report.html
commonsIo = "2.19.0"
coroutines = '1.10.2'
datastorePreferences = '1.1.6'
Copy link
Member

Choose a reason for hiding this comment

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

Latest stable version is 1.1.7

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh wow, Google updated it to 1.1.7 just last week. Fixed.

@ericli3690 ericli3690 force-pushed the ericli3690-review-reminders-datastore branch 2 times, most recently from 91dbce9 to 26c5e13 Compare May 28, 2025 05:16
@ericli3690
Copy link
Contributor Author

Had to do a second force push because I forgot to move the files to a new package as outlined at #18318.

@ericli3690 ericli3690 requested a review from ShridharGoel May 28, 2025 05:26
Copy link
Member

@Arthur-Milchior Arthur-Milchior left a comment

Choose a reason for hiding this comment

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

Thanks for this PR.

I admit I don't understand everything.

The main question for me really remain, why introducing dataStore. I'd really want a comment explaining what advantage it has. What limitation you had with the standard preferences we are currently using that caused you to want to use something else. I'd want it in the commit message adding the dependency to it.


/**
* Review reminder data class. Handles the fields of a review reminders and the logic behind creating one.
* Below, a public way of creating [ReviewReminder] is exposed via a companion object so that IDs are not abused.
Copy link
Member

Choose a reason for hiding this comment

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

What do you mean by "abused"?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've now clarified it a bit more. I was referring to developers creating overlapping IDs, creating IDs out of order, etc. In general we want IDs to increment in a nice orderly fashion without developer interference.

import kotlinx.serialization.Serializable

/**
* Review reminder data class. Handles the fields of a review reminders and the logic behind creating one.
Copy link
Member

Choose a reason for hiding this comment

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

The first sentence is not useful. You're just repeating the declaration. It's clear it's a data class named ReviewReminder

@ConsistentCopyVisibility
data class ReviewReminder private constructor(
val id: Int,
val type: Int, // ReviewReminderTypes ordinal
Copy link
Member

Choose a reason for hiding this comment

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

Why don't you directly use the ReviewReminder? It seems that enum can be serialized too.

val type: Int, // ReviewReminderTypes ordinal
val hour: Int,
val minute: Int,
val cardTriggerThreshold: Int,
Copy link
Member

Choose a reason for hiding this comment

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

You'd really need to document that, it's not very clear unless one have read your proposal and remember it, or know the feature well

companion object {
/**
* Create a new review reminder. This will allocate a new ID for the reminder.
* @param context The context to use for Datastore database access.
Copy link
Member

Choose a reason for hiding this comment

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

You don't have to add documentation for all params.
Context is used in all AnkiDroid, and I assume in all android app, it's self explanatory.

Copy link
Contributor Author

@ericli3690 ericli3690 Jun 1, 2025

Choose a reason for hiding this comment

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

Sounds good. I've now removed params annotations wherever I feel like they are self-explanatory.

Comment on lines 226 to 230
val reminderToRemove = reminders.find { it.id == reminderId }
if (reminderToRemove == null) {
throw IllegalArgumentException("Delete Reminder: Reminder with ID $reminderId not found in deck $did")
}
reminders.remove(reminderToRemove)
Copy link
Member

Choose a reason for hiding this comment

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

I believe you can simplify that to

        val removed = reminders.removeIf { it.id == reminderId }
        if (!removed) {
            throw IllegalArgumentException("Delete Reminder: Reminder with ID $reminderId not found in deck $did")
        }

Copy link
Contributor Author

@ericli3690 ericli3690 Jun 1, 2025

Choose a reason for hiding this comment

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

I've fully removed the addReviewReminder, editReviewReminder, and deleteReviewReminder methods and instead plan to move them to the onClick methods of the UI buttons for adding, editing, and deleting ReviewReminders respectively, since that's probably the only place adding, editing, and deleting code will ever be called anyways. Putting them into ReviewRemindersDatabase just makes ReviewRemindersDatabase more cluttered and messy in my opinion.

Thanks for reminding me about the removeIf method, though. I'll keep it in mind when I create deletion functionality.

* @param reminderId The ID of the reminder to delete.
*/
suspend fun deleteReviewReminderForDeck(
did: Long,
Copy link
Member

Choose a reason for hiding this comment

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

Please, use DeckId as a type. Here and everywhere

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oops, I'll make sure to do this every time going forward.

* @param did The deck ID.
* @return The list of reminders.
*/
suspend fun getRemindersForDeck(did: Long): List<ReviewReminder> {
Copy link
Member

Choose a reason for hiding this comment

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

Is there a reason for it not to be private? I assume it's for test. If so, here and elsewhere, use VisibleForTest

Copy link
Contributor Author

@ericli3690 ericli3690 Jun 1, 2025

Choose a reason for hiding this comment

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

The get and set functions are intentionally public, yes. I plan on using them from the ScheduleReminders UI.

reminderId: Int,
updatedReminder: ReviewReminder,
) {
val reminders = getRemindersForDeck(did).toMutableList()
Copy link
Member

Choose a reason for hiding this comment

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

I feel like you have a lot of code repetition here.
As you always use toMutableList maybe the function should directly return a mutable list (and note that mutating the input returned won't be registered unless it's through setRemindersForDeck)

You can also have an helper function that ensures as follow

    private suspend fun editReminder(did: DeckId, reminderEditor: (MutableList<ReviewReminder> )->List<ReviewReminder>) {
        setRemindersForDeck(did, reminderEditor(getRemindersForDeck(did)))
    }

    /**
     * Add a review reminder for a specific deck.
     * @param did The deck ID.
     * @param newReminder The new review reminder to add.
     */
    suspend fun addReviewReminderForDeck(
        did: Long,
        newReminder: ReviewReminder,
    ) {
        editReminder(did) {reminders ->
            reminders.add(newReminder)
            return@editReminder reminders
        }
    }

And reuse editReminder in both method below

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, I like this a lot, thanks. Implemented!

@@ -0,0 +1,195 @@
/*
* Copyright (c) 2025 Eric Li <[email protected]>
*
Copy link
Member

Choose a reason for hiding this comment

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

I believe it's usually better to have test in the same commit as what is tested. At least if it's already written.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sounds good. I've merged all my commits together.

@ericli3690 ericli3690 force-pushed the ericli3690-review-reminders-datastore branch 2 times, most recently from 9dcf3d3 to 1f79657 Compare June 1, 2025 03:20
@ericli3690
Copy link
Contributor Author

Repeated force push because I forgot to rebase, my bad.

@ericli3690
Copy link
Contributor Author

Hi, I've refactored to use SharedPreferences instead of Preferences Datastore. In retrospect, thanks for telling me to use it, it really is a lot more simple and I should have started with this method. Using Datastore was like trying to crack a nut with a sledgehammer. The only negative consequence is that my branch I'm trying to merge these changes in from is still called review-reminders-datastore, so that's a bit confusing-looking but shouldn't be an issue.

Regarding other overly-complex points I've simplified: the fancy ID-allocation system is now gone, and I've decided to move the addReminderForDeck, editReminderForDeck, and deleteReminderForDeck methods to the UI onClick methods since that should be the only place they're called from, and so they don't need to be in ReviewRemindersDatabase.

I've also done my best to clean up the docstrings, as per feedback. Previously they were more ad-hoc, now I've tried my best to make them genuine pieces of documentation.

@ericli3690 ericli3690 force-pushed the ericli3690-review-reminders-datastore branch from 1f79657 to 1df1d45 Compare June 1, 2025 06:05
@ericli3690
Copy link
Contributor Author

I realized the SharedPreferences keys are too short. Since there's tons of other things being stored in SharedPreferences, it's probably a good idea to append review_reminders_ to the beginning of the keys I use in these files so they don't collide with any other keys.

@ericli3690 ericli3690 force-pushed the ericli3690-review-reminders-datastore branch from 1df1d45 to c825609 Compare June 1, 2025 23:08
@ericli3690
Copy link
Contributor Author

I realized while I was building out the ScheduleReminders RecyclerView more that:

  1. There are typos in this commit, whoops.
  2. I need to store a review reminder's corresponding deck ID within ReviewReminder for the RecyclerView to work properly in GLOBAL_DECK_SPECIFIC scope mode.
  3. I need to add a method to retrieve the reminders in all decks to ReviewRemindersDatastore, to be used in GLOBAL_DECK_SPECIFIC scope mode.

@ericli3690 ericli3690 force-pushed the ericli3690-review-reminders-datastore branch from c825609 to cb8bb87 Compare June 6, 2025 01:09
@ericli3690
Copy link
Contributor Author

Rebased and fixed a List that should have been a MutableList.

@ericli3690 ericli3690 force-pushed the ericli3690-review-reminders-datastore branch from cb8bb87 to d73fe2f Compare June 6, 2025 04:19
@ericli3690 ericli3690 requested a review from david-allison June 6, 2025 04:31
@ericli3690 ericli3690 force-pushed the ericli3690-review-reminders-datastore branch from d73fe2f to 0c4180c Compare June 8, 2025 06:05
@ericli3690
Copy link
Contributor Author

  • Added a ReviewReminderId typealias.
  • Tweaked the contracts of the database's "edit" methods to now require a lambda which returns a List rather than strictly a MutableList.
  • Added an editAllDeckSpecificReminders method. I realized I needed it for the deck-specific scope mode's editing while creating the bulk deletion feature.
  • Added a new test to ReviewRemindersDatabaseTest to test editAllDeckSpecificReminders.
  • Cleaned up some syntax in the two new test files.

@ericli3690
Copy link
Contributor Author

Please do not review this PR. I'm actively refactoring it as I've realized I can streamline the UX. Sorry for asking for reviews and then going back and changing things again, I really do appreciate the constructive criticism of my code and am sorry for pulling it out of the merge pipeline when it's so close to the end.

@ericli3690 ericli3690 marked this pull request as draft June 9, 2025 05:00
@ericli3690 ericli3690 force-pushed the ericli3690-review-reminders-datastore branch from 0c4180c to fc1f2ab Compare June 14, 2025 18:18
@ericli3690
Copy link
Contributor Author

Refactored. Removed ReviewReminderTypes and replaced it with snoozing. Looking forward to reviews!

@ericli3690 ericli3690 marked this pull request as ready for review June 14, 2025 18:33
Copy link
Contributor

@criticalAY criticalAY left a comment

Choose a reason for hiding this comment

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

Solid niceee

@ericli3690 ericli3690 force-pushed the ericli3690-review-reminders-datastore branch from fc1f2ab to da51859 Compare June 14, 2025 23:43
@ericli3690
Copy link
Contributor Author

Thanks Ashish! I've added some try-catch blocks to handle corrupt JSON strings and added a few unit tests to test that behaviour.

@ericli3690 ericli3690 requested a review from criticalAY June 14, 2025 23:56
* Edit all [ReviewReminder]s that are associated with a specific deck by operating on a single mutable list.
*/
fun editAllDeckSpecificReminders(reminderEditor: (MutableList<ReviewReminder>) -> List<ReviewReminder>) {
val newReminders = reminderEditor(getAllDeckSpecificReminders())
Copy link
Contributor

Choose a reason for hiding this comment

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

getAllDeckSpecificReminders() internally decodes every JSON string in SharedPreferences, and then inside reminderEditor, the flat list is returned, and must be re-grouped again.
So:

  • Decodes once, which is nice
  • But there's no grouped structure to begin with, and grouping happens only at the end, forcing repeated transformations.

What if we use this

 fun editAllDeckSpecificReminders(
        reminderEditor: (MutableList<ReviewReminder>) -> List<ReviewReminder>
    ) {
        val existingRemindersGrouped = getAllDeckRemindersGrouped()
        val updatedReminders = reminderEditor(existingRemindersGrouped.values.flatten().toMutableList())
        val updatedGrouped = updatedReminders.groupBy { it.did }

        val existingKeys = existingRemindersGrouped.keys.map { DECK_SPECIFIC_KEY + it }

        context.sharedPrefs().edit {
            existingKeys.forEach { remove(it) }
            updatedGrouped.forEach { (did, reminders) ->
                putString(DECK_SPECIFIC_KEY + did, Json.encodeToString(reminders))
            }
        }
    }


    private fun getAllDeckRemindersGrouped(): Map<DeckId, MutableList<ReviewReminder>> {
        return context.sharedPrefs().all
            .filterKeys { it.startsWith(DECK_SPECIFIC_KEY) }
            .mapNotNull { (key, value) ->
                val did = key.removePrefix(DECK_SPECIFIC_KEY).toLongOrNull() ?: return@mapNotNull null
                val reminders = try {
                    Json.decodeFromString<MutableList<ReviewReminder>>(value.toString())
                } catch (e: Exception) {
                    null
                } ?: return@mapNotNull null
                did to reminders
            }
            .toMap()
    }

Here, SharedPreferences read + decode only happens once, and the grouping logic is explicit and ig Less parsing, and a cleaner transformation chain.

You may inspect more and reject this above suggestion if its not valid

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, I understand what you mean now. Sure, I'll do that, it is quite elegant.

@ericli3690 ericli3690 force-pushed the ericli3690-review-reminders-datastore branch from da51859 to 1bb931d Compare June 15, 2025 16:26
@ericli3690
Copy link
Contributor Author

ericli3690 commented Jun 15, 2025

  • Added getAllDeckSpecificRemindersGrouped
  • Refactored getAllDeckSpecificReminders and editAllDeckSpecificReminders to use it

@ericli3690 ericli3690 requested a review from criticalAY June 15, 2025 16:36
@ericli3690 ericli3690 force-pushed the ericli3690-review-reminders-datastore branch from 1bb931d to 2ca75ff Compare June 15, 2025 19:25
@ericli3690
Copy link
Contributor Author

  • Replaced List with HashMap to make editing individual review reminders more efficient
  • Added two utility methods to ReviewRemindersDatabase to flatten and group maps of review reminders
  • Updated ReviewRemindersDatabaseTest, which no longer needs to inefficiently sort lists of review reminders when asserting

@ericli3690 ericli3690 marked this pull request as draft June 15, 2025 21:32
@ericli3690 ericli3690 force-pushed the ericli3690-review-reminders-datastore branch from 2ca75ff to e12c282 Compare June 15, 2025 22:18
@ericli3690
Copy link
Contributor Author

  • Added the enabled property to ReviewReminder

@ericli3690 ericli3690 marked this pull request as ready for review June 15, 2025 22:31
@SanjaySargam
Copy link
Member

@ericli3690
I wanted to check—when we delete a deck, do its associated reminders still remain stored in SharedPreferences?

val did: DeckId,
var enabled: Boolean,
) {
init {
Copy link
Member

Choose a reason for hiding this comment

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

Use a single require with a compound condition

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hm, I think I'd actually like to keep the separate require lines if possible. Throwing everything together into a single compound condition makes the code a bit harder to read.

This comment was marked as outdated.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Wouldn't that make it so that if the require fails, it's hard to tell why it failed? I think if we break it up over multiple lines, and one particular condition fails, then we get a more clear and specific error message.

val existingReminders = getRemindersForKey(key)
val updatedReminders = reminderEditor(existingReminders)
context.sharedPrefs().edit {
putString(key, Json.encodeToString(updatedReminders))
Copy link
Member

Choose a reason for hiding this comment

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

Should we add try-catch around Json.encodeToString() in case it fails, so SharedPreferences doesn’t get into a bad state?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, good point. Sure, I'll add that in to be safe, though I won't be able to add unit tests for it. The type-checking of reminderEditor's contract prevents me from triggering a serialization error from these editing methods. Thanks!

Copy link
Member

@david-allison david-allison left a comment

Choose a reason for hiding this comment

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

Few thoughts, feel free to discard

* - [DISABLED]: The snooze button will never appear on notifications set by this review reminder.
* @see SnoozeAmount
*/
object SpecialSnoozeAmounts {
Copy link
Member

Choose a reason for hiding this comment

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

Consider using a sealed class to make a union (serializability untested):

@Serializable
sealed class SnoozeAmount {
    data class Limited(val maxSnoozes: Int, val interval: Duration): SnoozeAmount()
    /** The snooze button will always be available */
    data object Infinite: SnoozeAmount()
    /** The snooze button will never appear */
    data object Disabled: SnoozeAmount()
}

* @param hour
* @param minute
* @param snoozeAmountAllowed Amount of times the user can hit snooze on the notification. See [SpecialSnoozeAmounts] for distinguished values.
* @param snoozeTimeInterval Time interval between snoozes, in minutes. Unused if snoozesAllowed is a [SpecialSnoozeAmounts] value.
Copy link
Member

Choose a reason for hiding this comment

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

I would have expected this to be set for INFINITE, I might be missing something from my mental model of the class

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Doh! Nope, you're right. Typo on my part.

Comment on lines 51 to 52
val hour: Int,
val minute: Int,
Copy link
Member

Choose a reason for hiding this comment

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

Consider combining Hour and Minute in a class, so invalid values can't be passed in

Comment on lines 120 to 126
context.sharedPrefs().getInt(
NEXT_FREE_ID_KEY,
FIRST_REMINDER_ID,
)
context.sharedPrefs().edit {
putInt(NEXT_FREE_ID_KEY, nextFreeId + 1)
}
Copy link
Member

Choose a reason for hiding this comment

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

As mentioned in a previous review, look into the Prefs class

@After
override fun tearDown() {
super.tearDown()
// Reset the database after each test
activity.sharedPrefs().edit { clear() }
}
Copy link
Member

Choose a reason for hiding this comment

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

Is this necessary?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Technically, no, since there's only one test in this file right now. I added it in case someone else decides to write more tests for this file later, because they might forget to implement tearDown().

@ericli3690
Copy link
Contributor Author

Thanks for the reviews.

@SanjaySargam Regarding whether review reminders should remain in SharedPreferences after the corresponding deck is deleted: no, they should not, and don't worry, it's on my to-do list. Thanks for reminding me!

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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
@ericli3690 ericli3690 force-pushed the ericli3690-review-reminders-datastore branch from e12c282 to 9d0e81e Compare June 21, 2025 06:02
@ericli3690
Copy link
Contributor Author

  • Added try-catch for JSON encoding
  • Created a APP_WIDE_REMINDER_DECK_ID constant
  • Added ReviewReminderTime and SnoozeAmount classes
  • Cleaned up references to context.sharedPrefs() to instead use Prefs or AnkiDroidApp.sharedPrefs()
  • Added a new preferences resource to allow for Prefs.getString()

Copy link
Member

@david-allison david-allison left a comment

Choose a reason for hiding this comment

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

All looks VERY well documented!

import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes

internal typealias ReviewReminderId = Int
Copy link
Member

Choose a reason for hiding this comment

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

optional: look into value class for a low/0-cost abstraction which stops an Int being passed in where a ReviewReminderId is required

.withLocale(Locale.getDefault()),
)

fun toMinutesFromMidnight(): Int = hour * 60 + minute
Copy link
Member

Choose a reason for hiding this comment

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

You may be able to do something with the Duration API here, so you're working with a Duration object, rather than an untyped number of minutes

fun elapsedSinceMidnight(): Duration = (hour.hours + minute.minutes)
val seconds = time.elapsedSinceMidnight().inWholeSeconds

/**
* The "deck ID" field for review reminders that are app-wide rather than deck-specific.
*/
const val APP_WIDE_REMINDER_DECK_ID = -1L
Copy link
Member

Choose a reason for hiding this comment

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

You may or may not want this as a sealed class, depending on how low-level this API will be

* @return The next free reminder ID.
*/
private fun getAndIncrementNextFreeReminderId(): ReviewReminderId {
val nextFreeId = Prefs.getInt(R.string.review_reminders_next_free_id, FIRST_REMINDER_ID)
Copy link
Member

Choose a reason for hiding this comment

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

Look into by intPref. Use this new property as an opportunity to document the preference

Index: AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReviewReminder.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReviewReminder.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReviewReminder.kt
--- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReviewReminder.kt	(revision 9d0e81e75ce77a0d7ce8949b5344af2318389b23)
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReviewReminder.kt	(date 1750647615025)
@@ -16,7 +16,7 @@
 
 package com.ichi2.anki.reviewreminders
 
-import com.ichi2.anki.R
+import com.ichi2.anki.reviewreminders.ReviewReminder.Companion.APP_WIDE_REMINDER_DECK_ID
 import com.ichi2.anki.settings.Prefs
 import com.ichi2.libanki.DeckId
 import kotlinx.serialization.Serializable
@@ -131,11 +131,6 @@
          */
         const val APP_WIDE_REMINDER_DECK_ID = -1L
 
-        /**
-         * IDs start at this value and climb upwards by one each time.
-         */
-        private const val FIRST_REMINDER_ID = 0
-
         /**
          * Create a new review reminder. This will allocate a new ID for the reminder.
          * @return A new [ReviewReminder] object.
@@ -163,8 +158,8 @@
          * @return The next free reminder ID.
          */
         private fun getAndIncrementNextFreeReminderId(): ReviewReminderId {
-            val nextFreeId = Prefs.getInt(R.string.review_reminders_next_free_id, FIRST_REMINDER_ID)
-            Prefs.putInt(R.string.review_reminders_next_free_id, nextFreeId + 1)
+            val nextFreeId = Prefs.reviewReminderNextFreeId
+            Prefs.reviewReminderNextFreeId = nextFreeId + 1
             Timber.d("Generated next free review reminder ID: $nextFreeId")
             return nextFreeId
         }
Index: AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt b/AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt
--- a/AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt	(revision 9d0e81e75ce77a0d7ce8949b5344af2318389b23)
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt	(date 1750647582518)
@@ -204,4 +204,6 @@
     var isDevOptionsEnabled: Boolean
         get() = getBoolean(R.string.dev_options_enabled_by_user_key, false) || BuildConfig.DEBUG
         set(value) = putBoolean(R.string.dev_options_enabled_by_user_key, value)
+
+    var reviewReminderNextFreeId by intPref(R.string.review_reminders_next_free_id, defaultValue = 0)
 }

/**
* Get and return the next free reminder ID which can be associated with a new review reminder.
* Also increment the next free reminder ID stored in SharedPreferences.
* Since there are 4 billion IDs available, this should not overflow in practice.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
* Since there are 4 billion IDs available, this should not overflow in practice.

// If, during development, the review reminders storage schema (i.e. ReviewReminder) is modified,
// this class will attempt to read review reminders stored in the old schema via the new schema, causing a SerializationException.
// To delete all review reminder keys in SharedPreferences, uncomment the following line.
// AnkiDroidApp.sharedPrefs().edit { AnkiDroidApp.sharedPrefs().all.keys.filter { it.startsWith("review_reminders_") }.forEach { remove(it) } }
Copy link
Member

Choose a reason for hiding this comment

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

Was this discussed and confirmed with mentors? We may want to use a separate file for these

  • This will significantly increase the on-disk size of the default SharedPreferences - maybe to the point of affecting app loading times
  • We'll want to exclude this data from ACRA

private fun decodeJson(jsonString: String): HashMap<ReviewReminderId, ReviewReminder> =
try {
// If, during development, the review reminders storage schema (i.e. ReviewReminder) is modified,
// this class will attempt to read review reminders stored in the old schema via the new schema, causing a SerializationException.
Copy link
Member

Choose a reason for hiding this comment

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

You'll want to plan how to update the schema

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
GSoC Pull requests authored by a Google Summer of Code participant [Candidate/Selected], for GSoC mentors Needs Review
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

7 participants