diff --git a/AnkiDroid/build.gradle b/AnkiDroid/build.gradle index 55480fc75f3c..9b84e02470f4 100644 --- a/AnkiDroid/build.gradle +++ b/AnkiDroid/build.gradle @@ -389,6 +389,7 @@ dependencies { implementation project(":compat") implementation project(":libanki") implementation project(":vbpd") + implementation project(":widgets") implementation libs.androidx.activity implementation libs.androidx.annotation diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt index 3c7ef70fc8b9..d69fe687215d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt @@ -59,6 +59,7 @@ import com.ichi2.anki.services.AlarmManagerService import com.ichi2.anki.services.NotificationService import com.ichi2.anki.settings.Prefs import com.ichi2.anki.ui.dialogs.ActivityAgnosticDialogs +import com.ichi2.anki.widget.initWidgetDependencies import com.ichi2.utils.AdaptionUtil import com.ichi2.utils.ExceptionUtil import com.ichi2.utils.LanguageUtil @@ -128,6 +129,9 @@ open class AnkiDroidApp : } instance = this + // Initialize widget bridge dependencies + initWidgetDependencies() + // Get preferences val preferences = this.sharedPrefs() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/widget/WidgetDependenciesImpl.kt b/AnkiDroid/src/main/java/com/ichi2/anki/widget/WidgetDependenciesImpl.kt new file mode 100644 index 000000000000..121b1b06de48 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/widget/WidgetDependenciesImpl.kt @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2026 Ashish Yadav + * + * 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.widget + +import android.app.Application +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import com.ichi2.anki.AnkiDroidApp +import com.ichi2.anki.CollectionManager +import com.ichi2.anki.CrashReportService +import com.ichi2.anki.IntentHandler +import com.ichi2.anki.IntentHandler.Companion.intentToReviewDeckFromShortcuts +import com.ichi2.anki.MetaDB +import com.ichi2.anki.R +import com.ichi2.anki.libanki.Collection +import com.ichi2.anki.libanki.DeckId +import com.ichi2.anki.noteeditor.NoteEditorLauncher +import com.ichi2.anki.pages.DeckOptionsDestination +import com.ichi2.anki.preferences.sharedPrefs +import com.ichi2.anki.settings.Prefs +import com.ichi2.widget.AddNoteWidget +import com.ichi2.widget.AnkiDroidWidgetSmall +import com.ichi2.widget.SmallWidgetStatus +import com.ichi2.widget.bridge.WidgetAnalytics +import com.ichi2.widget.bridge.WidgetAppState +import com.ichi2.widget.bridge.WidgetCollectionAccess +import com.ichi2.widget.bridge.WidgetCrashReporter +import com.ichi2.widget.bridge.WidgetDependencies +import com.ichi2.widget.bridge.WidgetIntentFactory +import com.ichi2.widget.bridge.WidgetMetaStorage +import com.ichi2.widget.bridge.WidgetPreferences +import com.ichi2.widget.getAppWidgetIdsEx +import com.ichi2.widget.getAppWidgetManager +import kotlinx.coroutines.CoroutineScope + +class WidgetAnalyticsImpl : WidgetAnalytics { + override fun sendAnalyticsEvent( + category: String, + action: String, + value: Int?, + label: String?, + ) { + com.ichi2.anki.analytics.UsageAnalytics + .sendAnalyticsEvent(category, action, value, label) + } +} + +class WidgetIntentFactoryImpl : WidgetIntentFactory { + override fun grantedStoragePermissions( + context: Context, + showToast: Boolean, + ): Boolean = IntentHandler.grantedStoragePermissions(context, showToast) + + override fun intentToReviewDeck( + context: Context, + deckId: DeckId, + ): Intent = intentToReviewDeckFromShortcuts(context, deckId) + + override fun intentToOpenNoteEditor(context: Context): Intent = NoteEditorLauncher.AddNote().toIntent(context) + + override fun intentToMainActivity(context: Context): Intent = + Intent(context, IntentHandler::class.java).apply { + action = Intent.ACTION_MAIN + addCategory(Intent.CATEGORY_LAUNCHER) + } + + override suspend fun intentToDeckOptions( + context: Context, + deckId: DeckId, + ): Intent = DeckOptionsDestination.fromDeckId(deckId).toIntent(context) +} + +class WidgetCollectionAccessImpl : WidgetCollectionAccess { + override suspend fun withCol(block: Collection.() -> T): T = CollectionManager.withCol(block) + + override suspend fun isCollectionEmpty(): Boolean = com.ichi2.anki.isCollectionEmpty() +} + +class WidgetAppStateImpl : WidgetAppState { + override val isSdCardMounted: Boolean + get() = AnkiDroidApp.isSdCardMounted + + override val applicationScope: CoroutineScope + get() = AnkiDroidApp.applicationScope + + override val applicationInstance: Application + get() = AnkiDroidApp.instance + + override fun scheduleNotification(context: Context) { + (context.applicationContext as AnkiDroidApp).scheduleNotification() + } + + override fun updateSmallWidgetUi(context: Context) { + AnkiDroidWidgetSmall + .UpdateService() + .doUpdate(context) + } + + override fun updateAddNoteWidgets(context: Context) { + val appWidgetManager = getAppWidgetManager(context) ?: return + val widgetIds = + appWidgetManager.getAppWidgetIdsEx( + android.content.ComponentName(context, AddNoteWidget::class.java), + ) + AddNoteWidget + .updateWidgets(context, appWidgetManager, widgetIds) + } +} + +class WidgetCrashReporterImpl : WidgetCrashReporter { + override fun sendExceptionReport( + throwable: Throwable, + origin: String, + onlyIfSilent: Boolean, + ) { + CrashReportService.sendExceptionReport(throwable, origin, onlyIfSilent = onlyIfSilent) + } +} + +class WidgetMetaStorageImpl : WidgetMetaStorage { + override fun storeSmallWidgetStatus( + context: Context, + status: SmallWidgetStatus, + ) { + MetaDB.storeSmallWidgetStatus(context, status) + } + + override fun getWidgetSmallStatus(context: Context): SmallWidgetStatus = MetaDB.getWidgetSmallStatus(context) + + override fun getNotificationStatus(context: Context): Int = MetaDB.getNotificationStatus(context) +} + +class WidgetPreferencesImpl : WidgetPreferences { + override fun sharedPrefs(context: Context): SharedPreferences = context.sharedPrefs() + + override val newReviewRemindersEnabled: Boolean + get() = Prefs.newReviewRemindersEnabled + + override fun isLegacyNotificationEnabled(context: Context): Boolean { + val preferences = context.sharedPrefs() + return preferences + .getString(context.getString(R.string.pref_notifications_minimum_cards_due_key), "1000001")!! + .toInt() < 1000000 + } +} + +/** + * Initializes [WidgetDependencies] with real app-module implementations. + * Should be called from [AnkiDroidApp.onCreate]. + */ +fun initWidgetDependencies() { + WidgetDependencies.init( + analytics = WidgetAnalyticsImpl(), + intentFactory = WidgetIntentFactoryImpl(), + collectionAccess = WidgetCollectionAccessImpl(), + appState = WidgetAppStateImpl(), + crashReporter = WidgetCrashReporterImpl(), + metaStorage = WidgetMetaStorageImpl(), + preferences = WidgetPreferencesImpl(), + ) +} diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/AddNoteWidget.kt b/AnkiDroid/src/main/java/com/ichi2/widget/AddNoteWidget.kt index 81e0b51ae662..508b0b5f06d7 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/AddNoteWidget.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/AddNoteWidget.kt @@ -19,15 +19,15 @@ import android.content.Context import android.widget.RemoteViews import androidx.core.app.PendingIntentCompat import com.ichi2.anki.R -import com.ichi2.anki.analytics.UsageAnalytics -import com.ichi2.anki.noteeditor.NoteEditorLauncher +import com.ichi2.widget.bridge.WidgetAnalytics +import com.ichi2.widget.bridge.WidgetDependencies class AddNoteWidget : AnalyticsWidgetProvider() { override fun performUpdate( context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: AppWidgetIds, - usageAnalytics: UsageAnalytics, + usageAnalytics: WidgetAnalytics, ) { updateWidgets(context, appWidgetManager, appWidgetIds) } @@ -47,7 +47,7 @@ class AddNoteWidget : AnalyticsWidgetProvider() { appWidgetIds: AppWidgetIds, ) { val remoteViews = RemoteViews(context.packageName, R.layout.widget_add_note) - val intent = NoteEditorLauncher.AddNote().toIntent(context) + val intent = WidgetDependencies.intentFactory.intentToOpenNoteEditor(context) val pendingIntent = PendingIntentCompat.getActivity(context, 0, intent, 0, false) remoteViews.setOnClickPendingIntent(R.id.widget_add_note_button, pendingIntent) appWidgetManager.updateAppWidget(appWidgetIds, remoteViews) diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/AnkiDroidWidgetSmall.kt b/AnkiDroid/src/main/java/com/ichi2/widget/AnkiDroidWidgetSmall.kt index 7166d0bf929b..3026d2a21c0e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/AnkiDroidWidgetSmall.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/AnkiDroidWidgetSmall.kt @@ -32,12 +32,9 @@ import androidx.annotation.LayoutRes import androidx.core.app.PendingIntentCompat import androidx.core.content.ContextCompat import androidx.core.content.edit -import com.ichi2.anki.AnkiDroidApp -import com.ichi2.anki.IntentHandler import com.ichi2.anki.R -import com.ichi2.anki.analytics.UsageAnalytics -import com.ichi2.anki.compat.CompatHelper.Companion.registerReceiverCompat -import com.ichi2.anki.preferences.sharedPrefs +import com.ichi2.widget.bridge.WidgetAnalytics +import com.ichi2.widget.bridge.WidgetDependencies import timber.log.Timber import kotlin.math.sqrt @@ -52,20 +49,20 @@ class AnkiDroidWidgetSmall : AnalyticsWidgetProvider() { context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: AppWidgetIds, - usageAnalytics: UsageAnalytics, + usageAnalytics: WidgetAnalytics, ) { WidgetStatus.updateInBackground(context) } override fun onEnabled(context: Context) { super.onEnabled(context) - val preferences = context.sharedPrefs() + val preferences = WidgetDependencies.preferences.sharedPrefs(context) preferences.edit(commit = true) { putBoolean("widgetSmallEnabled", true) } } override fun onDisabled(context: Context) { super.onDisabled(context) - val preferences = context.sharedPrefs() + val preferences = WidgetDependencies.preferences.sharedPrefs(context) preferences.edit(commit = true) { putBoolean("widgetSmallEnabled", false) } } @@ -110,7 +107,7 @@ class AnkiDroidWidgetSmall : AnalyticsWidgetProvider() { private fun buildUpdate(context: Context): RemoteViews { Timber.d("updating small widget UI") val updateViews = RemoteViews(context.packageName, widgetSmallLayout) - val mounted = AnkiDroidApp.isSdCardMounted + val mounted = WidgetDependencies.appState.isSdCardMounted if (!mounted) { updateViews.setViewVisibility(R.id.widget_due, View.INVISIBLE) updateViews.setViewVisibility(R.id.widget_eta, View.INVISIBLE) @@ -128,10 +125,10 @@ class AnkiDroidWidgetSmall : AnalyticsWidgetProvider() { if (action != null && action == Intent.ACTION_MEDIA_MOUNTED) { Timber.d("mountReceiver - Action = Media Mounted") if (remounted) { - WidgetStatus.updateInBackground(AnkiDroidApp.instance) + WidgetStatus.updateInBackground(WidgetDependencies.appState.applicationInstance) remounted = false if (mountReceiver != null) { - AnkiDroidApp.instance.unregisterReceiver(mountReceiver) + WidgetDependencies.appState.applicationInstance.unregisterReceiver(mountReceiver) } } else { remounted = true @@ -142,7 +139,12 @@ class AnkiDroidWidgetSmall : AnalyticsWidgetProvider() { val iFilter = IntentFilter() iFilter.addAction(Intent.ACTION_MEDIA_MOUNTED) iFilter.addDataScheme("file") - AnkiDroidApp.instance.registerReceiverCompat(mountReceiver, iFilter, ContextCompat.RECEIVER_EXPORTED) + ContextCompat.registerReceiver( + WidgetDependencies.appState.applicationInstance, + mountReceiver, + iFilter, + ContextCompat.RECEIVER_EXPORTED, + ) } } else { // Compute the total number of cards due. @@ -176,9 +178,7 @@ class AnkiDroidWidgetSmall : AnalyticsWidgetProvider() { // Add a click listener to open Anki from the icon. // This should be always there, whether there are due cards or not. - val ankiDroidIntent = Intent(context, IntentHandler::class.java) - ankiDroidIntent.action = Intent.ACTION_MAIN - ankiDroidIntent.addCategory(Intent.CATEGORY_LAUNCHER) + val ankiDroidIntent = WidgetDependencies.intentFactory.intentToMainActivity(context) val pendingAnkiDroidIntent = PendingIntentCompat.getActivity( context, diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/WidgetConfigScreenAdapter.kt b/AnkiDroid/src/main/java/com/ichi2/widget/WidgetConfigScreenAdapter.kt index afaf3bbc09b6..519b3834d087 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/WidgetConfigScreenAdapter.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/WidgetConfigScreenAdapter.kt @@ -19,11 +19,11 @@ package com.ichi2.widget import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView -import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.common.utils.ext.indexOfOrNull import com.ichi2.anki.databinding.ItemWidgetDeckConfigBinding import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.model.SelectableDeck +import com.ichi2.widget.bridge.WidgetDependencies import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -69,7 +69,7 @@ class WidgetConfigScreenAdapter( coroutineScope.launch { val deckName = withContext(Dispatchers.IO) { - withCol { decks.getLegacy(deck.deckId)!!.name } + WidgetDependencies.collectionAccess.withCol { decks.getLegacy(deck.deckId)!!.name } } holder.binding.deckNameTextView.text = deckName } diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidget.kt b/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidget.kt index f678b357d09e..0f02aeeb2312 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidget.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidget.kt @@ -24,21 +24,17 @@ import android.content.Context import android.content.Intent import android.view.View import android.widget.RemoteViews -import com.ichi2.anki.AnkiDroidApp -import com.ichi2.anki.CrashReportService -import com.ichi2.anki.IntentHandler.Companion.intentToReviewDeckFromShortcuts import com.ichi2.anki.R -import com.ichi2.anki.analytics.UsageAnalytics -import com.ichi2.anki.isCollectionEmpty import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.libanki.Decks.Companion.NOT_FOUND_DECK_ID -import com.ichi2.anki.pages.DeckOptionsDestination import com.ichi2.widget.ACTION_UPDATE_WIDGET import com.ichi2.widget.AnalyticsWidgetProvider import com.ichi2.widget.AppWidgetId import com.ichi2.widget.AppWidgetId.Companion.INVALID_APPWIDGET_ID import com.ichi2.widget.AppWidgetId.Companion.getAppWidgetId import com.ichi2.widget.AppWidgetIds +import com.ichi2.widget.bridge.WidgetAnalytics +import com.ichi2.widget.bridge.WidgetDependencies import com.ichi2.widget.cancelRecurringAlarm import com.ichi2.widget.deckpicker.DeckWidgetData import com.ichi2.widget.deckpicker.getDeckNameAndStats @@ -88,8 +84,8 @@ class CardAnalysisWidget : AnalyticsWidgetProvider() { return } - AnkiDroidApp.applicationScope.launch { - val isCollectionEmpty = isCollectionEmpty() + WidgetDependencies.appState.applicationScope.launch { + val isCollectionEmpty = WidgetDependencies.collectionAccess.isCollectionEmpty() if (isCollectionEmpty) { showCollectionDeck(context, appWidgetManager, appWidgetId, remoteViews) return@launch @@ -193,9 +189,9 @@ class CardAnalysisWidget : AnalyticsWidgetProvider() { val intent = if (!isEmptyDeck) { - intentToReviewDeckFromShortcuts(context, deckData.deckId) + WidgetDependencies.intentFactory.intentToReviewDeck(context, deckData.deckId) } else { - DeckOptionsDestination.fromDeckId(deckData.deckId).toIntent(context) + WidgetDependencies.intentFactory.intentToDeckOptions(context, deckData.deckId) } val pendingIntent = PendingIntent.getActivity( @@ -233,7 +229,7 @@ class CardAnalysisWidget : AnalyticsWidgetProvider() { context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: AppWidgetIds, - usageAnalytics: UsageAnalytics, + usageAnalytics: WidgetAnalytics, ) { Timber.d("Performing widget update for appWidgetIds: %s", appWidgetIds) @@ -321,7 +317,7 @@ class CardAnalysisWidget : AnalyticsWidgetProvider() { } else -> { Timber.e("Unexpected action received: ${intent.action}") - CrashReportService.sendExceptionReport( + WidgetDependencies.crashReporter.sendExceptionReport( Exception("Unexpected action received: ${intent.action}"), "CardAnalysisWidget - onReceive", onlyIfSilent = true, diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidget.kt b/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidget.kt index a10a54f150b3..354b63973a38 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidget.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidget.kt @@ -24,21 +24,16 @@ import android.content.Context import android.content.Intent import android.view.View import android.widget.RemoteViews -import com.ichi2.anki.AnkiDroidApp -import com.ichi2.anki.CollectionManager.withCol -import com.ichi2.anki.CrashReportService -import com.ichi2.anki.IntentHandler.Companion.intentToReviewDeckFromShortcuts import com.ichi2.anki.R -import com.ichi2.anki.analytics.UsageAnalytics -import com.ichi2.anki.isCollectionEmpty import com.ichi2.anki.libanki.DeckId -import com.ichi2.anki.pages.DeckOptionsDestination import com.ichi2.widget.ACTION_UPDATE_WIDGET import com.ichi2.widget.AnalyticsWidgetProvider import com.ichi2.widget.AppWidgetId import com.ichi2.widget.AppWidgetId.Companion.INVALID_APPWIDGET_ID import com.ichi2.widget.AppWidgetId.Companion.getAppWidgetId import com.ichi2.widget.AppWidgetIds +import com.ichi2.widget.bridge.WidgetAnalytics +import com.ichi2.widget.bridge.WidgetDependencies import com.ichi2.widget.cancelRecurringAlarm import com.ichi2.widget.getAppWidgetIdsEx import com.ichi2.widget.setRecurringAlarm @@ -103,8 +98,8 @@ class DeckPickerWidget : AnalyticsWidgetProvider() { showEmptyWidget(context, appWidgetManager, appWidgetId, remoteViews) return } - AnkiDroidApp.applicationScope.launch { - val isCollectionEmpty = isCollectionEmpty() + WidgetDependencies.appState.applicationScope.launch { + val isCollectionEmpty = WidgetDependencies.collectionAccess.isCollectionEmpty() if (isCollectionEmpty) { showEmptyCollection(context, appWidgetManager, appWidgetId, remoteViews) return@launch @@ -144,9 +139,9 @@ class DeckPickerWidget : AnalyticsWidgetProvider() { val intent = if (!isEmptyDeck) { - intentToReviewDeckFromShortcuts(context, deck.deckId) + WidgetDependencies.intentFactory.intentToReviewDeck(context, deck.deckId) } else { - DeckOptionsDestination.fromDeckId(deck.deckId).toIntent(context) + WidgetDependencies.intentFactory.intentToDeckOptions(context, deck.deckId) } val pendingIntent = @@ -243,7 +238,7 @@ class DeckPickerWidget : AnalyticsWidgetProvider() { context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: AppWidgetIds, - usageAnalytics: UsageAnalytics, + usageAnalytics: WidgetAnalytics, ) { Timber.d("Performing widget update for appWidgetIds: %s", appWidgetIds) @@ -347,7 +342,7 @@ class DeckPickerWidget : AnalyticsWidgetProvider() { } else -> { Timber.e("Unexpected action received: ${intent.action}") - CrashReportService.sendExceptionReport( + WidgetDependencies.crashReporter.sendExceptionReport( Exception("Unexpected action received: ${intent.action}"), "DeckPickerWidget - onReceive", onlyIfSilent = true, @@ -387,7 +382,7 @@ suspend fun getDeckNameAndStats(deckId: DeckId): DeckWidgetData? = getDeckNamesA suspend fun getDeckNamesAndStats(deckIds: List): List { val result = mutableListOf() - val deckTree = withCol { sched.deckDueTree() } + val deckTree = WidgetDependencies.collectionAccess.withCol { sched.deckDueTree() } deckTree.forEach { node -> if (node.did !in deckIds) return@forEach diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/widget/AnalyticalWidgetProviderTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/widget/AnalyticalWidgetProviderTest.kt index d0c3f057319b..7fcf3f34e22d 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/widget/AnalyticalWidgetProviderTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/widget/AnalyticalWidgetProviderTest.kt @@ -21,6 +21,7 @@ import com.ichi2.anki.RobolectricTest import com.ichi2.anki.analytics.UsageAnalytics import com.ichi2.widget.AnalyticsWidgetProvider import com.ichi2.widget.AppWidgetIds +import com.ichi2.widget.bridge.WidgetAnalytics import io.mockk.every import io.mockk.mockkObject import io.mockk.unmockkObject @@ -59,7 +60,7 @@ class AnalyticalWidgetProviderTest : RobolectricTest() { context: android.content.Context, appWidgetManager: AppWidgetManager, appWidgetIds: AppWidgetIds, - usageAnalytics: UsageAnalytics, + usageAnalytics: WidgetAnalytics, ) { // Do nothing } diff --git a/settings.gradle.kts b/settings.gradle.kts index 1d72485394a9..3dbe5a996eda 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,4 +26,5 @@ include( ":libanki", ":lint-rules", ":vbpd", + ":widgets", ) \ No newline at end of file diff --git a/widgets/build.gradle.kts b/widgets/build.gradle.kts new file mode 100644 index 000000000000..e83792290e8e --- /dev/null +++ b/widgets/build.gradle.kts @@ -0,0 +1,61 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "com.ichi2.anki.widgets" + compileSdk = + libs.versions.compileSdk + .get() + .toInt() + + defaultConfig { + minSdk = + libs.versions.minSdk + .get() + .toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + } + } +} + +apply(from = "../lint.gradle") + +dependencies { + // modules + implementation(project(":common")) + implementation(project(":libanki")) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.google.material) + implementation(libs.jakewharton.timber) + implementation(libs.kotlinx.coroutines.core) + testImplementation(libs.junit.jupiter) + testImplementation(libs.junit.vintage.engine) + testImplementation(libs.junit.platform.launcher) + testImplementation(kotlin("test")) +} diff --git a/widgets/consumer-rules.pro b/widgets/consumer-rules.pro new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/widgets/proguard-rules.pro b/widgets/proguard-rules.pro new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/widgets/src/main/AndroidManifest.xml b/widgets/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..e10007615799 --- /dev/null +++ b/widgets/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/AnalyticsWidgetProvider.kt b/widgets/src/main/java/com/ichi2/widget/AnalyticsWidgetProvider.kt similarity index 87% rename from AnkiDroid/src/main/java/com/ichi2/widget/AnalyticsWidgetProvider.kt rename to widgets/src/main/java/com/ichi2/widget/AnalyticsWidgetProvider.kt index 9088aa3b6fbf..46c1ea663931 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/AnalyticsWidgetProvider.kt +++ b/widgets/src/main/java/com/ichi2/widget/AnalyticsWidgetProvider.kt @@ -21,8 +21,8 @@ import android.appwidget.AppWidgetProvider import android.content.Context import android.content.Intent import androidx.annotation.CallSuper -import com.ichi2.anki.IntentHandler.Companion.grantedStoragePermissions -import com.ichi2.anki.analytics.UsageAnalytics +import com.ichi2.widget.bridge.WidgetAnalytics +import com.ichi2.widget.bridge.WidgetDependencies import timber.log.Timber /** @@ -49,7 +49,7 @@ abstract class AnalyticsWidgetProvider : AppWidgetProvider() { override fun onEnabled(context: Context) { super.onEnabled(context) Timber.d("${this.javaClass.name}: Widget enabled") - UsageAnalytics.sendAnalyticsEvent(this.javaClass.simpleName, "enabled") + WidgetDependencies.analytics.sendAnalyticsEvent(this.javaClass.simpleName, "enabled") } /** @@ -61,7 +61,7 @@ abstract class AnalyticsWidgetProvider : AppWidgetProvider() { override fun onDisabled(context: Context) { super.onDisabled(context) Timber.d("${this.javaClass.name}: Widget disabled") - UsageAnalytics.sendAnalyticsEvent(this.javaClass.simpleName, "disabled") + WidgetDependencies.analytics.sendAnalyticsEvent(this.javaClass.simpleName, "disabled") } @CallSuper @@ -86,13 +86,12 @@ abstract class AnalyticsWidgetProvider : AppWidgetProvider() { appWidgetIds: IntArray, ) { super.onUpdate(context, appWidgetManager, appWidgetIds) - if (runCatching { grantedStoragePermissions(context, showToast = false) }.getOrNull() != true) { + if (runCatching { WidgetDependencies.intentFactory.grantedStoragePermissions(context, showToast = false) }.getOrNull() != true) { Timber.w("Opening widget ${this.javaClass.name} without storage access") return } - // Pass usageAnalytics to performUpdate Timber.d("${this.javaClass.name}: performUpdate") - performUpdate(context, appWidgetManager, AppWidgetIds(appWidgetIds), UsageAnalytics) + performUpdate(context, appWidgetManager, AppWidgetIds(appWidgetIds), WidgetDependencies.analytics) } /** @@ -105,13 +104,13 @@ abstract class AnalyticsWidgetProvider : AppWidgetProvider() { * @param context The context in which the receiver is running. * @param appWidgetManager The AppWidgetManager instance to use for updating widgets. * @param appWidgetIds The app widget IDs to update. - * @param usageAnalytics The UsageAnalytics instance for logging analytics events. + * @param usageAnalytics The analytics instance for logging analytics events. */ abstract fun performUpdate( context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: AppWidgetIds, - usageAnalytics: UsageAnalytics, + usageAnalytics: WidgetAnalytics, ) } diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/Id.kt b/widgets/src/main/java/com/ichi2/widget/Id.kt similarity index 100% rename from AnkiDroid/src/main/java/com/ichi2/widget/Id.kt rename to widgets/src/main/java/com/ichi2/widget/Id.kt diff --git a/widgets/src/main/java/com/ichi2/widget/SmallWidgetStatus.kt b/widgets/src/main/java/com/ichi2/widget/SmallWidgetStatus.kt new file mode 100644 index 000000000000..5cb555e6729b --- /dev/null +++ b/widgets/src/main/java/com/ichi2/widget/SmallWidgetStatus.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 Ashish Yadav + * + * 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.widget + +/** + * @param dueCardsCount The number of due cards (new + lrn + rev) + * @param eta The estimated time to review + */ +data class SmallWidgetStatus( + val dueCardsCount: Int, + val eta: Int, +) diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/WidgetAlarm.kt b/widgets/src/main/java/com/ichi2/widget/WidgetAlarm.kt similarity index 100% rename from AnkiDroid/src/main/java/com/ichi2/widget/WidgetAlarm.kt rename to widgets/src/main/java/com/ichi2/widget/WidgetAlarm.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/WidgetPermissionReceiver.kt b/widgets/src/main/java/com/ichi2/widget/WidgetPermissionReceiver.kt similarity index 73% rename from AnkiDroid/src/main/java/com/ichi2/widget/WidgetPermissionReceiver.kt rename to widgets/src/main/java/com/ichi2/widget/WidgetPermissionReceiver.kt index 5aa76c49ad5a..d15eb075d3fd 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/WidgetPermissionReceiver.kt +++ b/widgets/src/main/java/com/ichi2/widget/WidgetPermissionReceiver.kt @@ -18,10 +18,9 @@ package com.ichi2.widget import android.content.BroadcastReceiver -import android.content.ComponentName import android.content.Context import android.content.Intent -import com.ichi2.anki.IntentHandler +import com.ichi2.widget.bridge.WidgetDependencies /** * BroadcastReceiver to handle the scenario where storage permissions are granted, @@ -32,10 +31,8 @@ class WidgetPermissionReceiver : BroadcastReceiver() { context: Context, intent: Intent, ) { - if (IntentHandler.grantedStoragePermissions(context, showToast = false)) { - val appWidgetManager = getAppWidgetManager(context) ?: return - val widgetIds = appWidgetManager.getAppWidgetIdsEx(ComponentName(context, AddNoteWidget::class.java)) - AddNoteWidget.updateWidgets(context, appWidgetManager, widgetIds) + if (WidgetDependencies.intentFactory.grantedStoragePermissions(context, showToast = false)) { + WidgetDependencies.appState.updateAddNoteWidgets(context) } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/WidgetStatus.kt b/widgets/src/main/java/com/ichi2/widget/WidgetStatus.kt similarity index 72% rename from AnkiDroid/src/main/java/com/ichi2/widget/WidgetStatus.kt rename to widgets/src/main/java/com/ichi2/widget/WidgetStatus.kt index f9a27182b6a0..2b015f5d6d62 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/WidgetStatus.kt +++ b/widgets/src/main/java/com/ichi2/widget/WidgetStatus.kt @@ -15,28 +15,14 @@ package com.ichi2.widget import android.content.Context -import com.ichi2.anki.AnkiDroidApp -import com.ichi2.anki.CollectionManager.withCol -import com.ichi2.anki.MetaDB -import com.ichi2.anki.R -import com.ichi2.anki.preferences.sharedPrefs -import com.ichi2.anki.settings.Prefs -import com.ichi2.anki.utils.ext.allDecksCounts +import com.ichi2.widget.bridge.WidgetDependencies +import com.ichi2.widget.utils.allDecksCounts import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch import timber.log.Timber -/** - * @param dueCardsCount The number of due cards (new + lrn + rev) - * @param eta The estimated time to review - */ -data class SmallWidgetStatus( - val dueCardsCount: Int, - val eta: Int, -) - /** * The status of the widget. */ @@ -51,11 +37,11 @@ object WidgetStatus { * https://developer.android.com/guide/topics/appwidgets/#MetaData */ fun updateInBackground(context: Context) { - val preferences = context.sharedPrefs() + val preferences = WidgetDependencies.preferences.sharedPrefs(context) smallWidgetEnabled = preferences.getBoolean("widgetSmallEnabled", false) val canExecuteTask = smallWidgetUpdateJob == null || smallWidgetUpdateJob?.isActive == false - if (Prefs.newReviewRemindersEnabled) { + if (WidgetDependencies.preferences.newReviewRemindersEnabled) { if (smallWidgetEnabled && canExecuteTask) { Timber.d("WidgetStatus.update(): updating") smallWidgetUpdateJob = launchSmallWidgetUpdateJob(context) @@ -63,10 +49,7 @@ object WidgetStatus { Timber.d("WidgetStatus.update(): already running or not enabled") } } else { - val notificationEnabled = - preferences - .getString(context.getString(R.string.pref_notifications_minimum_cards_due_key), "1000001")!! - .toInt() < 1000000 + val notificationEnabled = WidgetDependencies.preferences.isLegacyNotificationEnabled(context) if ((smallWidgetEnabled || notificationEnabled) && canExecuteTask) { Timber.d("WidgetStatus.update(): updating") smallWidgetUpdateJob = launchSmallWidgetUpdateJob(context) @@ -88,28 +71,28 @@ object WidgetStatus { } suspend fun updateSmallWidgetStatus(context: Context) { - if (!AnkiDroidApp.isSdCardMounted) { + if (!WidgetDependencies.appState.isSdCardMounted) { Timber.w("updateStatus failed: no SD Card") return } val status = querySmallWidgetStatus() - MetaDB.storeSmallWidgetStatus(context, status) + WidgetDependencies.metaStorage.storeSmallWidgetStatus(context, status) if (smallWidgetEnabled) { Timber.i("triggering small widget UI update") - AnkiDroidWidgetSmall.UpdateService().doUpdate(context) + WidgetDependencies.appState.updateSmallWidgetUi(context) } - if (!Prefs.newReviewRemindersEnabled) { - (context.applicationContext as AnkiDroidApp).scheduleNotification() + if (!WidgetDependencies.preferences.newReviewRemindersEnabled) { + WidgetDependencies.appState.scheduleNotification(context) } } /** Returns the status of each of the decks. */ - fun fetchSmall(context: Context): SmallWidgetStatus = MetaDB.getWidgetSmallStatus(context) + fun fetchSmall(context: Context): SmallWidgetStatus = WidgetDependencies.metaStorage.getWidgetSmallStatus(context) - fun fetchDue(context: Context): Int = MetaDB.getNotificationStatus(context) + fun fetchDue(context: Context): Int = WidgetDependencies.metaStorage.getNotificationStatus(context) private suspend fun querySmallWidgetStatus(): SmallWidgetStatus = - withCol { + WidgetDependencies.collectionAccess.withCol { val total = sched.allDecksCounts() val eta = sched.eta(total, false) SmallWidgetStatus(total.count(), eta) diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/WidgetUtils.kt b/widgets/src/main/java/com/ichi2/widget/WidgetUtils.kt similarity index 100% rename from AnkiDroid/src/main/java/com/ichi2/widget/WidgetUtils.kt rename to widgets/src/main/java/com/ichi2/widget/WidgetUtils.kt diff --git a/widgets/src/main/java/com/ichi2/widget/bridge/WidgetAnalytics.kt b/widgets/src/main/java/com/ichi2/widget/bridge/WidgetAnalytics.kt new file mode 100644 index 000000000000..b3a8f4a60382 --- /dev/null +++ b/widgets/src/main/java/com/ichi2/widget/bridge/WidgetAnalytics.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 Ashish Yadav + * + * 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.widget.bridge + +/** + * Abstraction for analytics event tracking used by widgets. + * Implemented in the app module using [UsageAnalytics]. + */ +interface WidgetAnalytics { + fun sendAnalyticsEvent( + category: String, + action: String, + value: Int? = null, + label: String? = null, + ) +} diff --git a/widgets/src/main/java/com/ichi2/widget/bridge/WidgetAppState.kt b/widgets/src/main/java/com/ichi2/widget/bridge/WidgetAppState.kt new file mode 100644 index 000000000000..80fd25977373 --- /dev/null +++ b/widgets/src/main/java/com/ichi2/widget/bridge/WidgetAppState.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Ashish Yadav + * + * 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.widget.bridge + +import android.app.Application +import android.content.Context +import kotlinx.coroutines.CoroutineScope + +/** + * Abstraction for application-level state used by widgets. + * Implemented in the app module using [AnkiDroidApp]. + */ +interface WidgetAppState { + /** Whether the SD card is currently mounted */ + val isSdCardMounted: Boolean + + /** Application-scoped coroutine scope for background work */ + val applicationScope: CoroutineScope + + /** The application instance, for registering receivers */ + val applicationInstance: Application + + /** Schedules a notification for review reminders */ + fun scheduleNotification(context: Context) + + /** Triggers the small widget UI update */ + fun updateSmallWidgetUi(context: Context) + + /** Triggers AddNoteWidget update after permission grant */ + fun updateAddNoteWidgets(context: Context) +} diff --git a/widgets/src/main/java/com/ichi2/widget/bridge/WidgetCollectionAccess.kt b/widgets/src/main/java/com/ichi2/widget/bridge/WidgetCollectionAccess.kt new file mode 100644 index 000000000000..c0ccd297b5de --- /dev/null +++ b/widgets/src/main/java/com/ichi2/widget/bridge/WidgetCollectionAccess.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026 Ashish Yadav + * + * 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.widget.bridge + +import com.ichi2.anki.libanki.Collection + +/** + * Abstraction for accessing the Anki collection from widgets. + * Implemented in the app module using [CollectionManager]. + */ +interface WidgetCollectionAccess { + /** + * Executes a block with access to the Anki collection. + * Maps to `CollectionManager.withCol`. + */ + suspend fun withCol(block: Collection.() -> T): T + + /** + * Checks if the collection is empty. + * Maps to `isCollectionEmpty()` from DeckUtils. + */ + suspend fun isCollectionEmpty(): Boolean +} diff --git a/widgets/src/main/java/com/ichi2/widget/bridge/WidgetCrashReporter.kt b/widgets/src/main/java/com/ichi2/widget/bridge/WidgetCrashReporter.kt new file mode 100644 index 000000000000..a7e609dc6fba --- /dev/null +++ b/widgets/src/main/java/com/ichi2/widget/bridge/WidgetCrashReporter.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Ashish Yadav + * + * 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.widget.bridge + +/** + * Abstraction for crash reporting used by widgets. + * Implemented in the app module using [CrashReportService]. + */ +interface WidgetCrashReporter { + fun sendExceptionReport( + throwable: Throwable, + origin: String, + onlyIfSilent: Boolean = false, + ) +} diff --git a/widgets/src/main/java/com/ichi2/widget/bridge/WidgetDependencies.kt b/widgets/src/main/java/com/ichi2/widget/bridge/WidgetDependencies.kt new file mode 100644 index 000000000000..e9fe38160e47 --- /dev/null +++ b/widgets/src/main/java/com/ichi2/widget/bridge/WidgetDependencies.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026 Ashish Yadav + * + * 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.widget.bridge + +/** + * Central registry for widget dependencies provided by the app module. + * + * Must be initialized in `AnkiDroidApp.onCreate()` before any widget code runs. + */ +object WidgetDependencies { + lateinit var analytics: WidgetAnalytics + lateinit var intentFactory: WidgetIntentFactory + lateinit var collectionAccess: WidgetCollectionAccess + lateinit var appState: WidgetAppState + lateinit var crashReporter: WidgetCrashReporter + lateinit var metaStorage: WidgetMetaStorage + lateinit var preferences: WidgetPreferences + + fun init( + analytics: WidgetAnalytics, + intentFactory: WidgetIntentFactory, + collectionAccess: WidgetCollectionAccess, + appState: WidgetAppState, + crashReporter: WidgetCrashReporter, + metaStorage: WidgetMetaStorage, + preferences: WidgetPreferences, + ) { + this.analytics = analytics + this.intentFactory = intentFactory + this.collectionAccess = collectionAccess + this.appState = appState + this.crashReporter = crashReporter + this.metaStorage = metaStorage + this.preferences = preferences + } +} diff --git a/widgets/src/main/java/com/ichi2/widget/bridge/WidgetIntentFactory.kt b/widgets/src/main/java/com/ichi2/widget/bridge/WidgetIntentFactory.kt new file mode 100644 index 000000000000..f749de263b67 --- /dev/null +++ b/widgets/src/main/java/com/ichi2/widget/bridge/WidgetIntentFactory.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026 Ashish Yadav + * + * 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.widget.bridge + +import android.content.Context +import android.content.Intent +import com.ichi2.anki.libanki.DeckId + +/** + * Abstraction for creating intents used by widgets. + * Implemented in the app module using [IntentHandler], [NoteEditorLauncher], [DeckOptionsDestination]. + */ +interface WidgetIntentFactory { + /** + * Checks if storage permissions have been granted. + * Maps to `IntentHandler.grantedStoragePermissions`. + */ + fun grantedStoragePermissions( + context: Context, + showToast: Boolean, + ): Boolean + + /** + * Creates an intent to review a specific deck. + * Maps to `IntentHandler.intentToReviewDeckFromShortcuts`. + */ + fun intentToReviewDeck( + context: Context, + deckId: DeckId, + ): Intent + + /** + * Creates an intent to open the note editor. + * Maps to `NoteEditorLauncher.AddNote().toIntent`. + */ + fun intentToOpenNoteEditor(context: Context): Intent + + /** + * Creates an intent to open the main activity. + * Maps to `Intent(context, IntentHandler::class.java)`. + */ + fun intentToMainActivity(context: Context): Intent + + /** + * Creates an intent to open deck options for a specific deck. + * Maps to `DeckOptionsDestination.fromDeckId(deckId).toIntent`. + */ + suspend fun intentToDeckOptions( + context: Context, + deckId: DeckId, + ): Intent +} diff --git a/widgets/src/main/java/com/ichi2/widget/bridge/WidgetMetaStorage.kt b/widgets/src/main/java/com/ichi2/widget/bridge/WidgetMetaStorage.kt new file mode 100644 index 000000000000..118561ed3ba3 --- /dev/null +++ b/widgets/src/main/java/com/ichi2/widget/bridge/WidgetMetaStorage.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026 Ashish Yadav + * + * 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.widget.bridge + +import android.content.Context +import com.ichi2.widget.SmallWidgetStatus + +/** + * Abstraction for widget metadata storage. + * Implemented in the app module using [MetaDB]. + */ +interface WidgetMetaStorage { + fun storeSmallWidgetStatus( + context: Context, + status: SmallWidgetStatus, + ) + + fun getWidgetSmallStatus(context: Context): SmallWidgetStatus + + fun getNotificationStatus(context: Context): Int +} diff --git a/widgets/src/main/java/com/ichi2/widget/bridge/WidgetPreferences.kt b/widgets/src/main/java/com/ichi2/widget/bridge/WidgetPreferences.kt new file mode 100644 index 000000000000..2fbc19b8cc08 --- /dev/null +++ b/widgets/src/main/java/com/ichi2/widget/bridge/WidgetPreferences.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026 Ashish Yadav + * + * 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.widget.bridge + +import android.content.Context +import android.content.SharedPreferences + +/** + * Abstraction for app preferences used by widgets. + * Implemented in the app module using [sharedPrefs] and [Prefs]. + */ +interface WidgetPreferences { + /** Gets the default SharedPreferences for the given context */ + fun sharedPrefs(context: Context): SharedPreferences + + /** Whether the new review reminders feature is enabled */ + val newReviewRemindersEnabled: Boolean + + /** Whether legacy notification is enabled (minimumCardsDue < 1000000) */ + fun isLegacyNotificationEnabled(context: Context): Boolean +} diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidgetPreferences.kt b/widgets/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidgetPreferences.kt similarity index 100% rename from AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidgetPreferences.kt rename to widgets/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidgetPreferences.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidgetPreferences.kt b/widgets/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidgetPreferences.kt similarity index 100% rename from AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidgetPreferences.kt rename to widgets/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidgetPreferences.kt diff --git a/widgets/src/main/java/com/ichi2/widget/utils/SchedulerExt.kt b/widgets/src/main/java/com/ichi2/widget/utils/SchedulerExt.kt new file mode 100644 index 000000000000..6525e383f40a --- /dev/null +++ b/widgets/src/main/java/com/ichi2/widget/utils/SchedulerExt.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Brayan Oliveira <69634269+brayandso@users.noreply.github.com> + * + * 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.widget.utils + +import com.ichi2.anki.libanki.sched.Counts +import com.ichi2.anki.libanki.sched.Scheduler + +/** + * @return Number of new, rev and lrn card to review in all decks. + */ +fun Scheduler.allDecksCounts(): Counts { + val total = Counts() + // Only count the top-level decks in the total + val nodes = deckDueTree().children + for (node in nodes) { + total.addNew(node.newCount) + total.addLrn(node.lrnCount) + total.addRev(node.revCount) + } + return total +}