diff --git a/AnkiDroid/src/main/AndroidManifest.xml b/AnkiDroid/src/main/AndroidManifest.xml index 414e15995574..86d63fd701ac 100644 --- a/AnkiDroid/src/main/AndroidManifest.xml +++ b/AnkiDroid/src/main/AndroidManifest.xml @@ -523,6 +523,21 @@ + + + + + + + + + + * + * 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 + +import android.app.PendingIntent +import android.app.Service +import android.appwidget.AppWidgetManager +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.IBinder +import android.view.View +import android.widget.RemoteViews +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.preferences.sharedPrefs +import com.ichi2.compat.CompatHelper.Companion.registerReceiverCompat +import timber.log.Timber + +/** + * CardsDueWidget is a widget for displaying the number of due cards with additional card analysis data. + */ +class CardsDueWidget : AnalyticsWidgetProvider() { + + /** + * Updates the widget when called. This is the main method for refreshing the widget's view. + * + * @param context The context from which the method is called. + * @param appWidgetManager The AppWidgetManager instance used to update the widget. + * @param appWidgetIds The IDs of the widgets to be updated. + * @param usageAnalytics The usage analytics instance for tracking widget interactions. + */ + override fun performUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray, usageAnalytics: UsageAnalytics) { + updateWidget(context) + } + + override fun onEnabled(context: Context) { + super.onEnabled(context) + val preferences = context.sharedPrefs() + preferences.edit(commit = true) { putBoolean("cardsDueWidgetEnabled", true) } + } + + override fun onDisabled(context: Context) { + super.onDisabled(context) + val preferences = context.sharedPrefs() + preferences.edit(commit = true) { putBoolean("cardsDueWidgetEnabled", false) } + } + + /** + * Handles broadcasted intents of the widget. + * Triggers a widget update upon receiving any supported action. + * + * @param context The context from which the method is called. + * @param intent The intent that was received. + */ + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + if (intent.action.contentEquals("com.sec.android.widgetapp.APPWIDGET_RESIZE")) { + updateWidget(context) + } + updateWidget(context) // Ensures the widget is updated after receiving any intent + } + + /** + * UpdateService is a background service responsible for updating the widget periodically or on demand. + */ + class UpdateService : Service() { + /** The cached number of total due cards. */ + private var dueCardsCount = 0 + + /** + * Called when the service is started. Updates the widget immediately. + * + * @param intent The intent that started the service. + * @param flags Additional data about the start request. + * @param startId A unique integer representing this specific request to start. + * @return The mode in which the system should handle the service if it is killed. + */ + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Timber.i("CardsDueWidget: onStartCommand") + val manager = getAppWidgetManager(this) ?: return super.onStartCommand(intent, flags, startId) + val updateViews = buildUpdate(this) + val thisWidget = ComponentName(this, CardsDueWidget::class.java) + manager.updateAppWidget(thisWidget, updateViews) + updateWidget(this) // Trigger an instant update + return super.onStartCommand(intent, flags, startId) + } + + /** + * Constructs the RemoteViews object that defines the widget's layout and updates it with + * the latest data. + * + * @param context The context from which the method is called. + * @return The RemoteViews object that will be displayed in the widget. + */ + private fun buildUpdate(context: Context): RemoteViews { + Timber.d("buildUpdate") + val updateViews = RemoteViews(context.packageName, R.layout.widget_cards_due) + val mounted = AnkiDroidApp.isSdCardMounted + if (!mounted) { + updateViews.setViewVisibility(R.id.dueNumberTextCardsDue, View.INVISIBLE) + updateViews.setViewVisibility(R.id.etaNumberTextCardsDue, View.INVISIBLE) + if (mountReceiver == null) { + mountReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val action = intent.action + if (action != null && action == Intent.ACTION_MEDIA_MOUNTED) { + Timber.d("mountReceiver - Action = Media Mounted") + if (remounted) { + WidgetStatus.updateInBackground(AnkiDroidApp.instance) + remounted = false + if (mountReceiver != null) { + AnkiDroidApp.instance.unregisterReceiver(mountReceiver) + } + } else { + remounted = true + } + } + } + } + val iFilter = IntentFilter() + iFilter.addAction(Intent.ACTION_MEDIA_MOUNTED) + iFilter.addDataScheme("file") + AnkiDroidApp.instance.registerReceiverCompat(mountReceiver, iFilter, ContextCompat.RECEIVER_EXPORTED) + } + } else { + val counts = WidgetStatus.fetchSmall(context) + dueCardsCount = counts[0] + val eta = counts[1] + updateViews.setViewVisibility(R.id.dueNumberTextCardsDue, if (dueCardsCount > 0) View.VISIBLE else View.INVISIBLE) + updateViews.setViewVisibility(R.id.etaNumberTextCardsDue, if (eta > 0 && dueCardsCount > 0) View.VISIBLE else View.INVISIBLE) + updateViews.setTextViewText(R.id.dueNumberTextCardsDue, dueCardsCount.toString()) + updateViews.setTextViewText(R.id.etaNumberTextCardsDue, eta.toString()) + } + + val ankiDroidIntent = Intent(context, IntentHandler::class.java).apply { + action = Intent.ACTION_MAIN + addCategory(Intent.CATEGORY_LAUNCHER) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK // Ensures the correct task is opened + } + val pendingAnkiDroidIntent = PendingIntent.getActivity( + context, + 0, + ankiDroidIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE // Ensures the intent is immutable if needed + ) + updateViews.setOnClickPendingIntent(R.id.mainLayoutCardsDue, pendingAnkiDroidIntent) + return updateViews + } + + /** + * This service does not support binding, so this method returns null. + * + * @param intent The intent that was used to bind to the service. + * @return Always returns null as binding is not supported. + */ + override fun onBind(intent: Intent?): IBinder? { + Timber.d("onBind") + return null + } + } + + companion object { + private var mountReceiver: BroadcastReceiver? = null + private var remounted = false + + /** + * Updates the widget by fetching the latest data and applying it to the widget's views. + * + * @param context The context from which the method is called. + */ + private fun updateWidget(context: Context) { + val appWidgetManager = AppWidgetManager.getInstance(context) + val ids = appWidgetManager.getAppWidgetIds(ComponentName(context, CardsDueWidget::class.java)) + for (id in ids) { + val updateViews = RemoteViews(context.packageName, R.layout.widget_cards_due) + val counts = WidgetStatus.fetchSmall(context) + val dueCardsCount = counts[0] + val eta = counts[1] + updateViews.setViewVisibility(R.id.dueNumberTextCardsDue, if (dueCardsCount > 0) View.VISIBLE else View.INVISIBLE) + updateViews.setViewVisibility(R.id.etaNumberTextCardsDue, if (eta > 0 && dueCardsCount > 0) View.VISIBLE else View.INVISIBLE) + updateViews.setTextViewText(R.id.dueNumberTextCardsDue, dueCardsCount.toString()) + updateViews.setTextViewText(R.id.etaNumberTextCardsDue, eta.toString()) + + appWidgetManager.updateAppWidget(id, updateViews) + } + } + } +} diff --git a/AnkiDroid/src/main/res/drawable/widget_cards_due_drawable.jpg b/AnkiDroid/src/main/res/drawable/widget_cards_due_drawable.jpg new file mode 100644 index 000000000000..66ce4824318c Binary files /dev/null and b/AnkiDroid/src/main/res/drawable/widget_cards_due_drawable.jpg differ diff --git a/AnkiDroid/src/main/res/layout/widget_cards_due.xml b/AnkiDroid/src/main/res/layout/widget_cards_due.xml new file mode 100644 index 000000000000..39e487f26b95 --- /dev/null +++ b/AnkiDroid/src/main/res/layout/widget_cards_due.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AnkiDroid/src/main/res/layout/widget_cards_due_drawable_v31.xml b/AnkiDroid/src/main/res/layout/widget_cards_due_drawable_v31.xml new file mode 100644 index 000000000000..060da81dcae2 --- /dev/null +++ b/AnkiDroid/src/main/res/layout/widget_cards_due_drawable_v31.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AnkiDroid/src/main/res/values/08-widget.xml b/AnkiDroid/src/main/res/values/08-widget.xml index 0670988d919a..35ef68a951d0 100644 --- a/AnkiDroid/src/main/res/values/08-widget.xml +++ b/AnkiDroid/src/main/res/values/08-widget.xml @@ -20,7 +20,7 @@ - Cards due + Cards due %d AnkiDroid card due %d AnkiDroid cards due @@ -37,5 +37,8 @@ %d minutes remaining + + Cards Due + Add new AnkiDroid note diff --git a/AnkiDroid/src/main/res/xml/widget_provider_cards_due.xml b/AnkiDroid/src/main/res/xml/widget_provider_cards_due.xml new file mode 100644 index 000000000000..a7f4cceec190 --- /dev/null +++ b/AnkiDroid/src/main/res/xml/widget_provider_cards_due.xml @@ -0,0 +1,15 @@ + + \ No newline at end of file