diff --git a/README.md b/README.md
index 3f69bdf..a51732f 100644
--- a/README.md
+++ b/README.md
@@ -27,15 +27,21 @@ src/
native-fs.ts Capacitor Filesystem wrapper (vault root = app-scoped
external storage via Directory.External)
events.ts VaultChangeEvent emitter (in-app writes + rescan)
+ widget-snapshot.ts the Home Screen widget snapshot: the contract + pure selectors
+ widgets.ts publishes it through the ZenWidgets plugin on every change
ui-mobile/
MobileShell.tsx bottom nav (capture ⊕ / search / sidebar / palette),
phone drawer behavior via the shared Zustand store
mobile.css safe areas, overlay drawers, keyboard handling
+ widget-links.ts the zennotes:// links the widgets fire; deep-links.ts runs them
android/ Capacitor-generated Gradle project (appId md.zennotes)
app/src/main/java/md/zennotes/
MainActivity.java registers native plugins, stashes ACTION_SEND shares
DirectUploadPlugin.java streams signed object PUTs on Android 7+
ShareInboxPlugin.java Android ShareInbox (same jsName/contract as iOS)
+ WidgetBridgePlugin.java ZenWidgets (same jsName/contract as iOS): writes the snapshot
+ widgets/ New Note, Recent Notes, Today's Tasks: AppWidgetProviders +
+ RemoteViewsServices rendering that snapshot
```
Key decisions (all forced by "don't modify the zennotes repo"):
@@ -53,6 +59,23 @@ Key decisions (all forced by "don't modify the zennotes repo"):
storage. **Do not switch to `Directory.Documents`** — on Android that is the
public Documents collection, which the Filesystem plugin permission-gates
and Android 11+ scoped storage effectively breaks.
+- **Home Screen widgets** — the iPhone's three (New Note, Recent Notes,
+ Today's Tasks) on the same `src/bridge/widgets.ts` publisher and the same
+ snapshot contract, rendered here as classic RemoteViews in Java
+ (`md.zennotes.widgets`): no Glance, no Kotlin, the build stays Java-only.
+ The snapshot lives in the app's private files
+ (`files/widgets/snapshot.json`; iOS uses the App Group), the two list
+ widgets are `RemoteViewsService`-backed and scroll, colors come from the
+ app's live theme in the snapshot, and every tap is an ACTION_VIEW
+ `zennotes://` intent into the single-task MainActivity. Warm, it arrives
+ as `onNewIntent` → `appUrlOpen`; at boot the shell asks the plugin for
+ the newest link it saw (`ZenWidgets.consumeLaunchLink`, stashed from
+ `onCreate` and `onNewIntent`) because Capacitor's `getLaunchUrl` captures
+ the activity's intent once and an activity recreated into its old task
+ reports the task's *original* intent there — the tap that woke it only
+ shows up as a retained `appUrlOpen` the Cloud auth listener consumes.
+ Pins are keyed by `vault.root` here, where the iPhone shell has
+ `activeVaultStateKey`.
- **Durable app preferences.** WebView localStorage is evictable on some
devices, which silently reset theme and editor settings. `src/bootstrap.ts`
mirrors `zen:prefs:v2` into native Capacitor Preferences on every write,
diff --git a/android/app/build.gradle b/android/app/build.gradle
index caa96fc..bf87c98 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -15,8 +15,8 @@ android {
applicationId "md.zennotes"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 19
- versionName "1.1.17"
+ versionCode 20
+ versionName "1.1.18"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 89736ab..fb95aef 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -35,6 +35,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
+ try {
+ WidgetSnapshot.write(context, json);
+ } catch (IOException e) {
+ call.reject("Could not write the widget snapshot: " + e.getMessage());
+ return;
+ }
+ WidgetUpdater.refreshAll(context);
+ call.resolve();
+ }, "zn-widgets").start();
+ }
+
+ @PluginMethod
+ public void clear(PluginCall call) {
+ final Context context = getContext().getApplicationContext();
+ new Thread(() -> {
+ WidgetSnapshot.delete(context);
+ WidgetUpdater.refreshAll(context);
+ call.resolve();
+ }, "zn-widgets").start();
+ }
+}
diff --git a/android/app/src/main/java/md/zennotes/widgets/NewNoteWidgetProvider.java b/android/app/src/main/java/md/zennotes/widgets/NewNoteWidgetProvider.java
new file mode 100644
index 0000000..0fe4d06
--- /dev/null
+++ b/android/app/src/main/java/md/zennotes/widgets/NewNoteWidgetProvider.java
@@ -0,0 +1,41 @@
+package md.zennotes.widgets;
+
+import android.appwidget.AppWidgetManager;
+import android.appwidget.AppWidgetProvider;
+import android.content.Context;
+import android.widget.RemoteViews;
+
+import md.zennotes.R;
+
+/**
+ * One tap → a fresh note in the Inbox, title focused (the ⊕ sheet's "New
+ * note"). Wears the app's theme from the snapshot.
+ */
+public class NewNoteWidgetProvider extends AppWidgetProvider {
+ static final int REQUEST_NEW_NOTE = 11;
+
+ @Override
+ public void onUpdate(Context context, AppWidgetManager manager, int[] appWidgetIds) {
+ WidgetSnapshot snapshot = WidgetSnapshot.load(context);
+ for (int id : appWidgetIds) {
+ manager.updateAppWidget(id, build(context, snapshot));
+ }
+ }
+
+ static RemoteViews build(Context context, WidgetSnapshot snapshot) {
+ WidgetSnapshot.Palette p = snapshot != null ? snapshot.palette : WidgetSnapshot.Palette.fallback();
+ RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_new_note);
+ views.setInt(R.id.zn_widget_bg, "setColorFilter", p.bg);
+ views.setInt(R.id.zn_new_plus_bg, "setColorFilter", p.accent);
+ views.setInt(R.id.zn_new_plus, "setColorFilter", p.bg);
+ views.setTextColor(R.id.zn_new_title, p.fg);
+ String vault = snapshot != null && snapshot.vaultName != null
+ ? snapshot.vaultName
+ : context.getString(R.string.widget_app_name);
+ views.setTextViewText(R.id.zn_new_vault, vault);
+ views.setTextColor(R.id.zn_new_vault, p.muted);
+ views.setOnClickPendingIntent(R.id.zn_widget_root,
+ WidgetLinks.activity(context, WidgetLinks.newNote(), REQUEST_NEW_NOTE));
+ return views;
+ }
+}
diff --git a/android/app/src/main/java/md/zennotes/widgets/RecentNotesWidgetProvider.java b/android/app/src/main/java/md/zennotes/widgets/RecentNotesWidgetProvider.java
new file mode 100644
index 0000000..93aa56b
--- /dev/null
+++ b/android/app/src/main/java/md/zennotes/widgets/RecentNotesWidgetProvider.java
@@ -0,0 +1,69 @@
+package md.zennotes.widgets;
+
+import android.appwidget.AppWidgetManager;
+import android.appwidget.AppWidgetProvider;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.widget.RemoteViews;
+
+import md.zennotes.R;
+
+/**
+ * Pinned notes first, then the ones edited last: the Home dashboard's
+ * Recent list with the drawer's pins on top. Rows come from
+ * RecentNotesWidgetService and open their note; the header's + starts a
+ * new one; empty space opens Home.
+ */
+public class RecentNotesWidgetProvider extends AppWidgetProvider {
+ static final int REQUEST_NEW_NOTE = 21;
+ static final int REQUEST_HOME = 22;
+ static final int REQUEST_ROW_TEMPLATE = 23;
+
+ @Override
+ public void onUpdate(Context context, AppWidgetManager manager, int[] appWidgetIds) {
+ WidgetSnapshot snapshot = WidgetSnapshot.load(context);
+ for (int id : appWidgetIds) {
+ manager.updateAppWidget(id, build(context, snapshot, id));
+ }
+ manager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.zn_widget_list);
+ }
+
+ static RemoteViews build(Context context, WidgetSnapshot snapshot, int appWidgetId) {
+ WidgetSnapshot.Palette p = snapshot != null ? snapshot.palette : WidgetSnapshot.Palette.fallback();
+ RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_recent_notes);
+ views.setInt(R.id.zn_widget_bg, "setColorFilter", p.bg);
+
+ String vault = snapshot != null && snapshot.vaultName != null
+ ? snapshot.vaultName
+ : context.getString(R.string.widget_app_name);
+ views.setTextViewText(R.id.zn_header_title, vault);
+ views.setTextColor(R.id.zn_header_title, p.muted);
+ views.setInt(R.id.zn_header_action_bg, "setColorFilter", WidgetSnapshot.Palette.withAlpha(p.accent, 0.18f));
+ views.setInt(R.id.zn_header_action_icon, "setColorFilter", p.accent);
+ views.setOnClickPendingIntent(R.id.zn_header_action,
+ WidgetLinks.activity(context, WidgetLinks.newNote(), REQUEST_NEW_NOTE));
+
+ // One adapter intent per widget id: the data URI keeps the launcher
+ // from coalescing two placed widgets onto one factory.
+ Intent adapter = new Intent(context, RecentNotesWidgetService.class);
+ adapter.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
+ adapter.setData(Uri.parse(adapter.toUri(Intent.URI_INTENT_SCHEME)));
+ views.setRemoteAdapter(R.id.zn_widget_list, adapter);
+ views.setEmptyView(R.id.zn_widget_list, R.id.zn_widget_empty);
+ views.setPendingIntentTemplate(R.id.zn_widget_list,
+ WidgetLinks.template(context, REQUEST_ROW_TEMPLATE));
+
+ boolean noSnapshot = snapshot == null;
+ views.setTextViewText(R.id.zn_empty_title, context.getString(
+ noSnapshot ? R.string.widget_empty_open_app : R.string.widget_empty_no_notes));
+ views.setTextViewText(R.id.zn_empty_detail, context.getString(
+ noSnapshot ? R.string.widget_empty_open_app_detail : R.string.widget_empty_no_notes_detail));
+ views.setTextColor(R.id.zn_empty_title, p.fg);
+ views.setTextColor(R.id.zn_empty_detail, p.muted);
+
+ views.setOnClickPendingIntent(R.id.zn_widget_root,
+ WidgetLinks.activity(context, WidgetLinks.home(), REQUEST_HOME));
+ return views;
+ }
+}
diff --git a/android/app/src/main/java/md/zennotes/widgets/RecentNotesWidgetService.java b/android/app/src/main/java/md/zennotes/widgets/RecentNotesWidgetService.java
new file mode 100644
index 0000000..475060c
--- /dev/null
+++ b/android/app/src/main/java/md/zennotes/widgets/RecentNotesWidgetService.java
@@ -0,0 +1,93 @@
+package md.zennotes.widgets;
+
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Color;
+import android.widget.RemoteViews;
+import android.widget.RemoteViewsService;
+
+import java.util.Collections;
+import java.util.List;
+
+import md.zennotes.R;
+
+/** Rows for the Recent Notes widget's list, read from the snapshot. */
+public class RecentNotesWidgetService extends RemoteViewsService {
+ @Override
+ public RemoteViewsFactory onGetViewFactory(Intent intent) {
+ return new Factory(getApplicationContext());
+ }
+
+ static final class Factory implements RemoteViewsFactory {
+ private final Context context;
+ private List notes = Collections.emptyList();
+ private WidgetSnapshot.Palette palette = WidgetSnapshot.Palette.fallback();
+ private long now = System.currentTimeMillis();
+
+ Factory(Context context) {
+ this.context = context;
+ }
+
+ @Override
+ public void onCreate() {
+ load();
+ }
+
+ @Override
+ public void onDataSetChanged() {
+ load();
+ }
+
+ private void load() {
+ WidgetSnapshot snapshot = WidgetSnapshot.load(context);
+ notes = snapshot != null ? snapshot.notes : Collections.emptyList();
+ palette = snapshot != null ? snapshot.palette : WidgetSnapshot.Palette.fallback();
+ now = System.currentTimeMillis();
+ }
+
+ @Override
+ public void onDestroy() {}
+
+ @Override
+ public int getCount() {
+ return notes.size();
+ }
+
+ @Override
+ public RemoteViews getViewAt(int position) {
+ RemoteViews row = new RemoteViews(context.getPackageName(), R.layout.widget_note_row);
+ if (position < 0 || position >= notes.size()) return row;
+ WidgetSnapshot.Note note = notes.get(position);
+ row.setImageViewResource(R.id.zn_row_icon, note.pinned ? R.drawable.ic_zn_pin : R.drawable.ic_zn_doc);
+ row.setInt(R.id.zn_row_icon, "setColorFilter", note.pinned ? palette.accent : palette.muted);
+ row.setTextViewText(R.id.zn_row_title, note.title);
+ row.setTextColor(R.id.zn_row_title, palette.fg);
+ row.setTextViewText(R.id.zn_row_stamp, WidgetFormat.timeAgo(note.updatedAt, now));
+ row.setTextColor(R.id.zn_row_stamp, palette.muted);
+ boolean last = position == notes.size() - 1;
+ row.setInt(R.id.zn_row_divider, "setBackgroundColor", last ? Color.TRANSPARENT : palette.bg2);
+ row.setOnClickFillInIntent(R.id.zn_row, WidgetLinks.fillIn(WidgetLinks.open(note.path)));
+ return row;
+ }
+
+ @Override
+ public RemoteViews getLoadingView() {
+ return null;
+ }
+
+ @Override
+ public int getViewTypeCount() {
+ return 1;
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ @Override
+ public boolean hasStableIds() {
+ return false;
+ }
+ }
+}
diff --git a/android/app/src/main/java/md/zennotes/widgets/TasksWidgetProvider.java b/android/app/src/main/java/md/zennotes/widgets/TasksWidgetProvider.java
new file mode 100644
index 0000000..d9c5047
--- /dev/null
+++ b/android/app/src/main/java/md/zennotes/widgets/TasksWidgetProvider.java
@@ -0,0 +1,89 @@
+package md.zennotes.widgets;
+
+import android.appwidget.AppWidgetManager;
+import android.appwidget.AppWidgetProvider;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.view.View;
+import android.widget.RemoteViews;
+
+import md.zennotes.R;
+
+/**
+ * The Home dashboard's Today bucket: due today, overdue, and undated open
+ * tasks, overdue first. Rows (TasksWidgetService) jump to the task's line;
+ * the header, and any empty space, open the Tasks view.
+ */
+public class TasksWidgetProvider extends AppWidgetProvider {
+ static final int REQUEST_TASKS = 31;
+ static final int REQUEST_ROW_TEMPLATE = 32;
+
+ @Override
+ public void onUpdate(Context context, AppWidgetManager manager, int[] appWidgetIds) {
+ WidgetSnapshot snapshot = WidgetSnapshot.load(context);
+ for (int id : appWidgetIds) {
+ manager.updateAppWidget(id, build(context, snapshot, id));
+ }
+ manager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.zn_widget_list);
+ }
+
+ static boolean isOverdue(WidgetSnapshot.Task task, String todayIso) {
+ if (task.due != null) return task.due.compareTo(todayIso) < 0;
+ return task.overdue;
+ }
+
+ static RemoteViews build(Context context, WidgetSnapshot snapshot, int appWidgetId) {
+ WidgetSnapshot.Palette p = snapshot != null ? snapshot.palette : WidgetSnapshot.Palette.fallback();
+ RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_tasks);
+ views.setInt(R.id.zn_widget_bg, "setColorFilter", p.bg);
+ views.setInt(R.id.zn_header_icon, "setColorFilter", p.accent);
+ views.setTextColor(R.id.zn_header_title, p.fg);
+
+ String todayIso = WidgetFormat.isoDate(System.currentTimeMillis());
+ int overdue = 0;
+ int today = 0;
+ if (snapshot != null) {
+ for (WidgetSnapshot.Task task : snapshot.tasks) {
+ if (isOverdue(task, todayIso)) overdue++;
+ }
+ overdue = Math.max(overdue, snapshot.overdueCount);
+ today = snapshot.todayCount;
+ }
+ if (overdue > 0) {
+ views.setTextViewText(R.id.zn_header_count, context.getString(R.string.widget_overdue_count, overdue));
+ views.setTextColor(R.id.zn_header_count, p.red);
+ } else if (today > 0) {
+ views.setTextViewText(R.id.zn_header_count, context.getString(R.string.widget_open_count, today));
+ views.setTextColor(R.id.zn_header_count, p.muted);
+ } else {
+ views.setTextViewText(R.id.zn_header_count, "");
+ }
+
+ Intent adapter = new Intent(context, TasksWidgetService.class);
+ adapter.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
+ adapter.setData(Uri.parse(adapter.toUri(Intent.URI_INTENT_SCHEME)));
+ views.setRemoteAdapter(R.id.zn_widget_list, adapter);
+ views.setEmptyView(R.id.zn_widget_list, R.id.zn_widget_empty);
+ views.setPendingIntentTemplate(R.id.zn_widget_list,
+ WidgetLinks.template(context, REQUEST_ROW_TEMPLATE));
+
+ boolean ready = snapshot != null && snapshot.tasksReady;
+ if (ready) {
+ views.setViewVisibility(R.id.zn_empty_icon, View.VISIBLE);
+ views.setInt(R.id.zn_empty_icon, "setColorFilter", p.accent);
+ views.setTextViewText(R.id.zn_empty_title, context.getString(R.string.widget_all_clear));
+ views.setTextViewText(R.id.zn_empty_detail, context.getString(R.string.widget_all_clear_detail));
+ } else {
+ views.setViewVisibility(R.id.zn_empty_icon, View.GONE);
+ views.setTextViewText(R.id.zn_empty_title, context.getString(R.string.widget_empty_open_app));
+ views.setTextViewText(R.id.zn_empty_detail, context.getString(R.string.widget_empty_tasks_detail));
+ }
+ views.setTextColor(R.id.zn_empty_title, p.fg);
+ views.setTextColor(R.id.zn_empty_detail, p.muted);
+
+ views.setOnClickPendingIntent(R.id.zn_widget_root,
+ WidgetLinks.activity(context, WidgetLinks.tasks(), REQUEST_TASKS));
+ return views;
+ }
+}
diff --git a/android/app/src/main/java/md/zennotes/widgets/TasksWidgetService.java b/android/app/src/main/java/md/zennotes/widgets/TasksWidgetService.java
new file mode 100644
index 0000000..bdff639
--- /dev/null
+++ b/android/app/src/main/java/md/zennotes/widgets/TasksWidgetService.java
@@ -0,0 +1,97 @@
+package md.zennotes.widgets;
+
+import android.content.Context;
+import android.content.Intent;
+import android.widget.RemoteViews;
+import android.widget.RemoteViewsService;
+
+import java.util.Collections;
+import java.util.List;
+
+import md.zennotes.R;
+
+/** Rows for the Today's Tasks widget's list, read from the snapshot. */
+public class TasksWidgetService extends RemoteViewsService {
+ @Override
+ public RemoteViewsFactory onGetViewFactory(Intent intent) {
+ return new Factory(getApplicationContext());
+ }
+
+ static final class Factory implements RemoteViewsFactory {
+ private final Context context;
+ private List tasks = Collections.emptyList();
+ private WidgetSnapshot.Palette palette = WidgetSnapshot.Palette.fallback();
+ private String todayIso = WidgetFormat.isoDate(System.currentTimeMillis());
+
+ Factory(Context context) {
+ this.context = context;
+ }
+
+ @Override
+ public void onCreate() {
+ load();
+ }
+
+ @Override
+ public void onDataSetChanged() {
+ load();
+ }
+
+ private void load() {
+ WidgetSnapshot snapshot = WidgetSnapshot.load(context);
+ tasks = snapshot != null ? snapshot.tasks : Collections.emptyList();
+ palette = snapshot != null ? snapshot.palette : WidgetSnapshot.Palette.fallback();
+ todayIso = WidgetFormat.isoDate(System.currentTimeMillis());
+ }
+
+ @Override
+ public void onDestroy() {}
+
+ @Override
+ public int getCount() {
+ return tasks.size();
+ }
+
+ @Override
+ public RemoteViews getViewAt(int position) {
+ RemoteViews row = new RemoteViews(context.getPackageName(), R.layout.widget_task_row);
+ if (position < 0 || position >= tasks.size()) return row;
+ WidgetSnapshot.Task task = tasks.get(position);
+ boolean overdue = TasksWidgetProvider.isOverdue(task, todayIso);
+ row.setImageViewResource(R.id.zn_row_icon,
+ task.inProgress ? R.drawable.ic_zn_progress : R.drawable.ic_zn_square);
+ row.setInt(R.id.zn_row_icon, "setColorFilter", overdue ? palette.red : palette.muted);
+ String content = task.content.trim();
+ row.setTextViewText(R.id.zn_row_title, content.isEmpty() ? "Untitled task" : content);
+ row.setTextColor(R.id.zn_row_title, palette.fg);
+ String detail = task.noteTitle;
+ if (overdue && task.due != null) {
+ detail = WidgetFormat.shortDate(task.due) + " · " + task.noteTitle;
+ }
+ row.setTextViewText(R.id.zn_row_detail, detail);
+ row.setTextColor(R.id.zn_row_detail, overdue ? palette.red : palette.muted);
+ row.setOnClickFillInIntent(R.id.zn_row, WidgetLinks.fillIn(WidgetLinks.task(task.id, task.path)));
+ return row;
+ }
+
+ @Override
+ public RemoteViews getLoadingView() {
+ return null;
+ }
+
+ @Override
+ public int getViewTypeCount() {
+ return 1;
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ @Override
+ public boolean hasStableIds() {
+ return false;
+ }
+ }
+}
diff --git a/android/app/src/main/java/md/zennotes/widgets/WidgetFormat.java b/android/app/src/main/java/md/zennotes/widgets/WidgetFormat.java
new file mode 100644
index 0000000..7c89a31
--- /dev/null
+++ b/android/app/src/main/java/md/zennotes/widgets/WidgetFormat.java
@@ -0,0 +1,50 @@
+package md.zennotes.widgets;
+
+import android.text.format.DateFormat;
+
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+
+/** The Home dashboard's stamps, as the iPhone widgets format them. */
+public final class WidgetFormat {
+ private WidgetFormat() {}
+
+ /** just now, 5m ago, 3h ago, yesterday, 4d ago, then a short date. */
+ public static String timeAgo(long then, long now) {
+ long minutes = Math.round((now - then) / 60000.0);
+ if (minutes < 1) return "just now";
+ if (minutes < 60) return minutes + "m ago";
+ long hours = Math.round(minutes / 60.0);
+ if (hours < 24) return hours + "h ago";
+ long days = Math.round(hours / 24.0);
+ if (days == 1) return "yesterday";
+ if (days < 7) return days + "d ago";
+ return shortDate(new Date(then));
+ }
+
+ /** Local calendar day as ISO YYYY-MM-DD, the form task `due` uses, so
+ * overdue is a plain string comparison. */
+ public static String isoDate(long now) {
+ return new SimpleDateFormat("yyyy-MM-dd", Locale.US).format(new Date(now));
+ }
+
+ /** "Sep 5" in the device locale's order. */
+ public static String shortDate(Date date) {
+ String pattern = DateFormat.getBestDateTimePattern(Locale.getDefault(), "MMMd");
+ return new SimpleDateFormat(pattern, Locale.getDefault()).format(date);
+ }
+
+ /** "Sep 5" for an ISO due date; the raw string if it doesn't parse. */
+ public static String shortDate(String iso) {
+ SimpleDateFormat parser = new SimpleDateFormat("yyyy-MM-dd", Locale.US);
+ parser.setLenient(false);
+ try {
+ Date date = parser.parse(iso);
+ return date == null ? iso : shortDate(date);
+ } catch (ParseException e) {
+ return iso;
+ }
+ }
+}
diff --git a/android/app/src/main/java/md/zennotes/widgets/WidgetLinks.java b/android/app/src/main/java/md/zennotes/widgets/WidgetLinks.java
new file mode 100644
index 0000000..835728e
--- /dev/null
+++ b/android/app/src/main/java/md/zennotes/widgets/WidgetLinks.java
@@ -0,0 +1,86 @@
+package md.zennotes.widgets;
+
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Build;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Locale;
+
+import md.zennotes.MainActivity;
+
+/**
+ * The `zennotes://` links the shell runs (src/ui-mobile/widget-links.ts is
+ * the parser and the source of truth for the vocabulary). Every tap is an
+ * ACTION_VIEW intent aimed at MainActivity: cold, Capacitor's App plugin
+ * reports it through getLaunchUrl; warm (singleTask), through onNewIntent
+ * and the appUrlOpen event.
+ */
+public final class WidgetLinks {
+ private WidgetLinks() {}
+
+ /** Only unreserved characters stay bare: `#` in task ids and `&` in
+ * titles would otherwise split the URL. */
+ private static final String UNRESERVED =
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
+
+ public static Uri newNote() {
+ return Uri.parse("zennotes://new");
+ }
+
+ public static Uri tasks() {
+ return Uri.parse("zennotes://tasks");
+ }
+
+ public static Uri home() {
+ return Uri.parse("zennotes://home");
+ }
+
+ public static Uri open(String path) {
+ return Uri.parse("zennotes://open?path=" + encode(path));
+ }
+
+ public static Uri task(String id, String path) {
+ return Uri.parse("zennotes://task?id=" + encode(id) + "&path=" + encode(path));
+ }
+
+ static String encode(String value) {
+ byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
+ StringBuilder out = new StringBuilder(bytes.length * 3);
+ for (byte b : bytes) {
+ int c = b & 0xff;
+ if (c < 128 && UNRESERVED.indexOf((char) c) >= 0) {
+ out.append((char) c);
+ } else {
+ out.append(String.format(Locale.US, "%%%02X", c));
+ }
+ }
+ return out.toString();
+ }
+
+ /** A whole-widget or button tap: fixed link, immutable intent. */
+ public static PendingIntent activity(Context context, Uri link, int requestCode) {
+ Intent intent = new Intent(Intent.ACTION_VIEW, link, context, MainActivity.class);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ int flags = PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE;
+ return PendingIntent.getActivity(context, requestCode, intent, flags);
+ }
+
+ /** The list template: rows fill in their own link (setOnClickFillInIntent),
+ * which needs a mutable pending intent on Android 12+. */
+ public static PendingIntent template(Context context, int requestCode) {
+ Intent intent = new Intent(Intent.ACTION_VIEW, null, context, MainActivity.class);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ int flags = PendingIntent.FLAG_UPDATE_CURRENT;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) flags |= PendingIntent.FLAG_MUTABLE;
+ return PendingIntent.getActivity(context, requestCode, intent, flags);
+ }
+
+ public static Intent fillIn(Uri link) {
+ Intent intent = new Intent();
+ intent.setData(link);
+ return intent;
+ }
+}
diff --git a/android/app/src/main/java/md/zennotes/widgets/WidgetSnapshot.java b/android/app/src/main/java/md/zennotes/widgets/WidgetSnapshot.java
new file mode 100644
index 0000000..56fe428
--- /dev/null
+++ b/android/app/src/main/java/md/zennotes/widgets/WidgetSnapshot.java
@@ -0,0 +1,249 @@
+package md.zennotes.widgets;
+
+import android.content.Context;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Mirror of src/bridge/widget-snapshot.ts (the WebView is the writer;
+ * WidgetBridgePlugin hands the JSON here) and of the iPhone shell's
+ * WidgetSnapshot.swift. The widgets never see the vault: they render this
+ * one file from the app's private storage. Every field a later shell might
+ * add or drop is read with a default, so an older APK never fails on a
+ * newer snapshot.
+ */
+public final class WidgetSnapshot {
+ private static final String DIR = "widgets";
+ private static final String FILE = "snapshot.json";
+
+ public final long generatedAt;
+ /** Nullable: no vault open when the snapshot was written. */
+ public final String vaultName;
+ public final Palette palette;
+ public final List notes;
+ public final List tasks;
+ public final int todayCount;
+ public final int overdueCount;
+ public final boolean tasksReady;
+
+ private WidgetSnapshot(long generatedAt, String vaultName, Palette palette, List notes,
+ List tasks, int todayCount, int overdueCount, boolean tasksReady) {
+ this.generatedAt = generatedAt;
+ this.vaultName = vaultName;
+ this.palette = palette;
+ this.notes = Collections.unmodifiableList(notes);
+ this.tasks = Collections.unmodifiableList(tasks);
+ this.todayCount = todayCount;
+ this.overdueCount = overdueCount;
+ this.tasksReady = tasksReady;
+ }
+
+ public static final class Note {
+ public final String path;
+ public final String title;
+ public final String folder;
+ /** ms since epoch. */
+ public final long updatedAt;
+ public final boolean pinned;
+
+ Note(String path, String title, String folder, long updatedAt, boolean pinned) {
+ this.path = path;
+ this.title = title;
+ this.folder = folder;
+ this.updatedAt = updatedAt;
+ this.pinned = pinned;
+ }
+ }
+
+ public static final class Task {
+ public final String id;
+ public final String path;
+ public final String noteTitle;
+ public final String content;
+ /** ISO YYYY-MM-DD, or null for an undated task. */
+ public final String due;
+ public final boolean overdue;
+ public final boolean inProgress;
+ /** Nullable. */
+ public final String priority;
+
+ Task(String id, String path, String noteTitle, String content, String due,
+ boolean overdue, boolean inProgress, String priority) {
+ this.id = id;
+ this.path = path;
+ this.noteTitle = noteTitle;
+ this.content = content;
+ this.due = due;
+ this.overdue = overdue;
+ this.inProgress = inProgress;
+ this.priority = priority;
+ }
+ }
+
+ /**
+ * The app's active theme, sampled from the `--z-*` tokens by the shell.
+ * Falls back to ZenNotes' default dark-hard palette (the same colors as
+ * res/values/colors.xml) before the first publish.
+ */
+ public static final class Palette {
+ public final boolean isDark;
+ public final int bg;
+ public final int bg1;
+ public final int bg2;
+ public final int fg;
+ public final int fg2;
+ public final int muted;
+ public final int accent;
+ public final int red;
+
+ Palette(boolean isDark, int bg, int bg1, int bg2, int fg, int fg2, int muted, int accent, int red) {
+ this.isDark = isDark;
+ this.bg = bg;
+ this.bg1 = bg1;
+ this.bg2 = bg2;
+ this.fg = fg;
+ this.fg2 = fg2;
+ this.muted = muted;
+ this.accent = accent;
+ this.red = red;
+ }
+
+ public static Palette fallback() {
+ return new Palette(true, 0xFF1D2021, 0xFF32302F, 0xFF3C3836, 0xFFD4BE98, 0xFFDDC7A1,
+ 0xFFA89984, 0xFFE78A4E, 0xFFEA6962);
+ }
+
+ static Palette from(JSONObject theme) {
+ Palette f = fallback();
+ if (theme == null) return f;
+ return new Palette(
+ !"light".equals(theme.optString("mode", "dark")),
+ hex(theme, "bg", f.bg),
+ hex(theme, "bg1", f.bg1),
+ hex(theme, "bg2", f.bg2),
+ hex(theme, "fg", f.fg),
+ hex(theme, "fg2", f.fg2),
+ hex(theme, "muted", f.muted),
+ hex(theme, "accent", f.accent),
+ hex(theme, "red", f.red));
+ }
+
+ /** `#rrggbb` (the hash optional) → opaque ARGB; anything else keeps the fallback. */
+ static int hex(JSONObject theme, String key, int fallback) {
+ if (theme.isNull(key)) return fallback;
+ String value = theme.optString(key, "").trim();
+ if (value.startsWith("#")) value = value.substring(1);
+ if (value.length() != 6) return fallback;
+ try {
+ return 0xFF000000 | Integer.parseInt(value, 16);
+ } catch (NumberFormatException e) {
+ return fallback;
+ }
+ }
+
+ /** The color with its alpha replaced (0..1), for tinted circles behind glyphs. */
+ public static int withAlpha(int color, float alpha) {
+ int a = Math.max(0, Math.min(255, Math.round(alpha * 255)));
+ return (color & 0x00FFFFFF) | (a << 24);
+ }
+ }
+
+ // ---- storage ------------------------------------------------------------
+
+ public static File file(Context context) {
+ return new File(new File(context.getFilesDir(), DIR), FILE);
+ }
+
+ /** Atomic replace: a widget waking mid-write sees the old file or the new one. */
+ public static void write(Context context, String json) throws IOException {
+ File target = file(context);
+ File dir = target.getParentFile();
+ if (dir == null) throw new IOException("No parent directory for " + target);
+ if (!dir.isDirectory() && !dir.mkdirs()) throw new IOException("Could not create " + dir);
+ File tmp = new File(dir, FILE + ".tmp");
+ try (FileOutputStream out = new FileOutputStream(tmp)) {
+ out.write(json.getBytes(StandardCharsets.UTF_8));
+ out.getFD().sync();
+ }
+ if (!tmp.renameTo(target)) {
+ //noinspection ResultOfMethodCallIgnored
+ tmp.delete();
+ throw new IOException("Could not replace " + target);
+ }
+ }
+
+ public static void delete(Context context) {
+ //noinspection ResultOfMethodCallIgnored
+ file(context).delete();
+ }
+
+ /** The current snapshot, or null when none was written or it does not parse. */
+ public static WidgetSnapshot load(Context context) {
+ File f = file(context);
+ if (!f.isFile()) return null;
+ try (InputStream in = new FileInputStream(f)) {
+ ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+ byte[] chunk = new byte[8192];
+ int read;
+ while ((read = in.read(chunk)) != -1) buffer.write(chunk, 0, read);
+ return parse(new String(buffer.toByteArray(), StandardCharsets.UTF_8));
+ } catch (IOException | JSONException e) {
+ return null;
+ }
+ }
+
+ static WidgetSnapshot parse(String json) throws JSONException {
+ JSONObject root = new JSONObject(json);
+ long generatedAt = (long) root.optDouble("generatedAt", 0);
+ String vaultName = root.isNull("vaultName") ? null : root.optString("vaultName", null);
+ Palette palette = Palette.from(root.optJSONObject("theme"));
+
+ List notes = new ArrayList<>();
+ JSONArray noteArray = root.optJSONArray("notes");
+ if (noteArray != null) {
+ for (int i = 0; i < noteArray.length(); i++) {
+ JSONObject n = noteArray.optJSONObject(i);
+ if (n == null) continue;
+ String path = n.optString("path", "");
+ if (path.isEmpty()) continue;
+ String title = n.optString("title", "").trim();
+ notes.add(new Note(path, title.isEmpty() ? "Untitled" : title, n.optString("folder", ""),
+ (long) n.optDouble("updatedAt", 0), n.optBoolean("pinned", false)));
+ }
+ }
+
+ List tasks = new ArrayList<>();
+ JSONArray taskArray = root.optJSONArray("tasks");
+ if (taskArray != null) {
+ for (int i = 0; i < taskArray.length(); i++) {
+ JSONObject t = taskArray.optJSONObject(i);
+ if (t == null) continue;
+ String id = t.optString("id", "");
+ String path = t.optString("path", "");
+ if (id.isEmpty() || path.isEmpty()) continue;
+ tasks.add(new Task(id, path, t.optString("noteTitle", ""), t.optString("content", ""),
+ t.isNull("due") ? null : t.optString("due", null), t.optBoolean("overdue", false),
+ t.optBoolean("inProgress", false), t.isNull("priority") ? null : t.optString("priority", null)));
+ }
+ }
+
+ JSONObject counts = root.optJSONObject("taskCounts");
+ int todayCount = counts != null ? counts.optInt("today", tasks.size()) : tasks.size();
+ int overdueCount = counts != null ? counts.optInt("overdue", 0) : 0;
+ return new WidgetSnapshot(generatedAt, vaultName, palette, notes, tasks, todayCount, overdueCount,
+ root.optBoolean("tasksReady", false));
+ }
+}
diff --git a/android/app/src/main/java/md/zennotes/widgets/WidgetUpdater.java b/android/app/src/main/java/md/zennotes/widgets/WidgetUpdater.java
new file mode 100644
index 0000000..dbdb244
--- /dev/null
+++ b/android/app/src/main/java/md/zennotes/widgets/WidgetUpdater.java
@@ -0,0 +1,47 @@
+package md.zennotes.widgets;
+
+import android.appwidget.AppWidgetManager;
+import android.appwidget.AppWidgetProvider;
+import android.content.ComponentName;
+import android.content.Context;
+
+import md.zennotes.R;
+
+/**
+ * Re-renders every placed ZenNotes widget from the current snapshot. Called
+ * by WidgetBridgePlugin after each publish (WidgetKit's reloadAllTimelines
+ * counterpart) and safe from any thread: AppWidgetManager marshals to the
+ * launcher itself.
+ */
+public final class WidgetUpdater {
+ private WidgetUpdater() {}
+
+ public static void refreshAll(Context context) {
+ Context app = context.getApplicationContext();
+ AppWidgetManager manager = AppWidgetManager.getInstance(app);
+ if (manager == null) return;
+ WidgetSnapshot snapshot = WidgetSnapshot.load(app);
+
+ for (int id : ids(app, manager, NewNoteWidgetProvider.class)) {
+ manager.updateAppWidget(id, NewNoteWidgetProvider.build(app, snapshot));
+ }
+
+ int[] recent = ids(app, manager, RecentNotesWidgetProvider.class);
+ for (int id : recent) {
+ manager.updateAppWidget(id, RecentNotesWidgetProvider.build(app, snapshot, id));
+ }
+ if (recent.length > 0) manager.notifyAppWidgetViewDataChanged(recent, R.id.zn_widget_list);
+
+ int[] tasks = ids(app, manager, TasksWidgetProvider.class);
+ for (int id : tasks) {
+ manager.updateAppWidget(id, TasksWidgetProvider.build(app, snapshot, id));
+ }
+ if (tasks.length > 0) manager.notifyAppWidgetViewDataChanged(tasks, R.id.zn_widget_list);
+ }
+
+ private static int[] ids(Context context, AppWidgetManager manager,
+ Class extends AppWidgetProvider> provider) {
+ int[] ids = manager.getAppWidgetIds(new ComponentName(context, provider));
+ return ids == null ? new int[0] : ids;
+ }
+}
diff --git a/android/app/src/main/res/drawable-nodpi/zn_enso.png b/android/app/src/main/res/drawable-nodpi/zn_enso.png
new file mode 100644
index 0000000..bfabe26
Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/zn_enso.png differ
diff --git a/android/app/src/main/res/drawable/ic_zn_check_circle.xml b/android/app/src/main/res/drawable/ic_zn_check_circle.xml
new file mode 100644
index 0000000..035e6c3
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_zn_check_circle.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_zn_check_square.xml b/android/app/src/main/res/drawable/ic_zn_check_square.xml
new file mode 100644
index 0000000..ea4d493
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_zn_check_square.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_zn_doc.xml b/android/app/src/main/res/drawable/ic_zn_doc.xml
new file mode 100644
index 0000000..5d601fc
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_zn_doc.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_zn_pin.xml b/android/app/src/main/res/drawable/ic_zn_pin.xml
new file mode 100644
index 0000000..fc98568
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_zn_pin.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_zn_plus.xml b/android/app/src/main/res/drawable/ic_zn_plus.xml
new file mode 100644
index 0000000..bc55a2b
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_zn_plus.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_zn_progress.xml b/android/app/src/main/res/drawable/ic_zn_progress.xml
new file mode 100644
index 0000000..7405929
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_zn_progress.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_zn_square.xml b/android/app/src/main/res/drawable/ic_zn_square.xml
new file mode 100644
index 0000000..a689498
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_zn_square.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/zn_circle.xml b/android/app/src/main/res/drawable/zn_circle.xml
new file mode 100644
index 0000000..3cd90b3
--- /dev/null
+++ b/android/app/src/main/res/drawable/zn_circle.xml
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/zn_widget_bg.xml b/android/app/src/main/res/drawable/zn_widget_bg.xml
new file mode 100644
index 0000000..d3720e9
--- /dev/null
+++ b/android/app/src/main/res/drawable/zn_widget_bg.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_new_note.xml b/android/app/src/main/res/layout/widget_new_note.xml
new file mode 100644
index 0000000..ad3ee36
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_new_note.xml
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_note_row.xml b/android/app/src/main/res/layout/widget_note_row.xml
new file mode 100644
index 0000000..a18f102
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_note_row.xml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_recent_notes.xml b/android/app/src/main/res/layout/widget_recent_notes.xml
new file mode 100644
index 0000000..c442e27
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_recent_notes.xml
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_recent_notes_preview.xml b/android/app/src/main/res/layout/widget_recent_notes_preview.xml
new file mode 100644
index 0000000..4278e1b
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_recent_notes_preview.xml
@@ -0,0 +1,181 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_task_row.xml b/android/app/src/main/res/layout/widget_task_row.xml
new file mode 100644
index 0000000..7c79179
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_task_row.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_tasks.xml b/android/app/src/main/res/layout/widget_tasks.xml
new file mode 100644
index 0000000..6a6d939
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_tasks.xml
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_tasks_preview.xml b/android/app/src/main/res/layout/widget_tasks_preview.xml
new file mode 100644
index 0000000..0c6de54
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_tasks_preview.xml
@@ -0,0 +1,173 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values-v31/dimens.xml b/android/app/src/main/res/values-v31/dimens.xml
new file mode 100644
index 0000000..1a35a00
--- /dev/null
+++ b/android/app/src/main/res/values-v31/dimens.xml
@@ -0,0 +1,4 @@
+
+
+ @android:dimen/system_app_widget_background_radius
+
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
index 5738de6..582537f 100644
--- a/android/app/src/main/res/values/colors.xml
+++ b/android/app/src/main/res/values/colors.xml
@@ -3,4 +3,12 @@
#1d2021
+
+ #1d2021
+ #3c3836
+ #d4be98
+ #a89984
+ #e78a4e
+ #ea6962
diff --git a/android/app/src/main/res/values/dimens.xml b/android/app/src/main/res/values/dimens.xml
new file mode 100644
index 0000000..8dc0f24
--- /dev/null
+++ b/android/app/src/main/res/values/dimens.xml
@@ -0,0 +1,5 @@
+
+
+
+ 16dp
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index be764b1..bbe4d2c 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -5,4 +5,39 @@
md.zennotes
md.zennotes
zennotes
+
+
+ ZenNotes
+ New Note
+ Start a new note with one tap.
+ New note
+ Recent Notes
+ Your pinned notes, then the ones you edited last.
+ Today\'s Tasks
+ What\'s due today, overdue, or waiting for a date.
+ Today
+ %1$d overdue
+ %1$d open
+ No notes yet
+ Tap + to write your first.
+ Open ZenNotes once
+ The widget fills in from your vault.
+ Your tasks show up after the first scan.
+ All clear
+ Nothing due today.
+
+ My Vault
+ Reading list
+ Product ideas
+ Meeting notes
+ 25m ago
+ 3h ago
+ 6h ago
+ 1 overdue
+ Book the October flights
+ Sep 7 · Trip planning
+ Draft the release notes
+ Product ideas
+ Reply to the design review
+ Today
diff --git a/android/app/src/main/res/xml/widget_new_note_info.xml b/android/app/src/main/res/xml/widget_new_note_info.xml
new file mode 100644
index 0000000..d59e74e
--- /dev/null
+++ b/android/app/src/main/res/xml/widget_new_note_info.xml
@@ -0,0 +1,12 @@
+
+
diff --git a/android/app/src/main/res/xml/widget_recent_notes_info.xml b/android/app/src/main/res/xml/widget_recent_notes_info.xml
new file mode 100644
index 0000000..d4b3887
--- /dev/null
+++ b/android/app/src/main/res/xml/widget_recent_notes_info.xml
@@ -0,0 +1,12 @@
+
+
diff --git a/android/app/src/main/res/xml/widget_tasks_info.xml b/android/app/src/main/res/xml/widget_tasks_info.xml
new file mode 100644
index 0000000..365db64
--- /dev/null
+++ b/android/app/src/main/res/xml/widget_tasks_info.xml
@@ -0,0 +1,12 @@
+
+
diff --git a/docs/releases/v1.1.18/PLAY_STORE_DESCRIPTION.txt b/docs/releases/v1.1.18/PLAY_STORE_DESCRIPTION.txt
new file mode 100644
index 0000000..3f1c05c
--- /dev/null
+++ b/docs/releases/v1.1.18/PLAY_STORE_DESCRIPTION.txt
@@ -0,0 +1,46 @@
+ZenNotes is a local-first markdown notes app for writing, organizing, and connecting ideas without giving up ownership of your files.
+
+YOUR NOTES, YOUR FILES
+
+Every note is a plain .md file in a vault you control. Start with private on-device storage or choose any folder through Android's system folder picker. On-device and folder vaults work without an account.
+
+ZENNOTES CLOUD
+
+Connect an optional ZenNotes Cloud plan to:
+• Sync a vault across ZenNotes desktop, Android, and iPhone
+• Sync notes and attachments
+• Create manual and automatic daily backups
+• Restore a full vault or a single note from a backup
+• Publish notes to the web and manage their public links
+
+Cloud never replaces the free local-first workflow. Use it only when you want hosted sync, backup, or publishing.
+
+WRITE IN MARKDOWN
+
+Use a focused editor with headings, lists, tasks, tables, code, links, wikilinks, tags, callouts, footnotes, and frontmatter. A mobile formatting bar keeps common actions close to the keyboard.
+
+RICH, OFFLINE PREVIEW
+
+Render KaTeX math, Mermaid diagrams, JSXGraph, function plots, tables, callouts, and more directly on your device.
+
+ORGANIZE YOUR WAY
+
+Browse folders and tags, search the full vault, manage tasks, create periodic notes, and work with table databases stored as CSV.
+
+CAPTURE QUICKLY
+
+Create a quick note from the app or send text and links to ZenNotes from Android's share sheet.
+
+HOME SCREEN WIDGETS
+
+Start a note, open a recent one, or check today's tasks without opening the app. The widgets wear your theme and stay current as you write.
+
+ANDROID STORAGE, DONE RIGHT
+
+The default vault needs no storage permission. The folder option uses Android's Storage Access Framework and a scoped grant to the folder you choose. ZenNotes does not request all-files access.
+
+PRIVACY BY DEFAULT
+
+ZenNotes has no advertising or tracking. Local notes stay in the storage you choose. When you enable ZenNotes Cloud, the account identity, connected-device information, and vault content needed for the Cloud features you use are sent to the ZenNotes service. Payment details are handled by Stripe.
+
+ZenNotes is open source. Your vault remains useful outside the app because it is made of ordinary files.
diff --git a/docs/releases/v1.1.18/PLAY_STORE_METADATA.md b/docs/releases/v1.1.18/PLAY_STORE_METADATA.md
new file mode 100644
index 0000000..b1cb839
--- /dev/null
+++ b/docs/releases/v1.1.18/PLAY_STORE_METADATA.md
@@ -0,0 +1,43 @@
+# Play Console metadata: ZenNotes 1.1.18
+
+| Field | Value | Limit |
+| --- | --- | --- |
+| App name | ZenNotes: Markdown Notes | 30 |
+| Short description | see `SHORT_DESCRIPTION.txt` (unchanged) | 80 |
+| Full description | see `PLAY_STORE_DESCRIPTION.txt` (updated: widgets section) | 4000 |
+| Release notes | see `WHATS_NEW.md` (under 500 chars) | 500 |
+| Category | Productivity | n/a |
+| Application ID | md.zennotes | n/a |
+| Version | 1.1.18 (versionCode 20) | n/a |
+| Contact email | adib@lumarylabs.com | n/a |
+| Website | https://zennotes.org | n/a |
+| Privacy policy URL | https://zennotes.org/privacy | n/a |
+| Ads | Contains no ads | n/a |
+| Price | Free app; optional external SaaS subscription | n/a |
+
+## Data safety
+
+Unchanged. No new permissions, no new collection. The widgets read a
+summary file in the app's private storage (note titles, paths, dates,
+task lines, theme colors); no note bodies and nothing off the device.
+
+## Release checks
+
+- Native version: 1.1.18; versionCode: 20 (bump committed on its own
+ after the widget commit). Capacitor 8.5 / SDK 36 / minSdk 24 / AGP 8.13
+ / Gradle 8.14.5. Native changes: `WidgetBridgePlugin.java`, the
+ `md.zennotes.widgets` package, widget layouts, drawables and
+ appwidget-provider metadata, three receivers and two services in the
+ manifest. No permission changes.
+- Source: branch `release/1.1.18` off main. Commits: the widgets, the
+ version bump, this pack. main already carried the a3e638fc pin (app
+ core 2.46.0 plus the js-yaml / svgo lockfile fix) and the shell's own
+ js-yaml bump.
+- Verified 2026-09-08 on the Pixel 7 API 35 AVD: the picker previews, all
+ three widgets placed, New Note, note rows and task rows warm and after
+ a real process kill (landing on the task's line), the widgets updating
+ within seconds; `npm test` 50/50; `npm run typecheck` clean at the pin;
+ `testDebugUnitTest`, `lintDebug` and `assembleDebug` pass. Not verified
+ on a device or below API 31.
+- Matching ports: iOS ZenNotes/zennotesios `release/1.9.9` (build 20);
+ desktop ZenNotes/zennotes 2.46.0.
diff --git a/docs/releases/v1.1.18/PLAY_STORE_REVIEW_NOTES.md b/docs/releases/v1.1.18/PLAY_STORE_REVIEW_NOTES.md
new file mode 100644
index 0000000..054b0a2
--- /dev/null
+++ b/docs/releases/v1.1.18/PLAY_STORE_REVIEW_NOTES.md
@@ -0,0 +1,26 @@
+# Play review and Data safety notes: ZenNotes 1.1.18
+
+Version 1.1.18 adds Home Screen widgets. No new permissions, no new data
+collection, no manifest permission changes, and no change to the Data
+safety answers. The shared app core stays at 2.46.
+
+## What changed
+
+- Three app widgets (manifest receivers with the standard
+ `APPWIDGET_UPDATE` filter, plus two `RemoteViewsService`s guarded by
+ `BIND_REMOTEVIEWS`): New Note, Recent Notes, and Today's Tasks.
+- The widgets render a small summary the app writes into its own private
+ files directory: note titles, paths, and modification dates; task
+ lines; the theme's colors. Note bodies never leave the vault, and
+ nothing is sent anywhere.
+- Tapping a widget opens the app through its existing `zennotes` URL
+ scheme: New Note creates a note in the Inbox and opens it; a Recent
+ Notes row opens that note; a task row opens the note at that task's
+ line; the tasks header opens the Tasks view.
+
+## Data safety
+
+Unchanged. The app collects no data by default; the optional ZenNotes
+Cloud account and any user-configured self-hosted server work exactly as
+previously declared. On-device and folder vaults continue to work without
+an account, and no storage permission is requested.
diff --git a/docs/releases/v1.1.18/PROMOTIONAL_TEXT.txt b/docs/releases/v1.1.18/PROMOTIONAL_TEXT.txt
new file mode 100644
index 0000000..e7173ec
--- /dev/null
+++ b/docs/releases/v1.1.18/PROMOTIONAL_TEXT.txt
@@ -0,0 +1 @@
+Widgets: start a note, open a recent one, or see today's tasks from the Home Screen.
diff --git a/docs/releases/v1.1.18/RELEASE_NOTES.md b/docs/releases/v1.1.18/RELEASE_NOTES.md
new file mode 100644
index 0000000..c04a777
--- /dev/null
+++ b/docs/releases/v1.1.18/RELEASE_NOTES.md
@@ -0,0 +1,47 @@
+# ZenNotes for Android 1.1.18: widgets
+
+Home Screen widgets, in step with iPhone 1.9.9. Three of them, zero
+configuration, wearing whatever theme the app wears.
+
+## What changes on the phone
+
+- **New Note.** A 2×2 widget: one tap creates a note in the Inbox and
+ opens it with the title focused, the same path as the ⊕ sheet's New
+ note.
+- **Recent Notes.** 4×2 and up, resizable; the list scrolls. Your pinned
+ notes first, in the order you pinned them (the drawer's own rule), then
+ the ones you edited last, each with its "3h ago" stamp. A row opens the
+ note; the + in the header starts a new one.
+- **Today's Tasks.** 4×2 and up, resizable; the list scrolls. The Home
+ dashboard's Today bucket: due today, overdue, or undated, with overdue
+ tasks first and their count in the header. A row lands on the task's
+ line in its note; the header opens the Tasks view. "All clear" when
+ nothing is due.
+- **Live.** The widgets update within seconds of a change: a saved note,
+ a pinned one, a ticked task, a theme switch. Stamps refresh on the
+ system's half-hour widget cadence in between.
+
+## Under the hood
+
+- Classic RemoteViews in Java (`md.zennotes.widgets`): three
+ AppWidgetProviders and two RemoteViewsServices, no Glance and no
+ Kotlin, so the build stays Java-only. The picker shows real previews on
+ Android 12 and later.
+- A widget cannot see the vault, so the shell publishes a snapshot into
+ the app's private files (`files/widgets/snapshot.json`): pinned and
+ recent note titles, paths and dates; today's tasks; the theme's colors.
+ No note bodies. `src/bridge/widgets.ts` publishes on change, throttled
+ to one refresh per 8 s while typing and flushed on backgrounding;
+ `WidgetBridgePlugin.java` writes the file and re-renders every placed
+ widget. The same publisher and contract as the iPhone shell.
+- Taps are `zennotes://` view intents into the single-task MainActivity.
+ At boot the shell asks the plugin for the newest link it saw rather
+ than Capacitor's `getLaunchUrl`, which reports the task's original
+ intent when the activity is recreated into its old task.
+- Pinned to upstream `a3e638fc`: app core 2.46.0 plus the js-yaml and
+ svgo lockfile fix for the advisories published on 2026-09-08.
+
+No new permissions, services, or data collection. On-device and folder
+vaults continue to work without an account; self-hosted and ZenNotes
+Cloud vaults remain optional. The same widgets ship on iPhone and iPad as
+ZenNotes 1.9.9.
diff --git a/docs/releases/v1.1.18/SHORT_DESCRIPTION.txt b/docs/releases/v1.1.18/SHORT_DESCRIPTION.txt
new file mode 100644
index 0000000..91adbf0
--- /dev/null
+++ b/docs/releases/v1.1.18/SHORT_DESCRIPTION.txt
@@ -0,0 +1 @@
+Local-first markdown notes with optional sync, backups, and publishing.
diff --git a/docs/releases/v1.1.18/SOCIAL.md b/docs/releases/v1.1.18/SOCIAL.md
new file mode 100644
index 0000000..74529f5
--- /dev/null
+++ b/docs/releases/v1.1.18/SOCIAL.md
@@ -0,0 +1,6 @@
+# Social copy: ZenNotes Android 1.1.18
+
+## Short
+
+ZenNotes for Android 1.1.18 adds Home Screen widgets: start a note, open
+a recent one, or see today's tasks without opening the app.
diff --git a/docs/releases/v1.1.18/WHATS_NEW.md b/docs/releases/v1.1.18/WHATS_NEW.md
new file mode 100644
index 0000000..3cd72ea
--- /dev/null
+++ b/docs/releases/v1.1.18/WHATS_NEW.md
@@ -0,0 +1,9 @@
+Home Screen widgets and app core 2.46.
+
+• New Note: one tap starts a note in your Inbox
+• Recent Notes: pinned first, then last edited, with a + to start one
+• Today's Tasks: due today, overdue, or undated; tap a task to land on its line
+• Widgets wear your theme and update in seconds
+• App core 2.46: comment threads with names, Date… in the @ menu, saved Tasks filters, Kanban Folder board, display math in callouts
+
+No new permissions. Widgets read a small on-device summary, not note bodies.
diff --git a/docs/releases/v1.1.18/twitter-post.md b/docs/releases/v1.1.18/twitter-post.md
new file mode 100644
index 0000000..bf9dd5b
--- /dev/null
+++ b/docs/releases/v1.1.18/twitter-post.md
@@ -0,0 +1,5 @@
+ZenNotes for Android 1.1.18 is out.
+
+Home Screen widgets. New Note, one tap to a fresh note. Recent Notes, pinned first. Today's Tasks, overdue first, a tap away from the task's line. They wear your theme and update within seconds of a change.
+
+App core 2.46 underneath.
diff --git a/package-lock.json b/package-lock.json
index 4153e90..379096e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "zennotes-android",
- "version": "1.1.17",
+ "version": "1.1.18",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "zennotes-android",
- "version": "1.1.17",
+ "version": "1.1.18",
"dependencies": {
"@aparajita/capacitor-secure-storage": "^8.0.0",
"@capacitor/android": "^8.5.1",
diff --git a/package.json b/package.json
index 909de4a..b787e9f 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "zennotes-android",
"private": true,
- "version": "1.1.17",
+ "version": "1.1.18",
"type": "module",
"description": "ZenNotes for Android — Capacitor shell over the ZenNotes app core",
"homepage": "https://zennotes.org",
diff --git a/src/bridge/mobile-bridge.ts b/src/bridge/mobile-bridge.ts
index 23c41fa..5d645f6 100644
--- a/src/bridge/mobile-bridge.ts
+++ b/src/bridge/mobile-bridge.ts
@@ -130,7 +130,7 @@ import {
import { folderForRelativePath, posixNormalize, sanitizeNoteTitle } from './vault-core'
import { isPhoneViewport } from '../viewport'
-let appVersion = '1.1.17'
+let appVersion = '1.1.18'
export async function loadNativeAppVersion(): Promise {
try {
diff --git a/src/bridge/mobile-cloud-auth.ts b/src/bridge/mobile-cloud-auth.ts
index 962a59e..416a3a7 100644
--- a/src/bridge/mobile-cloud-auth.ts
+++ b/src/bridge/mobile-cloud-auth.ts
@@ -98,9 +98,25 @@ export async function configureMobileCloudAuth(appVersion: string): Promise scheduleAuthCallback(url))
+ await CapApp.addListener('appUrlOpen', ({ url }) => {
+ if (isCloudAuthUrl(url)) scheduleAuthCallback(url)
+ })
const launch = await CapApp.getLaunchUrl()
- if (launch?.url) scheduleAuthCallback(launch.url)
+ if (launch?.url && isCloudAuthUrl(launch.url)) scheduleAuthCallback(launch.url)
+}
+
+/** The scheme is shared with the widget links (ui-mobile/widget-links.ts);
+ * only `zennotes://auth…` is this module's to handle. */
+function isCloudAuthUrl(rawUrl: string): boolean {
+ try {
+ const parsed = new URL(rawUrl.trim())
+ return (
+ parsed.protocol === 'zennotes:' &&
+ (parsed.hostname || parsed.pathname.replace(/^\/+/, '')) === 'auth'
+ )
+ } catch {
+ return false
+ }
}
export async function getMobileCloudAccountStatus(): Promise {
diff --git a/src/bridge/widget-snapshot.test.ts b/src/bridge/widget-snapshot.test.ts
new file mode 100644
index 0000000..5c911b5
--- /dev/null
+++ b/src/bridge/widget-snapshot.test.ts
@@ -0,0 +1,147 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import type { WidgetNoteSource, WidgetTaskSource } from './widget-snapshot.ts'
+import {
+ FALLBACK_WIDGET_THEME,
+ channelsToHex,
+ filterLiveTasks,
+ selectWidgetNotes,
+ selectWidgetTasks,
+ themeFromTokens
+} from './widget-snapshot.ts'
+
+function note(
+ path: string,
+ updatedAt: number,
+ folder: WidgetNoteSource['folder'] = 'inbox',
+ title = path.replace(/^.*\//, '').replace(/\.md$/, '')
+): WidgetNoteSource {
+ return { path, title, folder, updatedAt }
+}
+
+test('pinned notes lead in pin order, then recents newest first', () => {
+ const notes = [
+ note('inbox/Old.md', 100),
+ note('inbox/Newest.md', 900),
+ note('inbox/Pinned B.md', 300),
+ note('inbox/Pinned A.md', 200),
+ note('inbox/Middle.md', 500)
+ ]
+ const out = selectWidgetNotes(notes, ['inbox/Pinned B.md', 'inbox/Pinned A.md'])
+ assert.deepEqual(
+ out.map((n) => [n.path, n.pinned]),
+ [
+ ['inbox/Pinned B.md', true],
+ ['inbox/Pinned A.md', true],
+ ['inbox/Newest.md', false],
+ ['inbox/Middle.md', false],
+ ['inbox/Old.md', false]
+ ]
+ )
+})
+
+test('trash and archive never show; a stale pin is skipped; the cap holds', () => {
+ const notes = [
+ note('trash/Gone.md', 999, 'trash'),
+ note('archive/Done.md', 998, 'archive'),
+ note('inbox/A.md', 3),
+ note('inbox/B.md', 2),
+ note('inbox/C.md', 1)
+ ]
+ const out = selectWidgetNotes(notes, ['inbox/Missing.md', 'trash/Gone.md'], 2)
+ assert.deepEqual(
+ out.map((n) => n.path),
+ ['inbox/A.md', 'inbox/B.md']
+ )
+ assert.equal(out.every((n) => !n.pinned), true)
+})
+
+test('an empty title reads Untitled and pins are not duplicated as recents', () => {
+ const out = selectWidgetNotes([note('inbox/x.md', 1, 'inbox', ' ')], ['inbox/x.md'])
+ assert.deepEqual(out, [
+ { path: 'inbox/x.md', title: 'Untitled', folder: 'inbox', updatedAt: 1, pinned: true }
+ ])
+})
+
+test('channel triplets become hex; anything else is rejected', () => {
+ assert.equal(channelsToHex('29 32 33'), '#1d2021')
+ assert.equal(channelsToHex(' 255 255 255 '), '#ffffff')
+ assert.equal(channelsToHex('0 0 0'), '#000000')
+ assert.equal(channelsToHex(''), null)
+ assert.equal(channelsToHex('#1d2021'), null)
+ assert.equal(channelsToHex('300 0 0'), null)
+})
+
+test('the theme reads every token it can and falls back per token', () => {
+ const tokens: Record = {
+ '--z-bg': '251 241 199',
+ '--z-accent': '195 94 10',
+ '--z-red': 'not a color'
+ }
+ const theme = themeFromTokens((t) => tokens[t] ?? '', 'light')
+ assert.equal(theme.mode, 'light')
+ assert.equal(theme.bg, '#fbf1c7')
+ assert.equal(theme.accent, '#c35e0a')
+ assert.equal(theme.red, FALLBACK_WIDGET_THEME.red)
+ assert.equal(theme.fg, FALLBACK_WIDGET_THEME.fg)
+})
+
+function task(
+ id: string,
+ content: string,
+ extra: Partial = {}
+): WidgetTaskSource {
+ const sourcePath = id.slice(0, id.lastIndexOf('#'))
+ return {
+ id,
+ sourcePath,
+ noteTitle: sourcePath.replace(/^.*\//, '').replace(/\.md$/, ''),
+ content,
+ inProgress: false,
+ ...extra
+ }
+}
+
+test('overdue tasks lead, the rest keep the bucket order, and counts cover the cut-off rows', () => {
+ const today = [
+ task('inbox/A.md#1', 'Due today', { due: '2026-09-08', inProgress: true }),
+ task('inbox/B.md#0', ' Undated ', { priority: 'high' }),
+ task('inbox/A.md#0', 'Overdue thing', { due: '2026-09-05' }),
+ task('inbox/C.md#0', 'Older overdue', { due: '2026-09-01' })
+ ]
+ const { tasks, counts } = selectWidgetTasks(today, 2, '2026-09-08', 3)
+ assert.deepEqual(counts, { today: 4, overdue: 2 })
+ assert.deepEqual(
+ tasks.map((t) => t.content),
+ ['Overdue thing', 'Older overdue', 'Due today']
+ )
+ assert.deepEqual(tasks[0], {
+ id: 'inbox/A.md#0',
+ path: 'inbox/A.md',
+ noteTitle: 'A',
+ content: 'Overdue thing',
+ due: '2026-09-05',
+ overdue: true,
+ inProgress: false,
+ priority: null
+ })
+ assert.equal(tasks[2]!.overdue, false)
+ assert.equal(tasks[2]!.inProgress, true)
+ const all = selectWidgetTasks(today, 2, '2026-09-08').tasks
+ assert.equal(all[3]!.content, 'Undated')
+ assert.equal(all[3]!.due, null)
+ assert.equal(all[3]!.priority, 'high')
+})
+
+test('tasks from deleted or trashed notes are dropped', () => {
+ const tasks = [
+ { sourcePath: 'inbox/Keep.md', id: 'k' },
+ { sourcePath: 'trash/Bin.md', id: 't' },
+ { sourcePath: 'inbox/Gone.md', id: 'g' }
+ ]
+ const notes = [note('inbox/Keep.md', 1), note('trash/Bin.md', 1, 'trash')]
+ assert.deepEqual(
+ filterLiveTasks(tasks, notes).map((t) => t.id),
+ ['k']
+ )
+})
diff --git a/src/bridge/widget-snapshot.ts b/src/bridge/widget-snapshot.ts
new file mode 100644
index 0000000..c9e63d7
--- /dev/null
+++ b/src/bridge/widget-snapshot.ts
@@ -0,0 +1,225 @@
+/**
+ * The Home Screen widget snapshot — the contract between the shell and the
+ * native widgets (android/app/src/main/java/md/zennotes/widgets; the same
+ * file, byte for byte, feeds the iPhone's WidgetKit extension).
+ *
+ * The widgets cannot see the vault, so the app publishes what they show —
+ * the active vault's pinned + recent notes, today's tasks, and the active
+ * theme's colors — as one JSON document: WidgetBridgePlugin.java writes it
+ * into the app's private files, WidgetSnapshot.java decodes it. Keep the
+ * two sides in step; the Java reader treats every field as optional.
+ *
+ * Only pure selectors live here (node --test covers them). The store/pins
+ * wiring and the native call are in widgets.ts.
+ */
+import type { NoteMeta } from '@bridge-contract/ipc'
+import type { VaultTask } from '@shared/tasks'
+
+export const WIDGET_SNAPSHOT_VERSION = 1
+/** The list scrolls; a dozen keeps the widget useful at any height. */
+export const WIDGET_MAX_NOTES = 12
+export const WIDGET_MAX_TASKS = 12
+
+export interface WidgetTheme {
+ mode: 'light' | 'dark'
+ /** Hex colors (#rrggbb) sampled from the app-core `--z-*` tokens. */
+ bg: string
+ bg1: string
+ bg2: string
+ fg: string
+ fg2: string
+ muted: string
+ accent: string
+ red: string
+}
+
+export interface WidgetNote {
+ path: string
+ title: string
+ folder: string
+ /** ms since epoch, as NoteMeta reports it. */
+ updatedAt: number
+ pinned: boolean
+}
+
+export interface WidgetTask {
+ /** VaultTask id (`${sourcePath}#${taskIndex}`) — the tap link carries it
+ * back so the shell can jump to the exact line. */
+ id: string
+ path: string
+ noteTitle: string
+ content: string
+ /** ISO YYYY-MM-DD or null for an undated task (those sit in Today too). */
+ due: string | null
+ overdue: boolean
+ inProgress: boolean
+ priority: string | null
+}
+
+export interface WidgetTaskCounts {
+ /** Everything in the Today bucket, not just the rows that fit. */
+ today: number
+ overdue: number
+}
+
+export interface WidgetSnapshot {
+ version: typeof WIDGET_SNAPSHOT_VERSION
+ /** ms since epoch. */
+ generatedAt: number
+ vaultName: string | null
+ theme: WidgetTheme
+ /** Pinned notes first (in pin order), then the most recently edited. */
+ notes: WidgetNote[]
+ /** The Today bucket, overdue first, then the Tasks view's order. */
+ tasks: WidgetTask[]
+ taskCounts: WidgetTaskCounts
+ /** False until the first task scan for this vault has landed, so the
+ * widget shows "loading" rather than a misleading "All clear". */
+ tasksReady: boolean
+}
+
+/** ZenNotes' default theme (dark-hard), used before the first publish and
+ * for any token the active theme leaves undefined. */
+export const FALLBACK_WIDGET_THEME: WidgetTheme = {
+ mode: 'dark',
+ bg: '#1d2021',
+ bg1: '#32302f',
+ bg2: '#3c3836',
+ fg: '#d4be98',
+ fg2: '#ddc7a1',
+ muted: '#a89984',
+ accent: '#e78a4e',
+ red: '#ea6962'
+}
+
+const THEME_TOKENS: Record, string> = {
+ bg: '--z-bg',
+ bg1: '--z-bg-1',
+ bg2: '--z-bg-2',
+ fg: '--z-fg',
+ fg2: '--z-fg-2',
+ muted: '--z-grey-2',
+ accent: '--z-accent',
+ red: '--z-red'
+}
+
+/** `"29 32 33"` (the `--z-*` channel triplet form) → `"#1d2021"`. */
+export function channelsToHex(value: string): string | null {
+ const channels = value.trim().split(/\s+/).map(Number)
+ if (
+ channels.length !== 3 ||
+ channels.some((channel) => !Number.isInteger(channel) || channel < 0 || channel > 255)
+ ) {
+ return null
+ }
+ return '#' + channels.map((n) => n.toString(16).padStart(2, '0')).join('')
+}
+
+/** Resolve the widget palette from a token reader (getComputedStyle in the
+ * app); tokens that don't parse keep the fallback value. */
+export function themeFromTokens(
+ read: (token: string) => string,
+ mode: WidgetTheme['mode']
+): WidgetTheme {
+ const theme: WidgetTheme = { ...FALLBACK_WIDGET_THEME, mode }
+ for (const [key, token] of Object.entries(THEME_TOKENS) as Array<
+ [Exclude, string]
+ >) {
+ const hex = channelsToHex(read(token))
+ if (hex) theme[key] = hex
+ }
+ return theme
+}
+
+export type WidgetNoteSource = Pick
+
+/**
+ * Pinned notes first, in the order they were pinned (the drawer's own
+ * convention — pins sort to the top of their group), then the most recently
+ * edited notes, mirroring the Home dashboard's Recent list. Trash and
+ * Archive never show; a pin whose note is gone is skipped, not surfaced.
+ */
+export function selectWidgetNotes(
+ notes: readonly WidgetNoteSource[],
+ pinnedPaths: readonly string[],
+ max = WIDGET_MAX_NOTES
+): WidgetNote[] {
+ const live = notes.filter((n) => n.folder !== 'trash' && n.folder !== 'archive')
+ const byPath = new Map(live.map((n) => [n.path, n] as const))
+ const out: WidgetNote[] = []
+ const seen = new Set()
+ const push = (note: WidgetNoteSource, pinned: boolean): void => {
+ if (seen.has(note.path) || out.length >= max) return
+ seen.add(note.path)
+ out.push({
+ path: note.path,
+ title: note.title.trim() || 'Untitled',
+ folder: note.folder,
+ updatedAt: note.updatedAt,
+ pinned
+ })
+ }
+ for (const path of pinnedPaths) {
+ const note = byPath.get(path)
+ if (note) push(note, true)
+ }
+ for (const note of live.slice().sort((a, b) => b.updatedAt - a.updatedAt)) {
+ if (out.length >= max) break
+ push(note, false)
+ }
+ return out
+}
+
+export type WidgetTaskSource = Pick<
+ VaultTask,
+ 'id' | 'sourcePath' | 'noteTitle' | 'content' | 'due' | 'inProgress' | 'priority'
+>
+
+/**
+ * Drop tasks whose note no longer exists (or sits in Trash). App-core keeps
+ * `vaultTasks` fresh only while a tasks surface is on screen, so on the
+ * phone a note deleted from the drawer can leave its tasks in the cache
+ * until the next full scan — the widget must not show them.
+ */
+export function filterLiveTasks(
+ tasks: readonly T[],
+ notes: readonly Pick[]
+): T[] {
+ const live = new Set(notes.filter((n) => n.folder !== 'trash').map((n) => n.path))
+ return tasks.filter((t) => live.has(t.sourcePath))
+}
+
+/**
+ * The rows for the Tasks widget from the Today bucket app-core's
+ * `computeTasksRender` produces (due today, overdue, or undated — the same
+ * list the Home dashboard shows), plus the counts the header needs even
+ * when rows are cut off. `todayIso` is the local calendar day.
+ *
+ * One departure from the bucket's file order: overdue tasks lead. The
+ * widget shows a few rows under a header that counts the overdue ones,
+ * and in a vault with many undated tasks the bucket order would keep
+ * every overdue row out of sight. The sort is stable, so everything else
+ * keeps the app's order.
+ */
+export function selectWidgetTasks(
+ today: readonly WidgetTaskSource[],
+ overdueCount: number,
+ todayIso: string,
+ max = WIDGET_MAX_TASKS
+): { tasks: WidgetTask[]; counts: WidgetTaskCounts } {
+ const isOverdue = (t: WidgetTaskSource): boolean => typeof t.due === 'string' && t.due < todayIso
+ const ordered = today.slice().sort((a, b) => Number(isOverdue(b)) - Number(isOverdue(a)))
+ const tasks = ordered.slice(0, max).map(
+ (t): WidgetTask => ({
+ id: t.id,
+ path: t.sourcePath,
+ noteTitle: t.noteTitle,
+ content: t.content.trim() || 'Untitled task',
+ due: t.due ?? null,
+ overdue: typeof t.due === 'string' && t.due < todayIso,
+ inProgress: t.inProgress,
+ priority: t.priority ?? null
+ })
+ )
+ return { tasks, counts: { today: today.length, overdue: overdueCount } }
+}
diff --git a/src/bridge/widgets.ts b/src/bridge/widgets.ts
new file mode 100644
index 0000000..19188ba
--- /dev/null
+++ b/src/bridge/widgets.ts
@@ -0,0 +1,197 @@
+/**
+ * Widget publisher: keeps the snapshot the native widgets render
+ * (widget-snapshot.ts is the contract) in step with the store. Same code as
+ * the iPhone shell's; the only Android difference is the pins key, which
+ * this shell derives from the vault root (note-actions.tsx does the same).
+ *
+ * Sources of change are the note index (every rescan and vault mutation
+ * replaces `notes`), the shared task cache, the drawer's pins, the active
+ * vault, and the theme. Each publish re-renders every placed widget, so the
+ * first change after a quiet spell goes out almost at once and edits that
+ * keep landing (every autosave bumps a note's updatedAt) are coalesced to
+ * one publish per interval; backgrounding flushes whatever is pending so
+ * the Home Screen is current the moment the user leaves.
+ *
+ * Task freshness is this module's job too: app-core rescans a note's tasks
+ * on change only while a tasks surface is on screen (store.ts,
+ * `tasksSurfaceVisible`), which on the phone is rarely the case while
+ * editing. Notes whose updatedAt moved get a per-note rescan; a vault switch
+ * (or a big batch, e.g. a folder vault landing many files) gets one full scan.
+ */
+import { App as CapApp } from '@capacitor/app'
+import { Capacitor, registerPlugin } from '@capacitor/core'
+import { useStore } from '@zennotes/app-core/store'
+import { computeTasksRender } from '@zennotes/app-core/lib/tasks-filter'
+import { filterTasksForDisplay, toIsoDateLocal } from '@shared/tasks'
+import { isMobileNoteIndexReady } from './mobile-bridge'
+import { getPinnedNotes, subscribePins } from '../ui-mobile/pins'
+import {
+ WIDGET_SNAPSHOT_VERSION,
+ filterLiveTasks,
+ selectWidgetNotes,
+ selectWidgetTasks,
+ themeFromTokens,
+ type WidgetSnapshot,
+ type WidgetTheme
+} from './widget-snapshot'
+
+interface ZenWidgetsPlugin {
+ update(options: { snapshot: string }): Promise
+ clear(): Promise
+ /** The newest `zennotes://` link that launched or woke this process, once
+ * (deep-links.ts). Null when the app was opened normally. */
+ consumeLaunchLink(): Promise<{ url: string | null }>
+}
+
+export const ZenWidgets = registerPlugin('ZenWidgets')
+
+const NO_COLLAPSE = {
+ today: false,
+ upcoming: false,
+ waiting: false,
+ forwarded: false,
+ done: false,
+ cancelled: false
+}
+
+const PUBLISH_DEBOUNCE_MS = 400
+const PUBLISH_MIN_INTERVAL_MS = 8000
+/** Past this many changed notes one full scan beats per-note rescans. */
+const RESCAN_BATCH_LIMIT = 8
+
+type StoreState = ReturnType
+
+let timer = 0
+let lastPublishedAt = 0
+let lastPayload = ''
+let taskVaultKey: string | null = null
+let tasksSettled = false
+let knownUpdatedAt = new Map()
+
+/** The pins key this shell uses everywhere (note-actions.tsx). */
+function pinsKey(state: StoreState): string | null {
+ return state.vault?.root ?? null
+}
+
+function themeMode(): WidgetTheme['mode'] {
+ return document.documentElement.dataset.themeMode === 'light' ? 'light' : 'dark'
+}
+
+function buildSnapshot(state: StoreState, now: Date): Omit {
+ const style = getComputedStyle(document.documentElement)
+ const live = filterLiveTasks(
+ filterTasksForDisplay(state.vaultTasks, state.showArchivedTasks),
+ state.notes
+ )
+ const render = computeTasksRender(live, '', now, NO_COLLAPSE)
+ const { tasks, counts } = selectWidgetTasks(
+ render.groups.today,
+ render.groups.overdueCount ?? 0,
+ toIsoDateLocal(now)
+ )
+ return {
+ version: WIDGET_SNAPSHOT_VERSION,
+ vaultName: state.vault?.name ?? null,
+ theme: themeFromTokens((token) => style.getPropertyValue(token), themeMode()),
+ notes: selectWidgetNotes(state.notes, getPinnedNotes(pinsKey(state))),
+ tasks,
+ taskCounts: counts,
+ tasksReady: tasksSettled
+ }
+}
+
+async function publish(): Promise {
+ const state = useStore.getState()
+ // No vault (onboarding, a switch in flight): keep whatever the widgets
+ // already show rather than blanking them.
+ if (!state.vault) return
+ let body: Omit
+ try {
+ body = buildSnapshot(state, new Date())
+ } catch (err) {
+ console.error('widget snapshot failed', err)
+ return
+ }
+ const payload = JSON.stringify(body)
+ if (payload === lastPayload) return
+ lastPayload = payload
+ lastPublishedAt = Date.now()
+ const snapshot: WidgetSnapshot = { ...body, generatedAt: Date.now() }
+ await ZenWidgets.update({ snapshot: JSON.stringify(snapshot) }).catch(() => {})
+}
+
+function schedule(): void {
+ if (timer) return
+ const wait = Math.max(PUBLISH_DEBOUNCE_MS, lastPublishedAt + PUBLISH_MIN_INTERVAL_MS - Date.now())
+ timer = window.setTimeout(() => {
+ timer = 0
+ void publish()
+ }, wait)
+}
+
+function flush(): void {
+ if (!timer) return
+ window.clearTimeout(timer)
+ timer = 0
+ void publish()
+}
+
+function reconcileTasks(state: StoreState, prev: StoreState | null): void {
+ if (!state.vault || !isMobileNoteIndexReady()) return
+ const key = pinsKey(state)
+ if (key !== taskVaultKey) {
+ taskVaultKey = key
+ tasksSettled = false
+ knownUpdatedAt = new Map(state.notes.map((n) => [n.path, n.updatedAt]))
+ if (!state.tasksLoading) void state.refreshTasks()
+ return
+ }
+ if (prev && prev.tasksLoading && !state.tasksLoading) tasksSettled = true
+ if (!prev || state.notes === prev.notes) return
+ const changed: string[] = []
+ const next = new Map()
+ for (const n of state.notes) {
+ next.set(n.path, n.updatedAt)
+ if (n.folder !== 'trash' && knownUpdatedAt.get(n.path) !== n.updatedAt) changed.push(n.path)
+ }
+ knownUpdatedAt = next
+ if (changed.length === 0) return
+ if (changed.length > RESCAN_BATCH_LIMIT) {
+ if (!state.tasksLoading) void state.refreshTasks()
+ return
+ }
+ for (const path of changed) void state.rescanTasksForPath(path)
+}
+
+/** Start publishing; returns the teardown (tests / hot paths — the shell
+ * itself never stops). No-op off the native platform. */
+export function installWidgetPublisher(): () => void {
+ if (!Capacitor.isNativePlatform()) return () => {}
+ const unsubStore = useStore.subscribe((state, prev) => {
+ reconcileTasks(state, prev)
+ if (
+ state.notes !== prev.notes ||
+ state.vaultTasks !== prev.vaultTasks ||
+ state.showArchivedTasks !== prev.showArchivedTasks ||
+ state.tasksLoading !== prev.tasksLoading ||
+ state.vault !== prev.vault ||
+ state.themeId !== prev.themeId ||
+ state.themeMode !== prev.themeMode
+ ) {
+ schedule()
+ }
+ })
+ const unsubPins = subscribePins(schedule)
+ const appState = CapApp.addListener('appStateChange', ({ isActive }) => {
+ if (!isActive) flush()
+ })
+ reconcileTasks(useStore.getState(), null)
+ schedule()
+ return () => {
+ unsubStore()
+ unsubPins()
+ void appState.then((handle) => handle.remove()).catch(() => {})
+ window.clearTimeout(timer)
+ timer = 0
+ }
+}
diff --git a/src/main.tsx b/src/main.tsx
index 304114a..2b13bce 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -19,6 +19,8 @@ import {
} from './bridge/mobile-bridge'
import { configureMobileCloudAuth } from './bridge/mobile-cloud-auth'
import { maybeRunFirstRunOnboarding } from './ui-mobile/Onboarding'
+import { installWidgetPublisher } from './bridge/widgets'
+import { installDeepLinks } from './ui-mobile/deep-links'
import { mountMobileShell } from './ui-mobile/MobileShell'
import { installHomeGuard } from './ui-mobile/nav'
import { refreshVault } from './ui-mobile/refresh'
@@ -94,6 +96,10 @@ async function boot(): Promise {
if (!root) throw new Error('Renderer root element #root was not found')
renderZenNotesApp(root)
mountMobileShell()
+ // Home Screen widgets: publish what they show, and run the links they
+ // open the app with (both wait for the workspace themselves).
+ installWidgetPublisher()
+ installDeepLinks()
}
void boot().catch((err) => {
diff --git a/src/ui-mobile/deep-links.ts b/src/ui-mobile/deep-links.ts
new file mode 100644
index 0000000..f45096a
--- /dev/null
+++ b/src/ui-mobile/deep-links.ts
@@ -0,0 +1,151 @@
+/**
+ * Runs the widgets' `zennotes://` links (widget-links.ts parses them).
+ *
+ * Delivery: `appUrlOpen` while the app runs (a widget tap reaches the
+ * single-task activity as a new intent; on iOS, an open-URL), and at boot the newest link the
+ * native side saw — `ZenWidgets.consumeLaunchLink()`, kept by the platform
+ * plugin from every URL open. Capacitor's own `getLaunchUrl` stays the
+ * fallback: on Android it captures the activity's intent once, and an
+ * activity recreated into its old task reports the task's ORIGINAL intent
+ * there while the tap that actually woke it arrives as a retained
+ * `appUrlOpen` the Cloud auth listener (registered first) consumes. The
+ * Cloud auth callback shares the scheme; its listener ignores these links
+ * and this one ignores `zennotes://auth`.
+ *
+ * A link waits for the workspace: the vault open, the store restored, and
+ * the note index in, since a stale row (the note was deleted after the last
+ * publish) must fall back to Home, not throw. Links that land while booting
+ * coalesce to the newest one — a wake can deliver the old task intent and
+ * the real tap back to back, and only the last is the user's. The runner
+ * then yields a beat so the shell's own cold-launch landing
+ * (usePhoneLayoutBoot: Home, or where the user left) has run first and the
+ * link wins, exactly as tapping the note in the app would.
+ */
+import { App as CapApp } from '@capacitor/app'
+import { Capacitor } from '@capacitor/core'
+import { useStore } from '@zennotes/app-core/store'
+import { isMobileNoteIndexReady } from '../bridge/mobile-bridge'
+import { ZenWidgets } from '../bridge/widgets'
+import { setDrawerOpen } from './drawer-state'
+import { goHome } from './nav'
+import { closeNoteMenu } from './note-actions'
+import { closeMobileSheet } from './sheet-state'
+import { parseWidgetLink, type WidgetLink } from './widget-links'
+
+const DUPLICATE_WINDOW_MS = 5000
+const LANDING_SETTLE_MS = 80
+/** A vault that never becomes ready (remote workspace offline, onboarding
+ * abandoned) must not pin a link forever; past this the link runs anyway
+ * and its own guards decide. */
+const READY_TIMEOUT_MS = 20000
+
+type StoreState = ReturnType
+
+let lastUrl = ''
+let lastAt = 0
+let pending: WidgetLink | null = null
+let waiting = false
+
+export function installDeepLinks(): void {
+ if (!Capacitor.isNativePlatform()) return
+ void CapApp.addListener('appUrlOpen', ({ url }) => handleDeepLink(url)).catch(() => {})
+ void ZenWidgets.consumeLaunchLink()
+ .then((result) => {
+ if (result?.url) handleDeepLink(result.url)
+ })
+ .catch(() =>
+ CapApp.getLaunchUrl()
+ .then((launch) => {
+ if (launch?.url) handleDeepLink(launch.url)
+ })
+ .catch(() => {})
+ )
+}
+
+export function handleDeepLink(raw: string): void {
+ const link = parseWidgetLink(raw)
+ if (!link) return
+ const now = Date.now()
+ if (raw === lastUrl && now - lastAt < DUPLICATE_WINDOW_MS) return
+ lastUrl = raw
+ lastAt = now
+ if (isReady()) {
+ window.setTimeout(() => void run(link), LANDING_SETTLE_MS)
+ return
+ }
+ pending = link
+ if (waiting) return
+ waiting = true
+ whenReady(() => {
+ waiting = false
+ const next = pending
+ pending = null
+ if (next) void run(next)
+ })
+}
+
+function isReady(): boolean {
+ const s = useStore.getState()
+ return Boolean(s.vault) && s.workspaceRestored && isMobileNoteIndexReady()
+}
+
+function whenReady(cb: () => void): void {
+ let done = false
+ const finish = (): void => {
+ if (done) return
+ done = true
+ unsub()
+ window.clearTimeout(deadline)
+ window.setTimeout(cb, LANDING_SETTLE_MS)
+ }
+ const unsub = useStore.subscribe(() => {
+ if (isReady()) finish()
+ })
+ const deadline = window.setTimeout(finish, READY_TIMEOUT_MS)
+}
+
+function hasNote(s: StoreState, path: string): boolean {
+ return s.notes.some((n) => n.path === path && n.folder !== 'trash')
+}
+
+async function run(link: WidgetLink): Promise {
+ // Whatever chrome was up when the user left is in the way now.
+ closeMobileSheet()
+ closeNoteMenu()
+ setDrawerOpen(false)
+ const s = useStore.getState()
+ switch (link.kind) {
+ case 'new':
+ // The ⊕ sheet's "New note" (commands.ts `note.new.inbox`).
+ await s.createAndOpen('inbox', '', { focusTitle: true })
+ return
+ case 'open':
+ if (hasNote(s, link.path)) await s.selectNote(link.path)
+ else goHome()
+ return
+ case 'task':
+ await openTask(s, link)
+ return
+ case 'tasks':
+ await s.openTasksView()
+ return
+ case 'home':
+ goHome()
+ return
+ }
+}
+
+async function openTask(s: StoreState, link: Extract): Promise {
+ if (!hasNote(s, link.path)) {
+ goHome()
+ return
+ }
+ let task = s.vaultTasks.find((t) => t.id === link.id)
+ if (!task) {
+ // The cache is lazy on the phone; one per-note scan is enough to find it.
+ await s.rescanTasksForPath(link.path)
+ task = useStore.getState().vaultTasks.find((t) => t.id === link.id)
+ }
+ if (task) await useStore.getState().openTaskAt(task)
+ else await useStore.getState().selectNote(link.path)
+}
diff --git a/src/ui-mobile/pins.ts b/src/ui-mobile/pins.ts
index a1147f1..60ac8f0 100644
--- a/src/ui-mobile/pins.ts
+++ b/src/ui-mobile/pins.ts
@@ -140,6 +140,14 @@ export function toggleFolderPin(
toggle(vaultKey, 'folders', subpath, livePaths)
}
+/** Plain subscription for code outside React (the widget publisher). */
+export function subscribePins(cb: () => void): () => void {
+ subscribers.add(cb)
+ return () => {
+ subscribers.delete(cb)
+ }
+}
+
/** Reactive pins for a vault; stable snapshot while nothing changes. */
export function usePins(vaultKey: string | null): VaultPins {
return useSyncExternalStore(
diff --git a/src/ui-mobile/widget-links.test.ts b/src/ui-mobile/widget-links.test.ts
new file mode 100644
index 0000000..0c4e2d3
--- /dev/null
+++ b/src/ui-mobile/widget-links.test.ts
@@ -0,0 +1,59 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import { isSafeVaultPath, parseWidgetLink } from './widget-links.ts'
+
+test('the action links parse by host', () => {
+ assert.deepEqual(parseWidgetLink('zennotes://new'), { kind: 'new' })
+ assert.deepEqual(parseWidgetLink('zennotes://tasks'), { kind: 'tasks' })
+ assert.deepEqual(parseWidgetLink('zennotes://home'), { kind: 'home' })
+ assert.deepEqual(parseWidgetLink(' zennotes://NEW '), { kind: 'new' })
+ assert.deepEqual(parseWidgetLink('zennotes:new'), { kind: 'new' })
+})
+
+test('open carries a decoded vault path', () => {
+ assert.deepEqual(parseWidgetLink('zennotes://open?path=inbox%2FMeeting%20notes.md'), {
+ kind: 'open',
+ path: 'inbox/Meeting notes.md'
+ })
+ assert.deepEqual(parseWidgetLink('zennotes://open?path=Ideas%20%26%20plans.md'), {
+ kind: 'open',
+ path: 'Ideas & plans.md'
+ })
+ assert.equal(parseWidgetLink('zennotes://open'), null)
+ assert.equal(parseWidgetLink('zennotes://open?path='), null)
+})
+
+test('task carries the VaultTask id; the path is explicit or derived from the id', () => {
+ assert.deepEqual(
+ parseWidgetLink('zennotes://task?id=inbox%2FA.md%233&path=inbox%2FA.md'),
+ { kind: 'task', id: 'inbox/A.md#3', path: 'inbox/A.md' }
+ )
+ assert.deepEqual(parseWidgetLink('zennotes://task?id=quick%2FQ.md%230'), {
+ kind: 'task',
+ id: 'quick/Q.md#0',
+ path: 'quick/Q.md'
+ })
+ assert.equal(parseWidgetLink('zennotes://task?id=%230'), null)
+ assert.equal(parseWidgetLink('zennotes://task'), null)
+})
+
+test('unsafe paths are refused', () => {
+ assert.equal(isSafeVaultPath('inbox/a.md'), true)
+ assert.equal(isSafeVaultPath('a.md'), true)
+ assert.equal(isSafeVaultPath(''), false)
+ assert.equal(isSafeVaultPath('/etc/passwd'), false)
+ assert.equal(isSafeVaultPath('../secret.md'), false)
+ assert.equal(isSafeVaultPath('inbox/../../x.md'), false)
+ assert.equal(isSafeVaultPath('inbox//x.md'), false)
+ assert.equal(isSafeVaultPath('inbox/./x.md'), false)
+ assert.equal(isSafeVaultPath('inbox\\x.md'), false)
+ assert.equal(parseWidgetLink('zennotes://open?path=..%2Fsecret.md'), null)
+})
+
+test('other schemes, unknown actions, and the Cloud auth callback are not ours', () => {
+ assert.equal(parseWidgetLink('zennotes://auth?code=abc&state=xyz'), null)
+ assert.equal(parseWidgetLink('zennotes://settings'), null)
+ assert.equal(parseWidgetLink('https://zennotes.org/open?path=a.md'), null)
+ assert.equal(parseWidgetLink('not a url'), null)
+ assert.equal(parseWidgetLink(''), null)
+})
diff --git a/src/ui-mobile/widget-links.ts b/src/ui-mobile/widget-links.ts
new file mode 100644
index 0000000..6e9971c
--- /dev/null
+++ b/src/ui-mobile/widget-links.ts
@@ -0,0 +1,61 @@
+/**
+ * The `zennotes://` links the widgets fire (md.zennotes.widgets.WidgetLinks
+ * builds them; deep-links.ts runs them):
+ *
+ * zennotes://new create a note in the Inbox and open it
+ * zennotes://open?path= open a note by vault-relative path
+ * zennotes://task?id=&path= jump to a task line (VaultTask id)
+ * zennotes://tasks the Tasks view
+ * zennotes://home the Home dashboard
+ *
+ * Values are percent-encoded with only unreserved characters left bare, so
+ * `#` (task ids are `path#index`) and `&` in titles survive the trip. The
+ * Cloud auth callback (`zennotes://auth?…`) shares the scheme and is not
+ * ours — it parses to null here. Pure, so node --test covers it.
+ */
+export type WidgetLink =
+ | { kind: 'new' }
+ | { kind: 'open'; path: string }
+ | { kind: 'task'; id: string; path: string }
+ | { kind: 'tasks' }
+ | { kind: 'home' }
+
+/** Vault-relative, forward-slash, no traversal — the same shape the bridge's
+ * path guards accept, checked here so a bad link never throws. */
+export function isSafeVaultPath(path: string): boolean {
+ if (!path || path.length > 2048) return false
+ if (path.startsWith('/') || path.includes('\\') || path.includes('\0')) return false
+ return path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..')
+}
+
+export function parseWidgetLink(raw: string): WidgetLink | null {
+ let url: URL
+ try {
+ url = new URL(raw.trim())
+ } catch {
+ return null
+ }
+ if (url.protocol !== 'zennotes:') return null
+ const action = (url.hostname || url.pathname.replace(/^\/+/, '')).toLowerCase()
+ switch (action) {
+ case 'new':
+ return { kind: 'new' }
+ case 'tasks':
+ return { kind: 'tasks' }
+ case 'home':
+ return { kind: 'home' }
+ case 'open': {
+ const path = url.searchParams.get('path') ?? ''
+ return isSafeVaultPath(path) ? { kind: 'open', path } : null
+ }
+ case 'task': {
+ const id = url.searchParams.get('id') ?? ''
+ const hash = id.lastIndexOf('#')
+ const path = url.searchParams.get('path') ?? (hash > 0 ? id.slice(0, hash) : '')
+ if (!id || !isSafeVaultPath(path)) return null
+ return { kind: 'task', id, path }
+ }
+ default:
+ return null
+ }
+}