diff --git a/.eslintignore b/.eslintignore
index af0a6c8bcbf..69b64a1f097 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -858,6 +858,10 @@ packages/app-mobile/utils/setupNotifications.js.map
packages/app-mobile/utils/shareHandler.d.ts
packages/app-mobile/utils/shareHandler.js
packages/app-mobile/utils/shareHandler.js.map
+packages/app-mobile/utils/RecentsWidget.js
+packages/app-mobile/utils/RecentsWidget.js.map
+packages/app-mobile/utils/WidgetUtils.js
+packages/app-mobile/utils/WidgetUtils.js.map
packages/fork-htmlparser2/src/CollectingHandler.d.ts
packages/fork-htmlparser2/src/CollectingHandler.js
packages/fork-htmlparser2/src/CollectingHandler.js.map
diff --git a/.gitignore b/.gitignore
index 5e4f054e984..7d4720810d4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -841,6 +841,10 @@ packages/app-mobile/utils/setupNotifications.js.map
packages/app-mobile/utils/shareHandler.d.ts
packages/app-mobile/utils/shareHandler.js
packages/app-mobile/utils/shareHandler.js.map
+packages/app-mobile/utils/RecentsWidget.js
+packages/app-mobile/utils/RecentsWidget.js.map
+packages/app-mobile/utils/WidgetUtils.js
+packages/app-mobile/utils/WidgetUtils.js.map
packages/fork-htmlparser2/src/CollectingHandler.d.ts
packages/fork-htmlparser2/src/CollectingHandler.js
packages/fork-htmlparser2/src/CollectingHandler.js.map
diff --git a/packages/app-mobile/android/app/src/main/AndroidManifest.xml b/packages/app-mobile/android/app/src/main/AndroidManifest.xml
index e76de4e9ba8..de692b95156 100644
--- a/packages/app-mobile/android/app/src/main/AndroidManifest.xml
+++ b/packages/app-mobile/android/app/src/main/AndroidManifest.xml
@@ -63,6 +63,17 @@
+
+
+
+
+
+
+
+
getPackages() {
packages.add(new SharePackage());
packages.add(new SslPackage());
packages.add(new TextInputPackage());
+ packages.add(new WidgetDataPackage());
return packages;
}
diff --git a/packages/app-mobile/android/app/src/main/java/net/cozic/joplin/widgets/WidgetData.java b/packages/app-mobile/android/app/src/main/java/net/cozic/joplin/widgets/WidgetData.java
new file mode 100644
index 00000000000..4a8f26f452b
--- /dev/null
+++ b/packages/app-mobile/android/app/src/main/java/net/cozic/joplin/widgets/WidgetData.java
@@ -0,0 +1,80 @@
+package net.cozic.joplin.widgets;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+import androidx.annotation.NonNull;
+
+import com.facebook.react.bridge.Promise;
+import com.facebook.react.bridge.ReactApplicationContext;
+import com.facebook.react.bridge.ReactContextBaseJavaModule;
+import com.facebook.react.bridge.ReactMethod;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+public abstract class WidgetData {
+ private static final String NAME = "widget_data";
+ private SharedPreferences sharedPreferences;
+
+ protected Context context;
+
+ protected abstract String getKey();
+
+ protected WidgetData(Context context) {
+ this.context = context;
+ sharedPreferences = context.getSharedPreferences(NAME, Context.MODE_PRIVATE);
+ }
+
+ protected JSONObject readJSON() {
+ try {
+ return new JSONObject(readString());
+ } catch (JSONException e) {
+ return new JSONObject();
+ }
+ }
+
+ protected void writeJSON(JSONObject value) {
+ writeString(value.toString());
+ }
+
+ protected String readString() {
+ return sharedPreferences.getString(getKey(), "{}");
+ }
+
+ protected void writeString(String value) {
+ sharedPreferences.edit().putString(getKey(), value).apply();
+ }
+
+ public ReactModule createReactModule(String name) {
+ return new ReactModule((ReactApplicationContext) context, name, this);
+ }
+
+ private final static class ReactModule extends ReactContextBaseJavaModule {
+ private String name;
+ private WidgetData widgetData;
+
+ private ReactModule(@NonNull ReactApplicationContext reactContext, String name, WidgetData widgetData) {
+ super(reactContext);
+ this.name = name;
+ this.widgetData = widgetData;
+ }
+
+ @NonNull
+ @Override
+ public String getName() {
+ return name;
+ }
+
+ @ReactMethod
+ public void write(String value) {
+ widgetData.writeString(value);
+ }
+
+ @ReactMethod
+ public void read(Promise promise) {
+ promise.resolve(widgetData.readString());
+ }
+
+ }
+}
diff --git a/packages/app-mobile/android/app/src/main/java/net/cozic/joplin/widgets/WidgetDataPackage.java b/packages/app-mobile/android/app/src/main/java/net/cozic/joplin/widgets/WidgetDataPackage.java
new file mode 100644
index 00000000000..af7d3ac4efb
--- /dev/null
+++ b/packages/app-mobile/android/app/src/main/java/net/cozic/joplin/widgets/WidgetDataPackage.java
@@ -0,0 +1,29 @@
+package net.cozic.joplin.widgets;
+
+import androidx.annotation.NonNull;
+
+import com.facebook.react.ReactPackage;
+import com.facebook.react.bridge.NativeModule;
+import com.facebook.react.bridge.ReactApplicationContext;
+import com.facebook.react.uimanager.ViewManager;
+
+import net.cozic.joplin.widgets.recents.RecentsWidgetData;
+
+import java.util.Collections;
+import java.util.List;
+
+public class WidgetDataPackage implements ReactPackage {
+ @NonNull
+ @Override
+ public List createNativeModules(@NonNull ReactApplicationContext reactContext) {
+ return Collections.singletonList(
+ new RecentsWidgetData(reactContext).createReactModule("RecentsWidget")
+ );
+ }
+
+ @NonNull
+ @Override
+ public List createViewManagers(@NonNull ReactApplicationContext reactContext) {
+ return Collections.emptyList();
+ }
+}
diff --git a/packages/app-mobile/android/app/src/main/java/net/cozic/joplin/widgets/recents/RecentsWidgetData.java b/packages/app-mobile/android/app/src/main/java/net/cozic/joplin/widgets/recents/RecentsWidgetData.java
new file mode 100644
index 00000000000..99260474476
--- /dev/null
+++ b/packages/app-mobile/android/app/src/main/java/net/cozic/joplin/widgets/recents/RecentsWidgetData.java
@@ -0,0 +1,78 @@
+package net.cozic.joplin.widgets.recents;
+
+import android.content.Context;
+import android.content.Intent;
+
+import net.cozic.joplin.widgets.WidgetData;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public class RecentsWidgetData extends WidgetData {
+ public RecentsWidgetData(Context context) {
+ super(context);
+ }
+
+ private void broadcastUpdate() {
+ Intent intent = new Intent(context, RecentsWidgetProvider.class);
+ intent.setAction(RecentsWidgetProvider.UPDATE_ACTION);
+ context.sendBroadcast(intent);
+ }
+
+ public List readRecents() {
+ JSONObject data = readJSON();
+ try {
+ JSONArray notes = data.getJSONArray("notes");
+ List result = new ArrayList<>(notes.length());
+ for (int i = 0; i < notes.length(); i++) {
+ result.add(NoteItem.fromJSONObject(notes.getJSONObject(i)));
+ }
+ return result;
+ } catch (JSONException e) {
+ return Collections.emptyList();
+ }
+ }
+
+ @Override
+ protected void writeString(String value) {
+ super.writeString(value);
+ broadcastUpdate();
+ }
+
+ @Override
+ protected String getKey() {
+ return "RecentsWidget";
+ }
+
+ public static final class NoteItem {
+ private String id;
+ private String title;
+
+ public static NoteItem fromJSONObject(JSONObject obj) throws JSONException {
+ return new NoteItem(obj.getString("id"), obj.getString("title"));
+ }
+
+ public NoteItem(String id, String title) {
+ this.id = id;
+ this.title = title;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ @Override
+ public int hashCode() {
+ return getId().hashCode();
+ }
+ }
+}
diff --git a/packages/app-mobile/android/app/src/main/java/net/cozic/joplin/widgets/recents/RecentsWidgetProvider.java b/packages/app-mobile/android/app/src/main/java/net/cozic/joplin/widgets/recents/RecentsWidgetProvider.java
new file mode 100644
index 00000000000..848d0920f98
--- /dev/null
+++ b/packages/app-mobile/android/app/src/main/java/net/cozic/joplin/widgets/recents/RecentsWidgetProvider.java
@@ -0,0 +1,79 @@
+package net.cozic.joplin.widgets.recents;
+
+import android.app.PendingIntent;
+import android.appwidget.AppWidgetManager;
+import android.appwidget.AppWidgetProvider;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.widget.RemoteViews;
+
+import net.cozic.joplin.R;
+
+public class RecentsWidgetProvider extends AppWidgetProvider {
+ public static final String CLICK_ACTION = "RECENTS_WIDGET_CLICK_ACTION";
+ public static final String UPDATE_ACTION = "RECENTS_WIDGET_UPDATE_ACTION";
+
+ public static final String NOTE_ID = "RECENTS_WIDGET_NOTE_ID";
+
+ private static final int listViewId = R.id.list_view;
+
+ private void handleClick(Context context, String noteId) {
+ Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse("joplin://x-callback-url/openNote?id=" + noteId));
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ context.startActivity(intent);
+ }
+
+ private void handleUpdate(Context context) {
+ AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context);
+ int[] ids = appWidgetManager.getAppWidgetIds(new ComponentName(context, getClass()));
+ appWidgetManager.notifyAppWidgetViewDataChanged(ids, listViewId);
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ super.onReceive(context, intent);
+ String action = intent.getAction();
+ if (action == null) {
+ return;
+ }
+ switch (action) {
+ case CLICK_ACTION:
+ String noteId = intent.getStringExtra(NOTE_ID);
+ if (noteId != null) {
+ handleClick(context, noteId);
+ }
+ break;
+ case UPDATE_ACTION:
+ handleUpdate(context);
+ break;
+ }
+ }
+
+ @Override
+ public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
+ for (int appWidgetId : appWidgetIds) {
+ RemoteViews rv = new RemoteViews(context.getPackageName(), R.layout.recents_widget);
+ rv.setRemoteAdapter(listViewId, widgetIntent(context, appWidgetId));
+ rv.setEmptyView(listViewId, R.id.empty_view);
+ rv.setPendingIntentTemplate(listViewId, pendingIntentTemplate(context, appWidgetId));
+ appWidgetManager.updateAppWidget(appWidgetId, rv);
+ }
+ super.onUpdate(context, appWidgetManager, appWidgetIds);
+ }
+
+ private Intent widgetIntent(Context context, int appWidgetId) {
+ Intent intent = new Intent(context, RecentsWidgetService.class);
+ intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
+ intent.setData(Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME)));
+ return intent;
+ }
+
+ private PendingIntent pendingIntentTemplate(Context context, int appWidgetId) {
+ Intent intent = new Intent(context, RecentsWidgetProvider.class);
+ intent.setAction(RecentsWidgetProvider.CLICK_ACTION);
+ intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
+ return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
+ }
+}
diff --git a/packages/app-mobile/android/app/src/main/java/net/cozic/joplin/widgets/recents/RecentsWidgetService.java b/packages/app-mobile/android/app/src/main/java/net/cozic/joplin/widgets/recents/RecentsWidgetService.java
new file mode 100644
index 00000000000..a2e7b3247f7
--- /dev/null
+++ b/packages/app-mobile/android/app/src/main/java/net/cozic/joplin/widgets/recents/RecentsWidgetService.java
@@ -0,0 +1,85 @@
+package net.cozic.joplin.widgets.recents;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.widget.RemoteViews;
+import android.widget.RemoteViewsService;
+
+import net.cozic.joplin.R;
+
+import java.util.List;
+
+public class RecentsWidgetService extends RemoteViewsService {
+ @Override
+ public RemoteViewsFactory onGetViewFactory(Intent intent) {
+ return new RecentsWidgetDataViewsFactory(getApplicationContext(), intent);
+ }
+
+ private static class RecentsWidgetDataViewsFactory implements RemoteViewsService.RemoteViewsFactory {
+ private List notes;
+ private Context context;
+ private RecentsWidgetData recentsWidgetData;
+
+ public RecentsWidgetDataViewsFactory(Context context, Intent intent) {
+ this.context = context;
+ recentsWidgetData = new RecentsWidgetData(context);
+ }
+
+ @Override
+ public void onCreate() {
+ notes = recentsWidgetData.readRecents();
+ }
+
+ @Override
+ public void onDataSetChanged() {
+ notes = recentsWidgetData.readRecents();
+ }
+
+ @Override
+ public RemoteViews getViewAt(int position) {
+ RemoteViews rv = new RemoteViews(context.getPackageName(), R.layout.recents_widget_item);
+ rv.setTextViewText(R.id.recents_widget_item, notes.get(position).getTitle());
+ rv.setOnClickFillInIntent(R.id.recents_widget_item, fillInIntent(notes.get(position).getId()));
+ return rv;
+ }
+
+ private Intent fillInIntent(String noteId) {
+ Bundle extras = new Bundle();
+ extras.putString(RecentsWidgetProvider.NOTE_ID, noteId);
+ Intent intent = new Intent();
+ intent.putExtras(extras);
+ return intent;
+ }
+
+ @Override
+ public void onDestroy() {
+ notes.clear();
+ }
+
+ @Override
+ public int getCount() {
+ return notes.size();
+ }
+
+ @Override
+ public RemoteViews getLoadingView() {
+ return null;
+ }
+
+ @Override
+ public int getViewTypeCount() {
+ return 1;
+ }
+
+ @Override
+ public boolean hasStableIds() {
+ return true;
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return notes.get(position).hashCode();
+ }
+ }
+}
diff --git a/packages/app-mobile/android/app/src/main/res/layout/recents_widget.xml b/packages/app-mobile/android/app/src/main/res/layout/recents_widget.xml
new file mode 100644
index 00000000000..332dbc24dc8
--- /dev/null
+++ b/packages/app-mobile/android/app/src/main/res/layout/recents_widget.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
diff --git a/packages/app-mobile/android/app/src/main/res/layout/recents_widget_item.xml b/packages/app-mobile/android/app/src/main/res/layout/recents_widget_item.xml
new file mode 100644
index 00000000000..4b26de13649
--- /dev/null
+++ b/packages/app-mobile/android/app/src/main/res/layout/recents_widget_item.xml
@@ -0,0 +1,9 @@
+
+
diff --git a/packages/app-mobile/android/app/src/main/res/values/dimens.xml b/packages/app-mobile/android/app/src/main/res/values/dimens.xml
new file mode 100644
index 00000000000..1c00c58ca6a
--- /dev/null
+++ b/packages/app-mobile/android/app/src/main/res/values/dimens.xml
@@ -0,0 +1,4 @@
+
+ 16sp
+ 8dp
+
\ No newline at end of file
diff --git a/packages/app-mobile/android/app/src/main/res/values/strings.xml b/packages/app-mobile/android/app/src/main/res/values/strings.xml
index f89aea2b78e..d71c88e95d4 100644
--- a/packages/app-mobile/android/app/src/main/res/values/strings.xml
+++ b/packages/app-mobile/android/app/src/main/res/values/strings.xml
@@ -1,4 +1,5 @@
Joplin
net.cozic.joplin.notification
+ No recent notes found
diff --git a/packages/app-mobile/android/app/src/main/res/xml/recents_widget_provider_info.xml b/packages/app-mobile/android/app/src/main/res/xml/recents_widget_provider_info.xml
new file mode 100644
index 00000000000..047cb4ddd95
--- /dev/null
+++ b/packages/app-mobile/android/app/src/main/res/xml/recents_widget_provider_info.xml
@@ -0,0 +1,7 @@
+
+
diff --git a/packages/app-mobile/root.tsx b/packages/app-mobile/root.tsx
index 376bf10e0ae..a21bb37cbf2 100644
--- a/packages/app-mobile/root.tsx
+++ b/packages/app-mobile/root.tsx
@@ -6,9 +6,10 @@ import setupQuickActions from './setupQuickActions';
import PluginAssetsLoader from './PluginAssetsLoader';
import AlarmService from '@joplin/lib/services/AlarmService';
import Alarm from '@joplin/lib/models/Alarm';
+import eventManager from '@joplin/lib/eventManager';
import time from '@joplin/lib/time';
import Logger, { TargetType } from '@joplin/lib/Logger';
-import BaseModel from '@joplin/lib/BaseModel';
+import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
import BaseService from '@joplin/lib/services/BaseService';
import ResourceService from '@joplin/lib/services/ResourceService';
import KvStore from '@joplin/lib/services/KvStore';
@@ -20,6 +21,7 @@ import PoorManIntervals from '@joplin/lib/PoorManIntervals';
import reducer from '@joplin/lib/reducer';
import ShareExtension from './utils/ShareExtension';
import handleShared from './utils/shareHandler';
+import { updateRecentsWidgetWithDebounce } from './utils/WidgetUtils';
import uuid from '@joplin/lib/uuid';
import { loadKeychainServiceAndSettings } from '@joplin/lib/services/SettingUtils';
import KeychainServiceDriverMobile from '@joplin/lib/services/keychain/KeychainServiceDriver.mobile';
@@ -754,6 +756,8 @@ class AppComponent extends React.Component {
Linking.addEventListener('url', this.handleOpenURL_);
+ eventManager.on('itemChange', this.onItemChange_);
+
BackButtonService.initialize(this.backButtonHandler_);
AlarmService.setInAppNotificationHandler(async (alarmId: string) => {
@@ -810,6 +814,13 @@ class AppComponent extends React.Component {
return false;
}
+ private onItemChange_({ itemType }: {itemType: ModelType}) {
+ if (itemType !== ModelType.Note) {
+ return;
+ }
+ updateRecentsWidgetWithDebounce();
+ }
+
private async handleShareData() {
const sharedData = await ShareExtension.data();
if (sharedData) {
diff --git a/packages/app-mobile/utils/RecentsWidget.ts b/packages/app-mobile/utils/RecentsWidget.ts
new file mode 100644
index 00000000000..b6c7f5365ae
--- /dev/null
+++ b/packages/app-mobile/utils/RecentsWidget.ts
@@ -0,0 +1,20 @@
+const { NativeModules, Platform } = require('react-native');
+
+export interface NoteItem {
+ id: string;
+ title: string;
+}
+
+interface WidgetData {
+ notes?: NoteItem[];
+}
+
+export const RecentsWidget = (Platform.OS === 'android' && NativeModules.RecentsWidget) ?
+ {
+ read: async (): Promise => JSON.parse(await NativeModules.RecentsWidget.read()),
+ write: async (data: WidgetData) => NativeModules.RecentsWidget.write(JSON.stringify(data)),
+ } :
+ {
+ read: async (): Promise => ({}),
+ write: async (_: WidgetData) => {},
+ };
diff --git a/packages/app-mobile/utils/WidgetUtils.ts b/packages/app-mobile/utils/WidgetUtils.ts
new file mode 100644
index 00000000000..439cbe3b56b
--- /dev/null
+++ b/packages/app-mobile/utils/WidgetUtils.ts
@@ -0,0 +1,32 @@
+import { RecentsWidget } from './RecentsWidget';
+import Note from '@joplin/lib/models/Note';
+import { reg } from '@joplin/lib/registry';
+import shim from '@joplin/lib/shim';
+
+const MAX_COUNT = 10;
+const DEBOUNCE_TIMEOUT = 5000;
+
+export async function updateRecentsWidget() {
+ reg.logger().info('updating recents widget');
+ const recents = await Note.all({
+ fields: ['id', 'title'],
+ order: [{ by: 'updated_time', dir: 'DESC' }],
+ limit: MAX_COUNT,
+ });
+ return RecentsWidget.write({
+ notes: recents,
+ });
+}
+
+let hasUpdateScheduled = false;
+
+export function updateRecentsWidgetWithDebounce() {
+ if (hasUpdateScheduled) {
+ return;
+ }
+ hasUpdateScheduled = true;
+ shim.setTimeout(async () => {
+ await updateRecentsWidget();
+ hasUpdateScheduled = false;
+ }, DEBOUNCE_TIMEOUT);
+}