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.
This commit is contained in:
Ammar Ahmed
2026-07-27 11:10:46 +05:00
parent 88eb3ab714
commit b2c7f732a0
6 changed files with 103 additions and 131 deletions

View File

@@ -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'

View File

@@ -231,11 +231,6 @@
</intent-filter>
</service>
<service
android:name=".ReminderViewsService"
android:exported="true"
android:permission="android.permission.BIND_REMOTEVIEWS" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"

View File

@@ -138,7 +138,7 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule {
if (Objects.equals(extras.getString(IntentType), "NewReminder")) {
map.putString(ReminderWidgetProvider.NewReminder, extras.getString(ReminderWidgetProvider.NewReminder));
} else if (Objects.equals(extras.getString(IntentType), "OpenReminder")) {
map.putString(ReminderViewsService.OpenReminderId, extras.getString(ReminderViewsService.OpenReminderId));
map.putString(ReminderWidgetProvider.OpenReminderId, extras.getString(ReminderWidgetProvider.OpenReminderId));
} else if (Objects.equals(extras.getString(IntentType), "OpenNote")) {
map.putString(NotePreviewWidget.OpenNoteId, extras.getString(NotePreviewWidget.OpenNoteId));
}
@@ -208,8 +208,8 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule {
for (int id: ids) {
Log.d("Reminders", "Updating" + id);
RemoteViews views = new RemoteViews(mContext.getPackageName(), R.layout.widget_reminders);
// The rows are part of this update, so there is nothing left to invalidate afterwards.
ReminderWidgetProvider.updateAppWidget(mContext, wm, id, views);
wm.notifyAppWidgetViewDataChanged(id, R.id.widget_list_view);
}
}

View File

@@ -1,119 +0,0 @@
package com.streetwriters.notesnook;
import android.app.ActivityOptions;
import android.app.PendingIntent;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.widget.RemoteViewsService;
import android.content.Context;
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.List;
public class ReminderViewsService extends RemoteViewsService {
static String OpenReminderId = "com.streetwriters.notesnook.OpenReminderId";
@Override
public RemoteViewsFactory onGetViewFactory(Intent intent) {
return new ReminderRemoteViewsFactory(this.getApplicationContext(), intent);
}
}
class ReminderRemoteViewsFactory implements RemoteViewsService.RemoteViewsFactory {
private Context context;
private List<Reminder> reminders;
public ReminderRemoteViewsFactory(Context context, Intent intent) {
this.context = context;
}
@Override
public void onCreate() {
// Initialize reminders list
reminders = new ArrayList<Reminder>();
}
@Override
public void onDataSetChanged() {
SharedPreferences preferences = context.getSharedPreferences("appPreview", Context.MODE_PRIVATE);
List<Reminder> stored = null;
try {
Gson gson = new Gson();
stored = gson.fromJson(preferences.getString("remindersList", "[]"), new TypeToken<List<Reminder>>(){}.getType());
} catch (Exception e) {
Log.e("Reminders", "Could not read the stored reminders list", e);
}
List<Reminder> updated = new ArrayList<Reminder>();
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;
}
}

View File

@@ -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<Reminder> 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);
}
}
/**
* 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();
}
}

View File

@@ -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<Reminder> getActiveReminders(Context context) {
SharedPreferences preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE);
List<Reminder> stored = null;
try {
stored = new Gson().fromJson(preferences.getString(REMINDERS_KEY, "[]"),
new TypeToken<List<Reminder>>() {}.getType());
} catch (Exception e) {
Log.e("Reminders", "Could not read the stored reminders list", e);
}
List<Reminder> 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.