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 +