From b2c7f732a0d71746bd7bd72c835959b6ea47947e Mon Sep 17 00:00:00 2001 From: Ammar Ahmed Date: Mon, 27 Jul 2026 11:10:46 +0500 Subject: [PATCH] mobile: replace reminders widget service with RemoteCollectionItems Push the reminder rows into the widget update itself via RemoteViewsCompat instead of binding a RemoteViewsService adapter, which has been deprecated since API 31. Removes the bound service entirely, so there is no cached factory to invalidate and no separate notifyAppWidgetViewDataChanged step that can race or be missed. Adds androidx.core:core-remoteviews for the API 24-30 fallback. Row ids are now derived from the reminder id rather than the list position, and the list is capped at 50 rows so the update fits in a binder transaction. --- apps/mobile/android/app/build.gradle | 3 + .../android/app/src/main/AndroidManifest.xml | 5 - .../notesnook/RCTNNativeModule.java | 4 +- .../notesnook/ReminderViewsService.java | 119 ------------------ .../notesnook/ReminderWidgetProvider.java | 34 ++++- .../streetwriters/notesnook/WidgetUtils.java | 69 ++++++++++ 6 files changed, 103 insertions(+), 131 deletions(-) delete mode 100644 apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/ReminderViewsService.java diff --git a/apps/mobile/android/app/build.gradle b/apps/mobile/android/app/build.gradle index 7cc709a78..1f4781422 100644 --- a/apps/mobile/android/app/build.gradle +++ b/apps/mobile/android/app/build.gradle @@ -233,6 +233,9 @@ dependencies { // The version of react-native is set by the React Native Gradle Plugin implementation("com.facebook.react:react-android") implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.0.0") + // Lets the widgets push their list data straight into the RemoteViews on every API level, + // instead of the deprecated RemoteViewsService adapter (which needs API 31 to do natively). + implementation("androidx.core:core-remoteviews:1.0.0") implementation("androidx.core:core-splashscreen:1.0.0") implementation 'androidx.multidex:multidex:2.0.1' diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml index 877df5eca..fc7f88466 100644 --- a/apps/mobile/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -231,11 +231,6 @@ - - reminders; - - public ReminderRemoteViewsFactory(Context context, Intent intent) { - this.context = context; - } - - @Override - public void onCreate() { - // Initialize reminders list - reminders = new ArrayList(); - } - - @Override - public void onDataSetChanged() { - SharedPreferences preferences = context.getSharedPreferences("appPreview", Context.MODE_PRIVATE); - List stored = null; - try { - Gson gson = new Gson(); - stored = gson.fromJson(preferences.getString("remindersList", "[]"), new TypeToken>(){}.getType()); - } catch (Exception e) { - Log.e("Reminders", "Could not read the stored reminders list", e); - } - - List updated = new ArrayList(); - if (stored != null) { - for (Reminder reminder : stored) { - if (WidgetUtils.isReminderActive(reminder)) { - updated.add(reminder); - } - } - } - reminders = updated; - } - - @Override - public void onDestroy() { - reminders.clear(); - } - - @Override - public int getCount() { - return reminders.size(); - } - - @Override - public RemoteViews getViewAt(int position) { - Reminder reminder = reminders.get(position); - - boolean useMiniLayout = reminder.getDescription() == null || reminder.getDescription().isEmpty(); - - RemoteViews views = new RemoteViews(context.getPackageName(), useMiniLayout ? R.layout.widget_reminder_layout_small : R.layout.widget_reminder_layout); - - views.setTextViewText(R.id.reminder_title, reminder.getTitle()); - if (!useMiniLayout) { - views.setTextViewText(R.id.reminder_description, reminder.getDescription()); - } - views.setTextViewText(R.id.reminder_time, WidgetUtils.formatReminderTime(context, reminder)); - final Intent fillInIntent = new Intent(); - final Bundle extras = new Bundle(); - extras.putString(ReminderViewsService.OpenReminderId, reminder.getId()); - fillInIntent.setData(Uri.parse("https://app.notesnook.com/open_reminder?id=" + reminder.getId())); - fillInIntent.putExtra(RCTNNativeModule.IntentType, "OpenReminder"); - fillInIntent.putExtras(extras); - views.setOnClickFillInIntent(R.id.reminder_item_btn, fillInIntent); - return views; - } - - - @Override - public RemoteViews getLoadingView() { - return null; - } - - @Override - public int getViewTypeCount() { - return 2; - } - - @Override - public long getItemId(int position) { - - return position; - } - - @Override - public boolean hasStableIds() { - return true; - } -} \ No newline at end of file diff --git a/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/ReminderWidgetProvider.java b/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/ReminderWidgetProvider.java index dc3aa9ff8..4619f594c 100644 --- a/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/ReminderWidgetProvider.java +++ b/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/ReminderWidgetProvider.java @@ -8,15 +8,21 @@ import android.content.Intent; import android.net.Uri; import android.widget.RemoteViews; +import androidx.core.widget.RemoteViewsCompat; + +import com.streetwriters.notesnook.datatypes.Reminder; + +import java.util.List; + public class ReminderWidgetProvider extends AppWidgetProvider { static String NewReminder = "com.streetwriters.notesnook.NewReminder"; + static String OpenReminderId = "com.streetwriters.notesnook.OpenReminderId"; @Override public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { for (int appWidgetId : appWidgetIds) { RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_reminders); updateAppWidget(context, appWidgetManager, appWidgetId, views); - appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetId, R.id.widget_list_view); } } @@ -35,10 +41,28 @@ public class ReminderWidgetProvider extends AppWidgetProvider { PendingIntent pendingIntent2 = PendingIntent.getActivity(context, appWidgetId, new_reminder_intent, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE, WidgetUtils.getActivityOptionsBundle()); views.setOnClickPendingIntent(R.id.add_button, pendingIntent2); - Intent list_remote_adapter_intent = new Intent(context, ReminderViewsService.class); - list_remote_adapter_intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); - views.setRemoteAdapter(R.id.widget_list_view, list_remote_adapter_intent); + // The rows travel with the update itself, so there is no bound service to keep in sync and + // nothing to invalidate separately: every update redraws from the current data. + List reminders = WidgetUtils.getActiveReminders(context); + RemoteViewsCompat.RemoteCollectionItems.Builder items = + new RemoteViewsCompat.RemoteCollectionItems.Builder(); + for (Reminder reminder : reminders) { + items.addItem(getItemId(reminder), WidgetUtils.createReminderItem(context, reminder)); + } + // Two, because a reminder without a description uses the compact row layout. + items.setViewTypeCount(2); + items.setHasStableIds(true); + + RemoteViewsCompat.setRemoteAdapter(context, views, appWidgetId, R.id.widget_list_view, items.build()); views.setEmptyView(R.id.widget_list_view, R.id.empty_view); appWidgetManager.updateAppWidget(appWidgetId, views); } -} \ No newline at end of file + + /** + * Ties a row to its reminder rather than to its position, so rows keep their identity when the + * list shifts around them. + */ + private static long getItemId(Reminder reminder) { + return reminder.getId() == null ? 0 : reminder.getId().hashCode(); + } +} diff --git a/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/WidgetUtils.java b/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/WidgetUtils.java index 109f01403..fa1d053a2 100644 --- a/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/WidgetUtils.java +++ b/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/WidgetUtils.java @@ -2,19 +2,88 @@ package com.streetwriters.notesnook; import android.app.ActivityOptions; import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.text.format.DateUtils; +import android.util.Log; +import android.widget.RemoteViews; +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; import com.streetwriters.notesnook.datatypes.Reminder; +import java.util.ArrayList; import java.util.Calendar; +import java.util.List; /** * Shared helpers for the home screen widgets. */ public class WidgetUtils { + static final String PREFERENCES = "appPreview"; + static final String REMINDERS_KEY = "remindersList"; + + /** + * Every row is serialized into the widget update itself, which has to fit inside a binder + * transaction, so the list cannot grow without bound. Far more than fits on screen anyway. + */ + private static final int MAX_REMINDERS = 50; + + /** + * The reminders the app last wrote out, minus any that have since fired. Reading and filtering + * happens here so the provider can push the rows straight into the widget. + */ + static List getActiveReminders(Context context) { + SharedPreferences preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE); + List stored = null; + try { + stored = new Gson().fromJson(preferences.getString(REMINDERS_KEY, "[]"), + new TypeToken>() {}.getType()); + } catch (Exception e) { + Log.e("Reminders", "Could not read the stored reminders list", e); + } + + List active = new ArrayList<>(); + if (stored == null) return active; + + for (Reminder reminder : stored) { + if (!isReminderActive(reminder)) continue; + if (active.size() >= MAX_REMINDERS) { + Log.w("Reminders", "Widget list truncated to " + MAX_REMINDERS + " reminders"); + break; + } + active.add(reminder); + } + return active; + } + + /** + * Builds a single row of the reminders list. + */ + static RemoteViews createReminderItem(Context context, Reminder reminder) { + boolean useMiniLayout = reminder.getDescription() == null || reminder.getDescription().isEmpty(); + + RemoteViews views = new RemoteViews(context.getPackageName(), + useMiniLayout ? R.layout.widget_reminder_layout_small : R.layout.widget_reminder_layout); + + views.setTextViewText(R.id.reminder_title, reminder.getTitle()); + if (!useMiniLayout) { + views.setTextViewText(R.id.reminder_description, reminder.getDescription()); + } + views.setTextViewText(R.id.reminder_time, formatReminderTime(context, reminder)); + + Intent fillInIntent = new Intent(); + fillInIntent.setData(Uri.parse("https://app.notesnook.com/open_reminder?id=" + reminder.getId())); + fillInIntent.putExtra(RCTNNativeModule.IntentType, "OpenReminder"); + fillInIntent.putExtra(ReminderWidgetProvider.OpenReminderId, reminder.getId()); + views.setOnClickFillInIntent(R.id.reminder_item_btn, fillInIntent); + return views; + } + /** * Options attached to the PendingIntents our widgets hand to the launcher, opting the creator * (us) in to background activity starts so a tap on the widget can bring up an activity.