From 30684d92b538e4f0f8dcdac2767ce483b9c2f11a Mon Sep 17 00:00:00 2001 From: Prateek Singh <76490368+prateek-singh-3212@users.noreply.github.com> Date: Mon, 27 Jun 2022 01:14:05 +0530 Subject: [PATCH 1/7] Integrated WorkManager & Datastore Preference Library. This commit simply adds all the tools we'll need to use datastore in workers. Gradle, manifest, and changing AnkidroidApp --- AnkiDroid/build.gradle | 2 ++ AnkiDroid/src/main/AndroidManifest.xml | 13 +++++++++++++ .../src/main/java/com/ichi2/anki/AnkiDroidApp.kt | 14 +++++++++++++- 3 files changed, 28 insertions(+), 1 deletion(-) 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..cd90a4c0c2a7 100644 --- a/AnkiDroid/src/main/AndroidManifest.xml +++ b/AnkiDroid/src/main/AndroidManifest.xml @@ -522,6 +522,19 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/filepaths" /> + + + + + diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt index a6a4c6bad452..6e6871523750 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt @@ -56,7 +56,7 @@ import java.util.regex.Pattern */ @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 +183,11 @@ open class AnkiDroidApp : Application() { } } } + // 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)) + // 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 +215,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. * From 35f2c01fcbf2a22e1ff562eee215855191eb604c Mon Sep 17 00:00:00 2001 From: Prateek Singh <76490368+prateek-singh-3212@users.noreply.github.com> Date: Mon, 27 Jun 2022 01:23:37 +0530 Subject: [PATCH 2/7] Implemented Notification Preference Datastore. Notification preference datastore is used to store all the data related to notification in the preference datastore. We both introduce helper methods to deal with primitive type, and an ad-hoc type to represents notification times for each decks in a way that is simple to store in the datastore and efficient to process. --- .../com/ichi2/anki/NotificationDatastore.kt | 266 ++++++++++++++++++ .../com/ichi2/anki/model/DeckNotification.kt | 41 +++ 2 files changed, 307 insertions(+) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/NotificationDatastore.kt create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/model/DeckNotification.kt 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/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 + ) +} From 139b02cabff56516d552861f977b970c8414eac6 Mon Sep 17 00:00:00 2001 From: Prateek Singh <76490368+prateek-singh-3212@users.noreply.github.com> Date: Tue, 28 Jun 2022 15:44:24 +0530 Subject: [PATCH 3/7] Implemented NotificationWork Manager Logic. * Worker to fire deck notification which is pending at time of execution of worker. * This worker also fire all deck notification when the due card is greater than min due cards. * After completion of work next worker is scheduled and time of next worker is calculated based upon the NotificationTodo data. * Also cleans decks that are not at top level anymore, so that should not leads to notification. --- .../ichi2/anki/worker/NotificationWorker.kt | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/worker/NotificationWorker.kt 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..b259315623d7 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/worker/NotificationWorker.kt @@ -0,0 +1,219 @@ +/* + * 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.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.ichi2.anki.* +import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.worker.NotificationWorker.Companion.getTriggerTime +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() + ) + ) + // TODO: Cancel deck notification when user deletes a particular deck to handle case [user deletes a deck between the notification being added and executed] + } + + // 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 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: Remove log used for now to remove compilation error. + Timber.d("$title $counts $message") + // TODO: Check the minimum no. of cards to send notification. + // TODO: Build and fire 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 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) + } + + if (totalDueCount.count() < minCardsDue) { + // Due card limit is higher. + return + } + // TODO: Build & Fire all deck 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") + // TODO: Start work manager with initial delay though Notification Helper. + } + + companion object { + const val ONE_HOUR_MS = 60 * 60 * 1000 + + /** + * 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 + } + } + } +} From 328e52e553ed64c12e7f752c64eb884c69e58e0d Mon Sep 17 00:00:00 2001 From: Prateek Singh <76490368+prateek-singh-3212@users.noreply.github.com> Date: Tue, 28 Jun 2022 16:12:16 +0530 Subject: [PATCH 4/7] Implemented NotificationHelper to Manage all Notifications --- .../java/com/ichi2/anki/NotificationHelper.kt | 376 ++++++++++++++++++ 1 file changed, 376 insertions(+) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/NotificationHelper.kt 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 + ) +} From a7a1c6091bd3fe475acba998ff059a629e75e52b Mon Sep 17 00:00:00 2001 From: Prateek Singh <108252818+prateek-web2native@users.noreply.github.com> Date: Sat, 16 Jul 2022 17:59:43 +0530 Subject: [PATCH 5/7] Implemented Calibrated notification. This is used in particular for Timezone Change broadcast receiver. Ensure notification are adapted to local timezone instead of absolute time. This is required because Android store notification in GMT time. --- AnkiDroid/src/main/AndroidManifest.xml | 6 ++ .../main/java/com/ichi2/anki/AnkiDroidApp.kt | 5 + .../anki/receiver/TimeZoneChangeReceiver.kt | 95 +++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/receiver/TimeZoneChangeReceiver.kt diff --git a/AnkiDroid/src/main/AndroidManifest.xml b/AnkiDroid/src/main/AndroidManifest.xml index cd90a4c0c2a7..48a94117dced 100644 --- a/AnkiDroid/src/main/AndroidManifest.xml +++ b/AnkiDroid/src/main/AndroidManifest.xml @@ -475,6 +475,12 @@ + + + + + + * + * 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 + } + } +} From 44907dfa1ef368d2fe83f7cd250ba84cdd1dfd01 Mon Sep 17 00:00:00 2001 From: Prateek Singh <76490368+prateek-singh-3212@users.noreply.github.com> Date: Tue, 28 Jun 2022 17:52:29 +0530 Subject: [PATCH 6/7] Integrated NotificationWorkManager with NotificationHelper --- .../ichi2/anki/worker/NotificationWorker.kt | 61 ++++++++++++++++--- .../java/com/ichi2/libanki/sched/SchedV2.java | 1 - AnkiDroid/src/main/res/values/02-strings.xml | 6 ++ 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/worker/NotificationWorker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/worker/NotificationWorker.kt index b259315623d7..195d177cad7b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/worker/NotificationWorker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/worker/NotificationWorker.kt @@ -16,12 +16,16 @@ 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 @@ -111,7 +115,9 @@ class NotificationWorker(val context: Context, workerParameters: WorkerParameter deckIdsToTrigger.toString() ) ) - // TODO: Cancel deck notification when user deletes a particular deck to handle case [user deletes a deck between the notification being added and executed] + NotificationHelper(context).removeDeckNotification( + deckIds = deckIdsToTrigger.toLongArray() + ) } // Updating time for next trigger. @@ -135,6 +141,8 @@ class NotificationWorker(val context: Context, workerParameters: WorkerParameter */ 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) @@ -144,10 +152,26 @@ class NotificationWorker(val context: Context, workerParameters: WorkerParameter deck.fullDeckName ) - // TODO: Remove log used for now to remove compilation error. - Timber.d("$title $counts $message") - // TODO: Check the minimum no. of cards to send notification. - // TODO: Build and fire notification. + // 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) } /** @@ -155,6 +179,8 @@ class NotificationWorker(val context: Context, workerParameters: WorkerParameter * */ 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, @@ -169,11 +195,30 @@ class NotificationWorker(val context: Context, workerParameters: WorkerParameter 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 } - // TODO: Build & Fire all deck notification. + + 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) } /** @@ -188,11 +233,13 @@ class NotificationWorker(val context: Context, workerParameters: WorkerParameter val initialDiff = TimeManager.time.intTimeMS() - nextTriggerTime Timber.d("Next trigger time $nextTriggerTime in $initialDiff ms") - // TODO: Start work manager with initial delay though Notification Helper. + 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. 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 + From 59023e5342fc81027464c4ff8d191402f56c93ce Mon Sep 17 00:00:00 2001 From: Prateek Singh <76490368+prateek-singh-3212@users.noreply.github.com> Date: Fri, 1 Jul 2022 23:55:01 +0530 Subject: [PATCH 7/7] Established Connection of Notification WorkManager in AnkiDroidApp When AnkiDroid starts, notification worker will start if not scheduled previously. Note that currently we can't add any deck in the datastore, so no notification will occur until we actually start to add data. --- AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt index 3fced97690d4..3991b86bf52a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt @@ -186,6 +186,10 @@ open class AnkiDroidApp : Application(), androidx.work.Configuration.Provider { } } } + + 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))