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 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 + } +}