Skip to content

Commit a717d70

Browse files
committed
feat(reminders): handle deck deletion
GSoC 2025: Review Reminders - Added logic for checking if a deck exists before returning deck-specific reminders to ReviewRemindersDatabase. This code uses the `decks.have` method, since it seemed like the most straightforward way to accomplish deck-existence-checking. - `have` was previously marked as "unused". I've removed this annotation. - Added a deleteAllRemindersForDeck helper method. It's public because it will also be used in NotificationService. - Had to mark a few database methods as suspending since they use the collection to check if a deck exists. This in turn meant wrapping some of the tests in the test file with `runTest`. Also had to explicitly create decks using `addDeck` in the test file. Moved the dummy review reminder declarations to setUp to accommodate this. - Added new tests for deck deletion functionality.
1 parent 0a52337 commit a717d70

File tree

3 files changed

+374
-234
lines changed

3 files changed

+374
-234
lines changed

AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReviewRemindersDatabase.kt

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import android.content.SharedPreferences
2121
import androidx.annotation.VisibleForTesting
2222
import androidx.core.content.edit
2323
import com.ichi2.anki.AnkiDroidApp
24+
import com.ichi2.anki.CollectionManager.withCol
2425
import com.ichi2.anki.common.utils.android.isRobolectric
2526
import com.ichi2.anki.libanki.DeckId
2627
import kotlinx.serialization.InternalSerializationApi
@@ -229,11 +230,19 @@ object ReviewRemindersDatabase {
229230
}
230231

231232
/**
232-
* Get the [ReviewReminder]s for a specific deck.
233+
* Get the [ReviewReminder]s for a specific deck. Deletes the review reminders for this deck if the deck does not exist.
233234
* @throws SerializationException If the reminders map has not been stored in SharedPreferences as a valid JSON string.
234235
* @throws IllegalArgumentException If the decoded reminders map is not a HashMap<[ReviewReminderId], [ReviewReminder]>.
235236
*/
236-
fun getRemindersForDeck(did: DeckId): HashMap<ReviewReminderId, ReviewReminder> = getRemindersForKey(DECK_SPECIFIC_KEY + did)
237+
suspend fun getRemindersForDeck(did: DeckId): HashMap<ReviewReminderId, ReviewReminder> {
238+
val doesDeckExist = withCol { decks.have(did) }
239+
return if (doesDeckExist) {
240+
getRemindersForKey(DECK_SPECIFIC_KEY + did)
241+
} else {
242+
deleteAllRemindersForDeck(did)
243+
hashMapOf()
244+
}
245+
}
237246

238247
/**
239248
* Get the app-wide [ReviewReminder]s.
@@ -244,15 +253,43 @@ object ReviewRemindersDatabase {
244253

245254
/**
246255
* Get all [ReviewReminder]s that are associated with a specific deck, all in a single flattened map.
256+
* For each deck, deletes the deck's review reminders if the deck does not exist.
247257
* @throws SerializationException If the reminders maps have not been stored in SharedPreferences as valid JSON strings.
248258
* @throws IllegalArgumentException If the decoded reminders maps are not instances of HashMap<[ReviewReminderId], [ReviewReminder]>.
249259
*/
250-
fun getAllDeckSpecificReminders(): HashMap<ReviewReminderId, ReviewReminder> =
251-
remindersSharedPrefs
252-
.all
253-
.filterKeys { it.startsWith(DECK_SPECIFIC_KEY) }
254-
.flatMap { (key, value) -> decodeJson(value.toString(), deckKeyForMigrationPurposes = key).entries }
255-
.associateTo(hashMapOf()) { it.toPair() }
260+
suspend fun getAllDeckSpecificReminders(): HashMap<ReviewReminderId, ReviewReminder> {
261+
// Get all deck-specific reminders
262+
val deckSpecificRemindersMap =
263+
remindersSharedPrefs
264+
.all
265+
.filterKeys { it.startsWith(DECK_SPECIFIC_KEY) }
266+
.toMutableMap()
267+
// Delete deck-specific reminders for decks that do not exist
268+
// Opens a SharedPreferences transaction and the collection only once
269+
remindersSharedPrefs.edit {
270+
withCol {
271+
deckSpecificRemindersMap.entries.removeIf { (key, _) ->
272+
val did = key.removePrefix(DECK_SPECIFIC_KEY).toLong()
273+
val doesDeckExist = decks.have(did)
274+
if (doesDeckExist) {
275+
false // Keep this group of review reminders
276+
} else {
277+
Timber.d("Deleting review reminders for deck $did")
278+
remove(key) // Remove from SharedPreferences
279+
true // Remove from deckSpecificRemindersMap
280+
}
281+
}
282+
}
283+
}
284+
// Decode the remaining deck-specific reminders and return
285+
return deckSpecificRemindersMap
286+
.flatMap { (key, value) ->
287+
decodeJson(
288+
value.toString(),
289+
deckKeyForMigrationPurposes = key,
290+
).entries
291+
}.associateTo(hashMapOf()) { it.toPair() }
292+
}
256293

257294
/**
258295
* Edit the [ReviewReminder]s for a specific key.
@@ -273,17 +310,24 @@ object ReviewRemindersDatabase {
273310
}
274311

275312
/**
276-
* Edit the [ReviewReminder]s for a specific deck.
313+
* Edit the [ReviewReminder]s for a specific deck. Deletes the review reminders for this deck if the deck does not exist.
277314
* This assumes the resulting map contains only reminders of scope [ReviewReminderScope.DeckSpecific].
278315
* @param did
279316
* @param reminderEditor A lambda that takes the current map and returns the updated map.
280317
* @throws SerializationException If the current reminders map has not been stored in SharedPreferences as a valid JSON string.
281318
* @throws IllegalArgumentException If the decoded current reminders map is not a HashMap<[ReviewReminderId], [ReviewReminder]>.
282319
*/
283-
fun editRemindersForDeck(
320+
suspend fun editRemindersForDeck(
284321
did: DeckId,
285322
reminderEditor: (HashMap<ReviewReminderId, ReviewReminder>) -> Map<ReviewReminderId, ReviewReminder>,
286-
) = editRemindersForKey(DECK_SPECIFIC_KEY + did, reminderEditor)
323+
) {
324+
val doesDeckExist = withCol { decks.have(did) }
325+
if (doesDeckExist) {
326+
editRemindersForKey(DECK_SPECIFIC_KEY + did, reminderEditor)
327+
} else {
328+
deleteAllRemindersForDeck(did)
329+
}
330+
}
287331

288332
/**
289333
* Edit the app-wide [ReviewReminder]s.
@@ -294,4 +338,21 @@ object ReviewRemindersDatabase {
294338
*/
295339
fun editAllAppWideReminders(reminderEditor: (HashMap<ReviewReminderId, ReviewReminder>) -> Map<ReviewReminderId, ReviewReminder>) =
296340
editRemindersForKey(APP_WIDE_KEY, reminderEditor)
341+
342+
/**
343+
* Delete all [ReviewReminder]s for a specific deck.
344+
* Fully removes the stored JSON string representing the stored review reminders from SharedPreferences.
345+
* Does nothing if no review reminders for this deck have been stored.
346+
*
347+
* Public so that if a notification is being fired for a deck that has been deleted, the notification can be
348+
* cancelled and the review reminders deleted. In general, deleting review reminders when a deck has been deleted
349+
* is handled lazily: i.e., we do not immediately delete reminders for a deck when it is deleted but rather
350+
* wait until the reminders are requested for display or for notification to check if a deletion should be performed.
351+
*/
352+
fun deleteAllRemindersForDeck(did: DeckId) {
353+
Timber.d("Deleting review reminders for deck $did")
354+
remindersSharedPrefs.edit {
355+
remove(DECK_SPECIFIC_KEY + did)
356+
}
357+
}
297358
}

0 commit comments

Comments
 (0)