diff --git a/AnkiDroid/build.gradle b/AnkiDroid/build.gradle
index 2695f8f3d0c6..7ccf943cfc83 100644
--- a/AnkiDroid/build.gradle
+++ b/AnkiDroid/build.gradle
@@ -315,6 +315,8 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-test:$kotlin_version"
implementation 'com.github.ByteHamster:SearchPreference:v2.2.1'
implementation 'com.github.AppIntro:AppIntro:6.1.0'
+ implementation "androidx.work:work-runtime-ktx:2.7.1"
+ implementation "androidx.datastore:datastore-preferences:1.0.0"
// Cannot use debugImplementation since classes need to be imported in AnkiDroidApp
// and there's no no-op version for release build. Usage has been disabled for release
diff --git a/AnkiDroid/src/main/AndroidManifest.xml b/AnkiDroid/src/main/AndroidManifest.xml
index 5b2231803e0f..48a94117dced 100644
--- a/AnkiDroid/src/main/AndroidManifest.xml
+++ b/AnkiDroid/src/main/AndroidManifest.xml
@@ -475,6 +475,12 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt
index a6a4c6bad452..3991b86bf52a 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt
@@ -39,6 +39,8 @@ import com.ichi2.anki.analytics.UsageAnalytics
import com.ichi2.anki.contextmenu.AnkiCardContextMenu
import com.ichi2.anki.contextmenu.CardBrowserContextMenu
import com.ichi2.anki.exception.StorageAccessException
+import com.ichi2.anki.receiver.TimeZoneChangeReceiver
+import com.ichi2.anki.receiver.TimeZoneChangeReceiver.Companion.registerTimeZoneChangeReceiver
import com.ichi2.anki.services.BootService
import com.ichi2.anki.services.NotificationService
import com.ichi2.compat.CompatHelper
@@ -51,12 +53,13 @@ import java.io.InputStream
import java.util.*
import java.util.regex.Pattern
+
/**
* Application class.
*/
@KotlinCleanup("lots to do")
@KotlinCleanup("IDE Lint")
-open class AnkiDroidApp : Application() {
+open class AnkiDroidApp : Application(), androidx.work.Configuration.Provider {
/** An exception if the WebView subsystem fails to load */
private var mWebViewError: Throwable? = null
private val mNotifications = MutableLiveData()
@@ -183,9 +186,17 @@ open class AnkiDroidApp : Application() {
}
}
}
+
+ Timber.i("AnkiDroidApp: Starting Workers")
+ NotificationHelper(this).startNotificationWorker(0, false)
+
+ // TODO: Notification CleanUP. Delete the Boot Service after successful implementation of Notification Work Manager.
Timber.i("AnkiDroidApp: Starting Services")
BootService().onReceive(this, Intent(this, BootService::class.java))
+ Timber.i("AnkiDroidApp: Registering Broadcast Receivers")
+ registerTimeZoneChangeReceiver(this, TimeZoneChangeReceiver())
+ // TODO: Notification CleanUP. Delete the Notification Service after successful implementation of Notification Work Manager.
// Register for notifications
mNotifications.observeForever { NotificationService.triggerNotificationFor(this) }
Themes.systemIsInNightMode =
@@ -213,6 +224,16 @@ open class AnkiDroidApp : Application() {
}
}
+ /**
+ * Our configuration for custom work manager.
+ */
+ /*
+ * We are using custom work manager because UNIT TESTS are failing.
+ * TODO: Remove custom implementation after implementing 14 tests using WorkManagerTestInitHelper. SEE: https://developer.android.com/topic/libraries/architecture/workmanager/how-to/integration-testing
+ */
+ override fun getWorkManagerConfiguration() = androidx.work.Configuration.Builder().build()
+
+
/**
* A tree which logs necessary data for crash reporting.
*
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NotificationDatastore.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NotificationDatastore.kt
new file mode 100644
index 000000000000..0a174e975d02
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/NotificationDatastore.kt
@@ -0,0 +1,266 @@
+/*
+ * Copyright (c) 2022 Prateek Singh
+ *
+ * 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 .
+ */
+
+package com.ichi2.anki
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import com.fasterxml.jackson.core.JacksonException
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.ichi2.anki.NotificationDatastore.Companion.getInstance
+import com.ichi2.anki.model.DeckNotification
+import com.ichi2.libanki.DeckId
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.launch
+import org.json.JSONObject
+import timber.log.Timber
+import java.util.*
+
+/**
+ * Time at which deck notification will trigger. Stores time in millisecond in EPOCH format.
+ */
+/*
+* We use TimeOfNotification as key of json object. So it must be string.
+* NOTE: It's in decimal. Hexadecimal would make the conversion faster and takes less space, but really not worth the code complexity added.
+*/
+typealias TimeOfNotification = String
+
+/**
+ * DeckIds which is going to trigger at particular time.
+ */
+typealias SimultaneouslyTriggeredDeckIds = HashSet
+
+/**
+ * Indicates all notifications that we must eventually trigger. If a notification time is in the past, we must do it as soon as possible, otherwise we must trigger the notification later.
+ * Implemented as map from time to set of deck ids triggered at this time
+ */
+/*
+* NOTE: In Time Of Notification String will break when timestamp gets an extra digit because we'll get new timestamps
+* starting with a 1 that will be a number greater than previous numbers but alphabetically shorter
+* This will occur on 20 NOVEMBER 2286
+*/
+typealias NotificationTodo = TreeMap
+
+/**
+ * Notification which is going to trigger next.
+ * */
+fun NotificationTodo.earliestNotifications(): MutableMap.MutableEntry? =
+ firstEntry()
+
+/**
+ * Stores the scheduled notification details
+ * This is a singleton class, use [getInstance]
+ * */
+class NotificationDatastore private constructor(val context: Context) {
+
+ private val objectMapper = ObjectMapper()
+
+ /**
+ * Stores the String in Notification Datastore
+ * It stores the data asynchronously.
+ * Calling this function guarantees to store value in database.
+ * @param key The Key of value. Used in fetching the data.
+ * @param value Value that needs to be stored (VALUE MUST BE STRING).
+ * */
+ suspend fun putStringAsync(key: String, value: String) {
+ val dataStoreKey = stringPreferencesKey(key)
+ context.notificationDatastore.edit { metaData ->
+ metaData[dataStoreKey] = value
+ }
+ }
+
+ /**
+ * Stores the String in Notification Datastore
+ * It stores the data synchronously. It will create Coroutine [Dispatchers.IO] Scope Internally.
+ * @param key The Key of value. Used in fetching the data.
+ * @param value Value that needs to be stored (VALUE MUST BE STRING).
+ * */
+ fun putStringSync(key: String, value: String) {
+ CoroutineScope(Dispatchers.IO).launch {
+ putStringAsync(key, value)
+ }
+ }
+
+ /**
+ * Fetches the String value from Datastore.
+ * @prams The Key of deck whose data you want to fetch.
+ * @return Value associated to `key` by the last call to [putStringSync], [putStringAsync], or [default] if none
+ * */
+ suspend fun getString(key: String, default: String): String {
+ val dataStoreKey = stringPreferencesKey(key)
+ return context.notificationDatastore.data.firstOrNull()?.let {
+ it[dataStoreKey]
+ } ?: default
+ }
+
+ /**
+ * Stores the Integer in Notification Datastore
+ * It stores the data asynchronously.
+ * Calling this function guarantees to store value in database.
+ * @param key The Key of value. Created while storing the data.
+ * @param value Value that needs to be stored (VALUE MUST BE INTEGER).
+ * */
+ suspend fun putIntAsync(key: String, value: Int) {
+ val dataStoreKey = intPreferencesKey(key)
+ context.notificationDatastore.edit { metaDataEditor ->
+ metaDataEditor[dataStoreKey] = value
+ }
+ }
+
+ /**
+ * Stores the Integer in Notification Datastore
+ * It stores the data synchronously. It will create Coroutine [Dispatchers.IO] Scope Internally.
+ * @param key The Key of value. Created while storing the data.
+ * @param value Value that needs to be stored (VALUE MUST BE INTEGER).
+ * */
+ fun putIntSync(key: String, value: Int) {
+ CoroutineScope(Dispatchers.IO).launch {
+ putIntAsync(key, value)
+ }
+ }
+
+ /**
+ * Fetches the Integer value from Datastore.
+ * @prams The Key of deck whose data you want to fetch.
+ * @return Value associated to `key` by the last call to [putIntSync], [putIntAsync], or [default] if none
+ * */
+ suspend fun getInt(key: String, default: Int): Int {
+ val dataStoreKey = intPreferencesKey(key)
+ return context.notificationDatastore.data.firstOrNull()?.let {
+ it[dataStoreKey]
+ } ?: default
+ }
+
+ /**
+ * Stores the Map of time and list of deck ids to Datastore
+ * It stores the data asynchronously.
+ * */
+ suspend fun setTimeDeckData(data: Map>) {
+ val dataStoreKey = stringPreferencesKey("TIME_DECK_DATA")
+ val jsonObj = JSONObject(data)
+ context.notificationDatastore.edit { metaData ->
+ metaData[dataStoreKey] = jsonObj.toString()
+ }
+ }
+
+ /**
+ * Fetches the Map of time and list of deck ids from Datastore.
+ * @return The current AllTimeAndDecksMap
+ * */
+ /*
+ * We actually are not blocking the thread. This method throws an exception. It will not create problem for us.
+ * */
+ @Suppress("UNCHECKED_CAST", "BlockingMethodInNonBlockingContext")
+ suspend fun getTimeDeckData(): NotificationTodo? {
+ val datastoreKey = stringPreferencesKey("TIME_DECK_DATA")
+ return context.notificationDatastore.data.firstOrNull()?.let {
+ try {
+ objectMapper.readValue(
+ it[datastoreKey],
+ TreeMap::class.java
+ ) as NotificationTodo
+ } catch (ex: JacksonException) {
+ Timber.d(ex.cause)
+ null
+ }
+ }
+ }
+
+ /**
+ * Stores the details of the [notification] scheduling of deck [did]
+ * @return whether operation is successful.
+ * */
+ suspend fun setDeckSchedData(did: DeckId, notification: DeckNotification): Boolean {
+ val dataStoreKey = stringPreferencesKey(did.toString())
+ return runCatching {
+ val json = objectMapper.writeValueAsString(notification)
+ context.notificationDatastore.edit { metaData ->
+ metaData[dataStoreKey] = json
+ }
+ }.isSuccess
+ }
+
+ /**
+ * Fetches the details of particular deck scheduling.
+ * @return Deck Notification model for particular deck.
+ * */
+ /*
+ * We actually are not blocking the thread. This method throws an exception. It will not create problem for us.
+ * TODO: unit test that :
+ * * if there is no preference at all, we return null
+ * * if there is a preference without entry for this key we return null
+ * * if there is a preference whose entry for this key can't be cast to DeckNotification, throw
+ * * if there is a preference with entry for this key that can be cast, we get expected notification
+ */
+ @Suppress("BlockingMethodInNonBlockingContext")
+ suspend fun getDeckSchedData(did: DeckId): DeckNotification? {
+ val datastoreKey = stringPreferencesKey(did.toString())
+ return context.notificationDatastore.data.firstOrNull()?.let {
+ val schedDataJSON = it[datastoreKey] ?: return null
+ try {
+ objectMapper.readValue(
+ schedDataJSON,
+ DeckNotification::class.java
+ )
+ } catch (ex: Exception) {
+ // Let the exception throw
+ CrashReportService.sendExceptionReport(
+ ex,
+ "Notification Datastore-getDeckSchedData",
+ "Exception Occurred during fetching of data."
+ )
+ throw Exception("Unable to find schedule data of given deck id: $did", ex)
+ }
+ }
+ }
+
+ companion object {
+ private lateinit var INSTANCE: NotificationDatastore
+ private val Context.notificationDatastore: DataStore by preferencesDataStore("NotificationDatastore")
+
+ /**
+ * @param block A function that create the unique notification data store.
+ * Only called if it does not already exists. It is then in charge of assigning INSTANCE.
+ * @return the unique instance of NotificationDataStore. Creates it through [block] if necessary.
+ */
+ private fun instanceInitializedOr(
+ context: Context,
+ block: (context: Context) -> NotificationDatastore
+ ) = if (this::INSTANCE.isInitialized) INSTANCE else block(context)
+
+ /**
+ * Thread safe.
+ * @return The singleton NotificationDatastore
+ */
+ fun getInstance(context: Context) = instanceInitializedOr(context) {
+ synchronized(this) {
+ // Check again whether [INSTANCE] is initialized because it could have been initialized while waiting for synchronization.
+ instanceInitializedOr(context) {
+ NotificationDatastore(context).also {
+ INSTANCE = it
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NotificationHelper.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NotificationHelper.kt
new file mode 100644
index 000000000000..14acb5f4b30d
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/NotificationHelper.kt
@@ -0,0 +1,376 @@
+/*
+ * Copyright (c) 2022 Prateek Singh
+ *
+ * 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 .
+ */
+
+package com.ichi2.anki
+
+import android.app.Notification
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.ContextCompat
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequest
+import androidx.work.WorkManager
+import com.ichi2.anki.worker.NotificationWorker
+import com.ichi2.libanki.DeckId
+import com.ichi2.libanki.utils.TimeManager
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import java.util.*
+import java.util.Calendar.HOUR_OF_DAY
+import java.util.Calendar.MINUTE
+import java.util.concurrent.TimeUnit
+import kotlin.collections.HashSet
+
+/**
+ * Helper class for Notification. Manages all the notification for AnkiDroid.
+ * */
+class NotificationHelper(val context: Context) {
+
+ private val TAG = "NOTIFICATION_WORKER"
+
+ /**
+ * Adds a daily recurring notification for the provided deck to the notification schedule.
+ * @param did A deck's id.
+ * @param hourOfDay Hour of day when notification trigger.
+ * @param minutesOfHour Minutes of day when notification trigger.
+ * */
+ fun scheduledDeckNotification(
+ did: DeckId,
+ hourOfDay: Int,
+ minutesOfHour: Int,
+ ) {
+ CoroutineScope(Dispatchers.Default).launch {
+ Timber.d("CreateScheduledDeckNotification-> did: $did hour: $hourOfDay min: $minutesOfHour")
+ val notificationDatastore = NotificationDatastore.getInstance(context)
+
+ // Collect all the time deck data.
+ val timeDeckData: NotificationTodo =
+ notificationDatastore.getTimeDeckData()
+ ?: NotificationTodo()
+
+ // Calculate the Notification Work Type and schedule time of notification.
+ val notificationData = notificationData(hourOfDay, minutesOfHour, timeDeckData)
+
+ // Add schedule time in deck time data.
+ timeDeckData.append(notificationData.scheduleTime, did)
+
+ saveScheduledNotification(
+ notificationDatastore,
+ timeDeckData,
+ notificationData.notificationWorkType,
+ )
+ }
+ }
+
+ /**
+ * Set the notificationTimeOfToday for schedule time. It will return the time according to user time zone.
+ * @param hourOfDay hour of notification time.
+ * @param minutesOfHour minutes of notification time
+ * */
+ private fun hourMinuteTimestampToday(hourOfDay: Int, minutesOfHour: Int) =
+ TimeManager.time.calendar().apply {
+ this.set(HOUR_OF_DAY, hourOfDay)
+ this.set(MINUTE, minutesOfHour)
+ }.timeInMillis
+
+ /**
+ * Calculates the notification time in MS and [NotificationWorkType] that needs to be done to trigger notification.
+ * Note: Time in the next 24 hours.
+ * @param hourOfDay Hour of day when notification trigger.
+ * @param minutesOfHour Minutes of day when notification trigger.
+ * @param timeDeckData allDeckTimeData map to compute work type
+ * @return [NotificationData] which contains schedule time and notification work type.
+ * */
+ private fun notificationData(
+ hourOfDay: Int,
+ minutesOfHour: Int,
+ timeDeckData: NotificationTodo
+ ) = NotificationData(timestampToSchedule(hourOfDay, minutesOfHour), notificationWorkType(hourOfDay, minutesOfHour, timeDeckData))
+
+ /**
+ * Calculates the notification work type.
+ * Need to reschedule only if, as far as we currently know, this is the next notification to trigger in the future.
+ * @param hourOfDay Hour of day when notification trigger.
+ * @param minutesOfHour Minutes of day when notification trigger.
+ * @param timeDeckData allDeckTimeData map to compute work type
+ * @return [NotificationWorkType] notification work type.
+ * */
+ private fun notificationWorkType(
+ hourOfDay: Int,
+ minutesOfHour: Int,
+ timeDeckData: NotificationTodo
+ ): NotificationWorkType {
+ val currentTimeMS = TimeManager.time.intTimeMS()
+ val notificationTimeInMS = hourMinuteTimestampToday(hourOfDay, minutesOfHour)
+ return if (notificationTimeInMS < currentTimeMS) {
+ Timber.d("Scheduled time is already passed for today")
+ NotificationWorkType.SAVE
+ } else if (timeDeckData.size == 0) {
+ Timber.d("Creating new time deck data ")
+ NotificationWorkType.SAVE_AND_RESCHEDULE
+ } else if (notificationTimeInMS < timeDeckData.earliestNotifications()!!.key.toLong()) {
+ Timber.d("Scheduled time will come today only. And it will come before the current scheduled deck.")
+ NotificationWorkType.SAVE_AND_RESCHEDULE
+ } else {
+ Timber.d("Scheduled time will come today only. And it will come after the current scheduled deck.")
+ NotificationWorkType.SAVE
+ }
+ }
+
+ /**
+ * Calculates the notification time in MS and [NotificationWorkType] that needs to be done to trigger notification.
+ * Note: Time in the next 24 hours.
+ * @param hourOfDay Hour of day when notification trigger.
+ * @param minutesOfHour Minutes of day when notification trigger.
+ * @return Timestamp of the moment at which notification should be sent.
+ * */
+ private fun timestampToSchedule(
+ hourOfDay: Int,
+ minutesOfHour: Int,
+ ): Long {
+ val currentTimeMS = TimeManager.time.intTimeMS()
+ val notificationTimeInMS = hourMinuteTimestampToday(hourOfDay, minutesOfHour)
+ return if (notificationTimeInMS < currentTimeMS) {
+ Timber.d("Scheduled time is already passed for today")
+ // Notification time is passed for today.
+ // Save the notification for tomorrow. Just save it, no need to reschedule.
+ val calendar = TimeManager.time.calendar().apply {
+ this.set(HOUR_OF_DAY, hourOfDay)
+ this.set(MINUTE, minutesOfHour)
+ add(Calendar.DAY_OF_YEAR, 1)
+ }
+ calendar.timeInMillis
+ } else {
+ Timber.d("Scheduled time is not yet passed.")
+ notificationTimeInMS
+ }
+ }
+
+ /**
+ * Sets the schedule notification according to [NotificationWorkType].
+ * [NotificationWorkType.SAVE] can be used when we want new notification data to save
+ * but [NotificationWorkType.SAVE_AND_RESCHEDULE] can be used when we want to save and reschedule next worker
+ * */
+ private suspend inline fun saveScheduledNotification(
+ notificationDatastore: NotificationDatastore,
+ allTimeAndDecksMap: NotificationTodo,
+ notificationWorkType: NotificationWorkType,
+ ) {
+ // Save the data in data store
+ notificationDatastore.setTimeDeckData(allTimeAndDecksMap)
+
+ // Rescheduling work manager.
+ if (notificationWorkType == NotificationWorkType.SAVE_AND_RESCHEDULE) {
+ // Replacing old work manager with new one. (initialDiff = currentTime - nextTriggerTime)
+ val nextTriggerTime = NotificationWorker.getTriggerTime(allTimeAndDecksMap)
+ val initialDiff = nextTriggerTime - TimeManager.time.intTimeMS()
+ Timber.d("Next trigger time $nextTriggerTime")
+ startNotificationWorker(initialDiff, true)
+ }
+ }
+
+ /**
+ * Adds the did and schedule time in [NotificationTodo]
+ * */
+ private fun NotificationTodo.append(
+ scheduleTimeMS: Long,
+ did: DeckId
+ ) = getOrPut(scheduleTimeMS.toString()) { HashSet() }.add(did)
+
+ /**
+ * Recreates the deck time map.
+ * Generally used when user time zone changes.
+ * While scheduling the notification we saves the data in datastore So, Data in datastore will be always fresh.
+ * */
+ fun calibrateNotificationTime() {
+ CoroutineScope(Dispatchers.Default).launch {
+ Timber.d("Calibrating the time deck data...")
+ val notificationDatastore = NotificationDatastore.getInstance(context)
+
+ // Notification. Their timestamp may be wrong
+ val oldTimeDeckData: NotificationTodo =
+ notificationDatastore.getTimeDeckData()
+ ?: NotificationTodo()
+
+ // Notifications with timestamp adapted to the current timezone.
+ val newTimeDeckData = NotificationTodo()
+
+ // Filtering all deck ids from the map
+ val listOfDeck = oldTimeDeckData.values.flatten()
+ Timber.d("List of all deck %s".format(listOfDeck.toString()))
+
+ // Compute new sched timing for all deck ids.
+ for (deck in listOfDeck) {
+ val deckData = notificationDatastore.getDeckSchedData(deck)
+ ?: continue
+ val notificationData = notificationData(
+ deckData.schedHour,
+ deckData.schedMinutes,
+ newTimeDeckData
+ )
+ newTimeDeckData.append(notificationData.scheduleTime, deckData.did)
+ }
+
+ // Save and reschedule work manager.
+ saveScheduledNotification(
+ notificationDatastore,
+ newTimeDeckData,
+ NotificationWorkType.SAVE_AND_RESCHEDULE
+ )
+ }
+ }
+
+ /**
+ * Start the new notification worker i.e new Instance of [NotificationWorker]
+ * @param initialDelay delay after work manager should start.
+ * @param force if true then it will destroy the existing work manager and recreates the new one.
+ * true: @see [ExistingWorkPolicy.REPLACE], false: @see [ExistingWorkPolicy.KEEP]
+ * */
+ fun startNotificationWorker(initialDelay: Long, force: Boolean) {
+ Timber.d("Starting work manager with initial delay $initialDelay force: $force")
+
+ // Create a One Time Work Request with initial delay.
+ val deckMetaDataWorker = OneTimeWorkRequest.Builder(
+ NotificationWorker::class.java,
+ )
+ .addTag(TAG)
+ .setInitialDelay(initialDelay, TimeUnit.MILLISECONDS)
+ .build()
+
+ val workPolicy = if (force) {
+ ExistingWorkPolicy.REPLACE
+ } else {
+ ExistingWorkPolicy.KEEP
+ }
+
+ // Enqueue the periodic work manager.
+ WorkManager.getInstance(context)
+ .enqueueUniqueWork(
+ TAG,
+ workPolicy,
+ deckMetaDataWorker
+ )
+ }
+
+ /**
+ * Cancels the previously scheduled notification Worker.
+ * */
+ fun cancelScheduledDeckNotification() {
+ WorkManager.getInstance(context).cancelAllWorkByTag(TAG)
+ }
+
+ /**
+ * Cancel/Removes the deck id from the [NotificationTodo]. To avoid notification triggering.
+ * NOTE: It is a O(|deckIds| * numberOfDeckInNotification) and It is modifying NotificationDatastore, so no other change should be done at the same time.
+ * */
+ fun removeDeckNotification(vararg deckIds: DeckId) {
+ CoroutineScope(Dispatchers.Default).launch {
+ val notificationDatastore = NotificationDatastore.getInstance(context)
+ val allTimeAndDecksMap = notificationDatastore.getTimeDeckData() ?: return@launch
+ val notificationDataToDelete = mutableListOf()
+ deckIds.forEach { did ->
+ for (timeDeckData in allTimeAndDecksMap) {
+ if (!timeDeckData.value.contains(did)) {
+ // list of deck doesn't contains did.
+ break
+ }
+ // Deck id found delete the deck.
+ if (timeDeckData.value.size == 1) {
+ Timber.d("List of deck contains only one deck id.")
+ notificationDataToDelete.add(timeDeckData.key)
+ } else {
+ Timber.d("List of deck contains more than one deck ids.")
+ timeDeckData.value.remove(did)
+ }
+ }
+ }
+ notificationDataToDelete.forEach {
+ allTimeAndDecksMap.remove(it)
+ }
+ notificationDatastore.setTimeDeckData(allTimeAndDecksMap)
+ }
+ }
+
+ /**
+ * Triggers the notification immediately.
+ * It will check internally whether notification is enabled or not.
+ * @param id Notification id
+ * @param notification Notification which should be displayed.
+ * Build Notification using [NotificationHelper.buildNotification]
+ * */
+ fun triggerNotificationNow(id: Int, notification: Notification) {
+ val notificationManager =
+ context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val notificationManagerCompat = NotificationManagerCompat.from(context)
+
+ // Check notification is enabled or not.
+ if (!notificationManagerCompat.areNotificationsEnabled()) {
+ Timber.v("Notifications disabled")
+ return
+ }
+
+ notificationManager.notify(id, notification)
+ }
+
+ /**
+ * Builds the notification that is going to trigger.
+ * @param notificationChannel Channel on which notification should trigger.
+ * @param title Title of notification.
+ * @param body Text message for Body of Notification.
+ * @param pendingIntent Activity which need to open on notification tap.
+ * */
+ fun buildNotification(
+ notificationChannel: NotificationChannels.Channel,
+ title: String,
+ body: String,
+ pendingIntent: PendingIntent
+ ) = NotificationCompat.Builder(
+ context,
+ NotificationChannels.getId(notificationChannel)
+ ).apply {
+ setCategory(NotificationCompat.CATEGORY_REMINDER)
+ setContentTitle(title)
+ setContentText(body)
+ setSmallIcon(R.drawable.ic_stat_notify)
+ color = ContextCompat.getColor(context, R.color.material_light_blue_700)
+ setContentIntent(pendingIntent)
+ setAutoCancel(true)
+ }.build()
+
+ /**
+ * Work that needs to be done so that notification can trigger.
+ * SAVE: It only saves the data in notification datastore.
+ * SAVE_AND_RESCHEDULE: Saves the data and reschedule the notification work manager.
+ * @see [saveScheduledNotification] for enum implementation.
+ * */
+ private enum class NotificationWorkType {
+ SAVE,
+ SAVE_AND_RESCHEDULE,
+ }
+
+ /**
+ * Data of notification that need to be scheduled.
+ * */
+ private data class NotificationData(
+ val scheduleTime: Long,
+ val notificationWorkType: NotificationWorkType
+ )
+}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/model/DeckNotification.kt b/AnkiDroid/src/main/java/com/ichi2/anki/model/DeckNotification.kt
new file mode 100644
index 000000000000..20a542789d95
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/model/DeckNotification.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2022 Prateek Singh
+ *
+ * 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 .
+ */
+
+package com.ichi2.anki.model
+
+import com.ichi2.libanki.DeckId
+import com.ichi2.libanki.Decks
+
+/**
+ * Notification details of particular deck.
+ * */
+data class DeckNotification(
+ val enabled: Boolean,
+ val did: DeckId,
+ val schedHour: Int,
+ val schedMinutes: Int,
+ val minCardDue: Int
+) {
+
+ // Empty constructor. Required for jackson to serialize.
+ constructor() : this(
+ false,
+ Decks.NOT_FOUND_DECK_ID,
+ 0,
+ 0,
+ 0
+ )
+}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/receiver/TimeZoneChangeReceiver.kt b/AnkiDroid/src/main/java/com/ichi2/anki/receiver/TimeZoneChangeReceiver.kt
new file mode 100644
index 000000000000..9121c4a46173
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/receiver/TimeZoneChangeReceiver.kt
@@ -0,0 +1,95 @@
+/*
+ * Copyright (c) 2022 Prateek Singh
+ *
+ * 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 .
+ */
+
+package com.ichi2.anki.receiver
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import com.ichi2.anki.NotificationDatastore
+import com.ichi2.anki.NotificationHelper
+import com.ichi2.libanki.utils.TimeManager
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import java.util.*
+
+/**
+ * Broadcast receiver to listen timezone change of user.
+ * Changes the notification triggering time when user timezone changes.
+ * */
+class TimeZoneChangeReceiver : BroadcastReceiver() {
+
+ override fun onReceive(context: Context, intent: Intent) {
+ Timber.d("TimeZoneChangeReceiver Started...")
+
+ CoroutineScope(Dispatchers.Default).launch {
+ val notificationDatastore = NotificationDatastore.getInstance(context)
+
+ val oldTimezone: String = notificationDatastore.getString(TIMEZONE_KEY, "null")
+ val newTimezone: String = TimeZone.getDefault().id
+ val now = TimeManager.time.intTimeMS()
+
+ /**
+ * Get the current offset according to given TimeZone.
+ * */
+ fun getOffsetFromNow(timezone: String) = TimeZone.getTimeZone(timezone).getOffset(now)
+
+ if (oldTimezone != "null" || getOffsetFromNow(oldTimezone) == getOffsetFromNow(newTimezone)) {
+ Timber.d("No Timezone changed found...")
+ return@launch
+ }
+
+ Timber.d("Timezone changed...")
+ // Change the timezone to new timezone and recalculate the notification according to new time zone.
+ notificationDatastore.putStringAsync(TIMEZONE_KEY, newTimezone)
+
+ // Calibrate notification time.
+ NotificationHelper(context).calibrateNotificationTime()
+ }
+ }
+
+ companion object {
+ /**
+ * Key to access user's Time Zone from datastore.
+ * */
+ private const val TIMEZONE_KEY = "TIMEZONE"
+
+ /**
+ * Registers a receiver for notification about timezone change.
+ * This method should be called on app start.
+ * */
+ fun registerTimeZoneChangeReceiver(context: Context, receiver: BroadcastReceiver) {
+ Timber.d("Registering Timezone change receiver...")
+
+ val filter = IntentFilter(Intent.ACTION_TIMEZONE_CHANGED)
+ context.registerReceiver(receiver, filter)
+ }
+
+ /**
+ * Get current time according the timezone of user.
+ * @zoneId @see [TimeZone.getTimeZone]
+ * @return Time since epoch in milliseconds
+ * */
+ fun getZonedCurrTimeMS(zoneId: String): Long {
+ val currTimeMS = TimeManager.time.intTimeMS()
+ val offset = TimeZone.getTimeZone(zoneId).getOffset(currTimeMS)
+ return currTimeMS + offset
+ }
+ }
+}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/worker/NotificationWorker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/worker/NotificationWorker.kt
new file mode 100644
index 000000000000..195d177cad7b
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/worker/NotificationWorker.kt
@@ -0,0 +1,266 @@
+/*
+ * Copyright (c) 2022 Prateek Singh
+ *
+ * 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 .
+ */
+
+package com.ichi2.anki.worker
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+import com.ichi2.anki.*
+import com.ichi2.anki.CollectionManager.withCol
+import com.ichi2.anki.services.ReminderService
+import com.ichi2.anki.worker.NotificationWorker.Companion.getTriggerTime
+import com.ichi2.compat.CompatHelper
+import com.ichi2.libanki.sched.Counts
+import com.ichi2.libanki.sched.DeckDueTreeNode
+import com.ichi2.libanki.utils.TimeManager
+import timber.log.Timber
+import java.util.*
+
+/**
+ * Worker class to collect the data for notification and triggers all the notifications.
+ * It will calculate the next time of execution after completing the task of current execution i.e Execution time is dynamic.
+ * It will run at most in one hour. Sooner if we'll have notification to send earlier @see [getTriggerTime].
+ * If the device is switched off at the time of notification then Notification work manager will run whenever
+ * devices gets turned on and it will fire all the previous pending notifications.
+ * If there is no deck notification within 1 hour then also notification work manager will run after 1 hour to trigger all deck notification.
+ * **NOTE: It is a coroutine worker i.e it will run asynchronously on the Dispatcher.DEFAULT**
+ * */
+class NotificationWorker(val context: Context, workerParameters: WorkerParameters) :
+ CoroutineWorker(context, workerParameters) {
+
+ /**
+ * Send all notifications and schedule next worker.
+ * */
+ override suspend fun doWork() = notifies().also {
+ rescheduleNextWorker()
+ }
+
+ /**
+ * Send single and all deck notifications.
+ * */
+ suspend fun notifies(): Result {
+ Timber.d("NotificationManagerWorker: Worker status -> STARTED")
+
+ // Collect the deck details
+ val topLevelDecks = try {
+ withCol {
+ sched.deckDueTree().map { it.value }
+ }
+ } catch (ex: Exception) {
+ // Unable to access collection
+ return Result.failure()
+ }
+
+ processSingleDeckNotifications(topLevelDecks)
+ fireAllDeckNotification(topLevelDecks)
+
+ Timber.d("NotificationManagerWorker: Worker status -> FINISHED")
+ return Result.success() // Done work successfully...
+ }
+
+ /**
+ * Trigger all top level decks whose notification is due and has card.
+ * Replaying those decks 24 hours later
+ * Remove decks which are not top level anymore from notification setting.
+ * */
+ private suspend fun processSingleDeckNotifications(topLevelDecks: List) {
+ Timber.d("Processing single deck notification...")
+ val currentTime = TimeManager.time.currentDate.time
+
+ // Collect all the notification data which need to triggered.
+ val timeDeckData = NotificationDatastore.getInstance(context).getTimeDeckData()
+ if (timeDeckData.isNullOrEmpty()) {
+ Timber.d("No time deck data found, not firing any notifications")
+ return
+ }
+
+ // Filtered all the decks whose notification time is less than current time.
+ val timeDeckDataToTrigger = timeDeckData.filterTo(HashMap()) { it.key.toLong() <= currentTime }
+
+ // Creating hash set of all decks whose notification is going to trigger
+ val deckIdsToTrigger = timeDeckDataToTrigger
+ .flatMap { it.value }
+ .toHashSet()
+
+ // Sorting deck notification data with help of deck id.
+ val deckNotificationData = topLevelDecks.filter { deckIdsToTrigger.contains(it.did) }
+
+ // Triggering the deck notification
+ for (deck in deckNotificationData) {
+ fireDeckNotification(deck)
+ deckIdsToTrigger.remove(deck.did)
+ }
+
+ // Decks may have been deleted. This means that there are decks that should have been triggered but were not present.
+ // The deck may not be here without having be deleted. The only thing we know is that it's not at top level
+ if (deckIdsToTrigger.isNotEmpty()) {
+ Timber.d(
+ "Decks %s might be deleted but we didn't cancel deck notification for those decks. Canceling deck notification for these decks.".format(
+ deckIdsToTrigger.toString()
+ )
+ )
+ NotificationHelper(context).removeDeckNotification(
+ deckIds = deckIdsToTrigger.toLongArray()
+ )
+ }
+
+ // Updating time for next trigger.
+ val calendar = TimeManager.time.calendar()
+ timeDeckDataToTrigger.forEach {
+ timeDeckData.remove(it.key)
+ calendar.timeInMillis = it.key.toLong()
+ calendar.add(Calendar.DAY_OF_YEAR, 1)
+ // TODO: We should ensure that we only plan the next notification for a time in the future.
+ timeDeckData[calendar.timeInMillis.toString()] = it.value
+ }
+
+ // Saving the new Time Deck Data.
+ NotificationDatastore.getInstance(context).setTimeDeckData(timeDeckData)
+ }
+
+ /**
+ * Fire the notification for [deck] if needed.
+ * We consider it is needed if [deck] or any of its subdecks have cards.
+ * Even if subdecks have cards, we only trigger notification for [deck] itself, not for the subdecks
+ */
+ private fun fireDeckNotification(deck: DeckDueTreeNode) {
+ Timber.d("Firing deck notification for did -> %d", deck.did)
+ val notificationHelper = NotificationHelper(context)
+
+ val title = context.getString(R.string.reminder_title)
+ val counts =
+ Counts(deck.newCount, deck.lrnCount, deck.revCount)
+ val message = context.resources.getQuantityString(
+ R.plurals.reminder_text,
+ counts.count(),
+ deck.fullDeckName
+ )
+
+ // TODO: Check the minimum no. of cards to send notification. This will be Implemented after successful Implementation of Deck Notification UI.
+
+ val resultIntent = Intent(context, IntentHandler::class.java).apply {
+ putExtra(ReminderService.EXTRA_DECK_ID, deck.did)
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ }
+ val resultPendingIntent = CompatHelper.compat.getImmutableActivityIntent(
+ context, 0, resultIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT
+ )
+
+ // Build and fire notification.
+ val notification = notificationHelper.buildNotification(
+ NotificationChannels.Channel.GENERAL,
+ title,
+ message,
+ resultPendingIntent
+ )
+
+ notificationHelper.triggerNotificationNow(INDIVIDUAL_DECK_NOTIFICATION, notification)
+ }
+
+ /**
+ * Fire a notification stating that there remains at least [minCardsDue] in the collection
+ * */
+ private fun fireAllDeckNotification(topLevelDecks: List) {
+ Timber.d("Firing all deck notification.")
+ val notificationHelper = NotificationHelper(context)
+
+ val preferences = AnkiDroidApp.getSharedPrefs(context)
+ val minCardsDue = preferences.getInt(
+ Preferences.MINIMUM_CARDS_DUE_FOR_NOTIFICATION,
+ Preferences.PENDING_NOTIFICATIONS_ONLY
+ )
+
+ // All Decks Notification.
+ val totalDueCount = Counts()
+ topLevelDecks.forEach {
+ totalDueCount.addLrn(it.lrnCount)
+ totalDueCount.addNew(it.newCount)
+ totalDueCount.addRev(it.revCount)
+ }
+
+ val deckPickerIntent = Intent(context, DeckPicker::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ }
+ val resultPendingIntent = CompatHelper.compat.getImmutableActivityIntent(
+ context, 0, deckPickerIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT
+ )
+
+ if (totalDueCount.count() < minCardsDue) {
+ // Due card limit is higher.
+ return
+ }
+
+ val notification = notificationHelper.buildNotification(
+ NotificationChannels.Channel.GENERAL,
+ context.resources.getString(R.string.all_deck_notification_new_title),
+ context.resources.getQuantityString(
+ R.plurals.all_deck_notification_new_message,
+ totalDueCount.count()
+ ),
+ resultPendingIntent
+ )
+
+ notificationHelper.triggerNotificationNow(ALL_DECK_NOTIFICATION_ID, notification)
+ }
+
+ /**
+ * Reschedule Next Worker. It is calculated on the basis of [NotificationTodo] data by [getTriggerTime]
+ * */
+ private suspend fun rescheduleNextWorker() {
+ Timber.d("Task Completed. Rescheduling...")
+ val notificationDatastore = NotificationDatastore.getInstance(context)
+ val timeAndDeckData = notificationDatastore.getTimeDeckData() ?: NotificationTodo()
+
+ val nextTriggerTime = getTriggerTime(timeAndDeckData)
+ val initialDiff = TimeManager.time.intTimeMS() - nextTriggerTime
+
+ Timber.d("Next trigger time $nextTriggerTime in $initialDiff ms")
+ NotificationHelper(context).startNotificationWorker(initialDiff, true)
+ }
+
+ companion object {
+ const val ONE_HOUR_MS = 60 * 60 * 1000
+ private const val ALL_DECK_NOTIFICATION_ID = 11
+ private const val INDIVIDUAL_DECK_NOTIFICATION = 22
+
+ /**
+ * Calculates the next time to trigger the Notification WorkManager.
+ * it's not the next time a notification should be shown, but a time at most in one hour to check all deck notification.
+ * @param allTimeDeckData Mapping from time to the list of decks whose notification should be sent at this time. [NotificationTodo]
+ * @return next trigger time in milliseconds.
+ * */
+ fun getTriggerTime(allTimeDeckData: NotificationTodo): Long {
+ val currentTime = TimeManager.time.currentDate.time
+
+ val nextTimeKey = allTimeDeckData.keys.firstOrNull { it.toLong() >= currentTime }
+ ?: return currentTime + ONE_HOUR_MS // No deck within 1 hour. Restarting after 1 hour for all deck notification
+
+ val timeDiff = nextTimeKey.toLong() - currentTime
+
+ return if (timeDiff < ONE_HOUR_MS) {
+ nextTimeKey.toLong()
+ } else {
+ // No deck is scheduled in next hour. Restart service after 1 hour.
+ currentTime + ONE_HOUR_MS
+ }
+ }
+ }
+}
diff --git a/AnkiDroid/src/main/java/com/ichi2/libanki/sched/SchedV2.java b/AnkiDroid/src/main/java/com/ichi2/libanki/sched/SchedV2.java
index 1259137b2801..248b8cb510d4 100644
--- a/AnkiDroid/src/main/java/com/ichi2/libanki/sched/SchedV2.java
+++ b/AnkiDroid/src/main/java/com/ichi2/libanki/sched/SchedV2.java
@@ -54,7 +54,6 @@
import java.lang.ref.WeakReference;
import java.util.ArrayList;
-import java.util.Calendar;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedList;
diff --git a/AnkiDroid/src/main/res/values/02-strings.xml b/AnkiDroid/src/main/res/values/02-strings.xml
index 6f30a2d19074..2bd65c4bc13e 100644
--- a/AnkiDroid/src/main/res/values/02-strings.xml
+++ b/AnkiDroid/src/main/res/values/02-strings.xml
@@ -423,4 +423,10 @@
Sync from AnkiWeb
Already logged in
+
+ You have card due
+
+ - %1$s card due
+ - %1$s cards due
+