From c323811fc97501834feae1b1b1bfa16205cccd68 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 8 Sep 2026 18:43:39 -0500 Subject: [PATCH 1/4] feat(mobile): Home Screen widgets Port of the iPhone shell's widgets (zennotesiphone release/1.9.9): New Note (2x2), Recent Notes and Today's Tasks (4x2 and up, resizable, scrolling lists), on the same src/bridge/widgets.ts publisher and snapshot contract, rendered as classic RemoteViews in Java (md.zennotes.widgets) so the build stays Java-only. The snapshot lives in the app's private files (files/widgets/snapshot.json), written by WidgetBridgePlugin; the two list widgets are RemoteViewsService-backed; colors come from the app's live theme; every tap is an ACTION_VIEW zennotes:// intent into the single-task MainActivity. Pins are keyed by vault.root here. At boot the shell reads ZenWidgets.consumeLaunchLink(), stashed by MainActivity 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 real tap only arrives as a retained appUrlOpen the Cloud auth listener consumes. mobile-cloud-auth.ts now only takes zennotes://auth. RemoteViews gotchas baked in: dividers are 1dp FrameLayouts (a plain View makes the launcher show "Can't load widget"), and the widget layouts carry tools:ignore="UseAppTint" since RemoteViews cannot use app:tint. Verified on the Pixel 7 API 35 AVD: picker previews, all three placed, New Note, note rows and task rows warm and after a real process kill; testDebugUnitTest, lintDebug and assembleDebug pass. --- README.md | 23 ++ android/app/src/main/AndroidManifest.xml | 45 ++++ .../main/java/md/zennotes/MainActivity.java | 7 + .../java/md/zennotes/WidgetBridgePlugin.java | 85 ++++++ .../widgets/NewNoteWidgetProvider.java | 41 +++ .../widgets/RecentNotesWidgetProvider.java | 69 +++++ .../widgets/RecentNotesWidgetService.java | 93 +++++++ .../zennotes/widgets/TasksWidgetProvider.java | 89 +++++++ .../zennotes/widgets/TasksWidgetService.java | 97 +++++++ .../md/zennotes/widgets/WidgetFormat.java | 50 ++++ .../java/md/zennotes/widgets/WidgetLinks.java | 86 ++++++ .../md/zennotes/widgets/WidgetSnapshot.java | 249 ++++++++++++++++++ .../md/zennotes/widgets/WidgetUpdater.java | 47 ++++ .../src/main/res/drawable-nodpi/zn_enso.png | Bin 0 -> 51249 bytes .../main/res/drawable/ic_zn_check_circle.xml | 10 + .../main/res/drawable/ic_zn_check_square.xml | 10 + .../app/src/main/res/drawable/ic_zn_doc.xml | 10 + .../app/src/main/res/drawable/ic_zn_pin.xml | 10 + .../app/src/main/res/drawable/ic_zn_plus.xml | 10 + .../src/main/res/drawable/ic_zn_progress.xml | 10 + .../src/main/res/drawable/ic_zn_square.xml | 10 + .../app/src/main/res/drawable/zn_circle.xml | 5 + .../src/main/res/drawable/zn_widget_bg.xml | 9 + .../src/main/res/layout/widget_new_note.xml | 82 ++++++ .../src/main/res/layout/widget_note_row.xml | 54 ++++ .../main/res/layout/widget_recent_notes.xml | 121 +++++++++ .../layout/widget_recent_notes_preview.xml | 181 +++++++++++++ .../src/main/res/layout/widget_task_row.xml | 48 ++++ .../app/src/main/res/layout/widget_tasks.xml | 124 +++++++++ .../main/res/layout/widget_tasks_preview.xml | 173 ++++++++++++ .../app/src/main/res/values-v31/dimens.xml | 4 + android/app/src/main/res/values/colors.xml | 8 + android/app/src/main/res/values/dimens.xml | 5 + android/app/src/main/res/values/strings.xml | 35 +++ .../src/main/res/xml/widget_new_note_info.xml | 12 + .../main/res/xml/widget_recent_notes_info.xml | 12 + .../src/main/res/xml/widget_tasks_info.xml | 12 + src/bridge/mobile-cloud-auth.ts | 20 +- src/bridge/widget-snapshot.test.ts | 147 +++++++++++ src/bridge/widget-snapshot.ts | 225 ++++++++++++++++ src/bridge/widgets.ts | 197 ++++++++++++++ src/main.tsx | 6 + src/ui-mobile/deep-links.ts | 151 +++++++++++ src/ui-mobile/pins.ts | 8 + src/ui-mobile/widget-links.test.ts | 59 +++++ src/ui-mobile/widget-links.ts | 61 +++++ 46 files changed, 2808 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/java/md/zennotes/WidgetBridgePlugin.java create mode 100644 android/app/src/main/java/md/zennotes/widgets/NewNoteWidgetProvider.java create mode 100644 android/app/src/main/java/md/zennotes/widgets/RecentNotesWidgetProvider.java create mode 100644 android/app/src/main/java/md/zennotes/widgets/RecentNotesWidgetService.java create mode 100644 android/app/src/main/java/md/zennotes/widgets/TasksWidgetProvider.java create mode 100644 android/app/src/main/java/md/zennotes/widgets/TasksWidgetService.java create mode 100644 android/app/src/main/java/md/zennotes/widgets/WidgetFormat.java create mode 100644 android/app/src/main/java/md/zennotes/widgets/WidgetLinks.java create mode 100644 android/app/src/main/java/md/zennotes/widgets/WidgetSnapshot.java create mode 100644 android/app/src/main/java/md/zennotes/widgets/WidgetUpdater.java create mode 100644 android/app/src/main/res/drawable-nodpi/zn_enso.png create mode 100644 android/app/src/main/res/drawable/ic_zn_check_circle.xml create mode 100644 android/app/src/main/res/drawable/ic_zn_check_square.xml create mode 100644 android/app/src/main/res/drawable/ic_zn_doc.xml create mode 100644 android/app/src/main/res/drawable/ic_zn_pin.xml create mode 100644 android/app/src/main/res/drawable/ic_zn_plus.xml create mode 100644 android/app/src/main/res/drawable/ic_zn_progress.xml create mode 100644 android/app/src/main/res/drawable/ic_zn_square.xml create mode 100644 android/app/src/main/res/drawable/zn_circle.xml create mode 100644 android/app/src/main/res/drawable/zn_widget_bg.xml create mode 100644 android/app/src/main/res/layout/widget_new_note.xml create mode 100644 android/app/src/main/res/layout/widget_note_row.xml create mode 100644 android/app/src/main/res/layout/widget_recent_notes.xml create mode 100644 android/app/src/main/res/layout/widget_recent_notes_preview.xml create mode 100644 android/app/src/main/res/layout/widget_task_row.xml create mode 100644 android/app/src/main/res/layout/widget_tasks.xml create mode 100644 android/app/src/main/res/layout/widget_tasks_preview.xml create mode 100644 android/app/src/main/res/values-v31/dimens.xml create mode 100644 android/app/src/main/res/values/dimens.xml create mode 100644 android/app/src/main/res/xml/widget_new_note_info.xml create mode 100644 android/app/src/main/res/xml/widget_recent_notes_info.xml create mode 100644 android/app/src/main/res/xml/widget_tasks_info.xml create mode 100644 src/bridge/widget-snapshot.test.ts create mode 100644 src/bridge/widget-snapshot.ts create mode 100644 src/bridge/widgets.ts create mode 100644 src/ui-mobile/deep-links.ts create mode 100644 src/ui-mobile/widget-links.test.ts create mode 100644 src/ui-mobile/widget-links.ts 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/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 0000000000000000000000000000000000000000..bfabe26f43e85ea49a43b242add74af9f08ff11e GIT binary patch literal 51249 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

)Pc@Ik5Jb)P)l7s)JK!x z*$A@wBsA2t1-|AW)?03U5@cKj?LJF|3PJS|79dj?2^?6i1H`c{e0uoU^xl1s|8B|a ze59&*-(Eu5uY~gOKFZNheRE1ZeDLt0`I+26I2Kx7mrSb9A3XlxiNO;mmUVUiX+vj2 zt8GUeQ4~4L)qI}k9na?o5s80v|Ne=&`Sfl{;6K~e)<)N?Su=}Wm+R~6llSf0SD=)J znK}K|HKDfvXUrQJ#Dna)o@aJI!b` z3UNX_(=;7Isg*Cd&c=-!xU<`wRlSwVaxg-J(m|8Rua%`(%^@^VQrAt?Bdg{67{A@Ohn(9&zk`rIY~CC<48xUtDN?QtHlnJ~5HxeUgg%xx%!KYSdnAWfTBt{L zjIoo5h>3Z<;BQ>NdQ&=|lg4K!3B}axXly3b<-l@Hm@AAxZ8R0ccUi(lh50+^V-%!I zmMV={*vNG)C>nF1h)F0EGH7;wh7Jy&`U?c8T4hufCMZ3UJ|PMaa|-&rM|_JAOpKf{ zx3oRQ_a8d?EuNFE{LHU?a^rym2V6&S{8TFC@;t92+ctQ~vrXG}Qw_=Xa5NmzCrwfm zMcd;%$F^;Od7$T~<_CZD*pDvhTKEkEuu`Lhv4AAog#xefnoGErL`^i&+S+vEY$go^ zTz*M=t6G<+$Alo5Ob-LcbD+tQ1&{__tHAqQGM||Pi#=y+-b{WRFeicQU>_lh$EHUA z<8S`*YY*Rb_gz)TRaGdv1Po-Z?`C8wE39~d@{D@Tr;$hv`p!Rn=VYO1{&elyo|}h< zhaIJ%0kf_f{r&yEuIoj?5gaR%p#mojtX{F~rbq+dvc9d&;yg}wLvFD4J#gSamM`-B zrNr4A0pP&ijYVV4BEljuO-s>~dRDC8v$XxXCEd&7^SLyuA3`fSx~i_^q(<^)NI+F+?0uf96Ed+(F~Cd$(L@4WN&HPaIl%t6J#NCiRQJui(? z&CRhV9(d%8PpteEYkM~D+_@7YgiM^n3!oJQLGSMF7T!t#4(cVbDAfDJca@mJdjw=f zQ+)x*X_WD*Z4HJq+E{NwQLUq7ZYJAIGN}2PD0XV4j1rn@%bA?IZ zI39?P385YuogRMl8(+Tn*&s8g+{UshRS9G#_%h)}<1(%{vc{B8d{Yv*a3mfM{pfr5 z4s)2Fyz$m+e*gG0&p;xPVC3#pu8}2IP$Xhmv#f|=Zg%ESO-$>6XjExxYO-C|#hW*8 z4ssfvde>7n$GYOZ1pr(^9sty}MH&$hE}O}YfgsA15Tzy=yJF??)k)p4k!}`%F-&6M zz;Ru$i35>v7-W$ZE0k(|&n7*yhGfiiE_D|q6a{f#RZ4+g)bY&x1e`j3;D56VzjNo- zH#+mIDwmf~Hf23-S4h||@l%s@@lGZ&6=){Q=jH%;I58!}Bt_ONV zf~0Onkm0%<5eSWW6#QgOtiHLi6^%|z(E7SKEN$%u!_))Xo2kqhNG9q*;^ZL583nb1 z74VkG*_j|DK%p=Ttf(J>!af3xPmVtFkAHp71B{TpTIXAp7ZabFlM?4Q`9(jg3ZjU> zliE64Vqg5*|FgTO+oLyJcg;r+9XgbGe)M_m=+UD(Mpz<*L>NQm-?>&({)}VH(Zy7W)o7iYH*UDi7hG(Z zI%;of1`ar|Z3pHH(||b)niL5N^h-)N;DRkXuS(^DZ2gN72bsbIC~^W^$3u*<D08D*a}lEoEJ?oM!}% zV}tGFK$Vlk^<6wMIePMcfA`z>Gw0RTtvjo0->STr1o`lqpiOhC8g}z;-t&>kVJvnn zX^%c||6_Y8A(=ZreCOuzqvO79+bWTWRimm5tWJG>y*DvB_59RSdZMYbmL+eTn_pi_ z*DW#4yg329ZQHg0z^VGs>S!#~;@KXABB2Pyq;=!k^*wD3OX2kJu&*d0l>`M0vk<(G zfDZyEfym3?v#hIf-B5{Tl9re@SlT4wpkSl}UTEJ%)UpkjNzZ-@hQVUq!D_)mRbDo^ zW7n=M7F@>{ec#rdBE}pR1yP&N=>E{?#1F#}<%)DVUDPxU**2!&8mX(Ux~i6obAn;{ zGp_4Zh!BIh&eqFidMKvc#m%Fm;+q!0JGSo#?nsI#wDLSAl(+Dj;$7)tNj-ys4lSdd)A_!3)MR^~< zqCj=u_wnj$S4SBo^3feT(BR-;C7i^X6!>o%00)$tXWLCM2Qf`_CnB;YFVzd?tgLBj z!P2>&?OG8Xn;rHs@4Km*S^%#^Oy?sHLRuJPK?`EP474xQz$7SOU-1+JGV`^Bt z@|HVq**KEPAXah2aV1BUolcvaZR-?)u4rPN;;^8oBF*&mv5c$$v@IwG6K4>IINA`e2jp|$Q5Rh50?&&eaw-G` zN0nWbW4|o>ih-IHHRKCZ!1Ek%SfP$*fbPsc#zsE&*!I_z;-o5XYC()kOTo&d<6sBN zm_vr^wskZo@A=+$juM~DuU)fz&GBcR@oQ>oxV$wt7muhh)`Ty3x96HZ_#9P=9u$Gk zX0y!m9GKKQckW~imTw*a-+S-9+`<8u%5@C{dsHK8hwl*Qw)fonvG>0J z-M?|owb$HG8;ihnZrq>G%#oJ51}E@2XVg0wtI)PCA`K2yqTVGAUU4@++>WpO~6`LJdVW zf~F}BW(~Q(k3^!UBk|D6+1c452zEgLQezMz&N@!9s3^hw@9XPZlzH?f0C2_gR+hjs zx3qU@trk+c4c*8tS-Ru{tsNcf6hWdQC%~D>A#B>Fp^75G2mxkJtg;3!wMgMgvEM+L z^Qr{kVEKy~VVwzvnPp&S;UhYqoqd3P2X~RX*l|<=T$NWWj2g$pbV?Rc$mLv88&T!{ z1N#R#4r{;l8^7HB;OK)a&Wjz}8c#&ERg4~b;^c%QsS;VergPn7f4`gXeTD6dRVOWK zw}LKv05i{<<2Wxk(9XurM&EHl^({?3k|If?W8=2SW1E5tQ?pYP6MrHej!~Wyg3wW^ zrk4S@)PuY*@@L9>l;H+jW;dRN+zO^G3Mc! z=MEf+Yc=U)O;VsiTR=bPELd7RVAd_A?7S*Fu$cFmg^+?1V7brJJ_gz2uz)i8-1NR9 z`=4UNw{GpJYTv578VTkNW0ph+8G;~SqB}FJxL+??dqQ&P8uof96yhKL(ZiWjCr9o^ zfLhs-F&2-6B}HPEAHiIhdLd6^Ws9s7G1&9YiSTNVZbAV6l{cehjUy21-p(jE()|anM{Vp3**3I zzL~spoC2}ZCckpd1BJgJk648YV@|Ix^~tJY4GUCz~JcQEX&=10u)_Isw8g&D#tWU>9Phe zJBgwrRuZ`p1074~P!y?ImP3-F%Jum|!B!O!TaE>mWjV2MSW{GqV_A_tTmLR(lCsrr z`4G!1>WTv^xEjoX<(QDyvs4nIIA6%a?A+Xgl?3vtROK}aiDsfK^NN)N8qr8nKKT5g ze&WRGJ+azY^Vrx}U}f&wxwCle=)mKAoR@a(+BLpr#j;hO_{~qWuyB;bxr^J5Qbkeh zi`hT;q63(D+*sA~Vrg8~7*ixZDe(fwVa)lCXT+lspY=1M;Hr^WC>oMg@O(G;VbLLG zF$dL06)g^)=L{U-C`1wM5l#T1@sjh#k^4f)ET$z$2g+d_* zkmD8ddDPL-(iV=XEdZbyVFNSzSOP>ZX1s9AvSmrn_c@J|SF-oZk}UW2^)0j>WzGAF zk|bZ&YhcT={1-T|Y7?z2ojIO})nT3$-4j>fITX;uIyQ=jV<0f@X3t@z2xYG`JGjNm zh4T8hY+hGD?D#fB!nHxhmTu)|jy%14jE&x2&b_WmRbGn_LOhwXm}d0^BG^+iGj=Qz zn`vrjU;srCE~4fm5pH&Dc8qgfC# z2w=uUpsH%H?mfElXvy%!wA*rABFa*e=XxMXJgtq#6iN^{zC&63mg6d3=Zdvlp^Ghr zkSZp0MH81=h$7I94Dgtz9EZU$^of1@(yZ4}DM70$Re6o_)KgE{p3Mg>OHCmtkt3e# zPsg-KJ4;XqK?t}>iTbe^DijK)Ac|f#Q=H1@3KQD^P;ss}W7$DQR#4Q>5}e8%6faKz zV~ly$xtu-UQrXDyF(6=NGr6NhUH9XWm?9}6;&~o84&it>!n3Y}fs`9_m!AN$rz_4Y z*6S#E3@VXI8rZapROB>LD(XigY!`%(YWZMQE|w4<=|QvZFb&(b<9LXAKF`xOR{URT zL~o-I$AvUaQ;F|mnd6b736UKDLD55k9)&px~Fsi}#{W8nyEqrxFpe88eTs-y&5w_dyTxL$lHJ7;Brfkbu;M6;06W0kmMvR?gDn(v z!PIRt90_Zf!+sq{%WCcOWH8m$|ZQFL@5Ql>Y59%(* zaWN?tWxa$g*UswED7SR|(s*rSZ5`XsmMvQ_ktFS6b}qamnri3HoiA`8A%r46<99_I z_6T(Zo~277Fl`eAfd_=kw3N-iZ%1|^CTXRn`~TEl57rfGNUG!u@vK` zr)fM9Pe_7Pvj}hsYgu~P0+{Fd!1!ea4Xlcxp`k&wEQh0nwNRlXo6C67NCae21Xb36 zfmsohAhsK{2`&4pN@jHF*+SJ|*%MtkbVlHgF#?_wkZsxEc#vkVR3pGuxu`kO7%Qx4 zH+!xs9I^GH>wEsZ>s!pzjPJht?!YPK`%}6=1ck%CC`f`5kH_VyfvM?iK08}hY;0eg z>$>Q&1+eFNXpt7CEiEkx%wr{+&lO{lNPA~z*9wk9ROChE5hoyEmjp40{RV4W=AbIq z`<^ARt7KvMB?gX#nqV&&$S#O(75zfKkYTU&9I3{Dt8&o<5kgQ=DF$XQCupXgHHfT8 zwaIuqi1prZ!`2{KW7n>uM$vW6a5&tSs;_A{bZE-eIgZ+nT}dkr_Tel3;^dCY3c!eU zCtpAv08MqJLb_=dmvnYtdHZ|c^OI+kk?|D~zOw-8l4vC!U|X$mdg)x4HE7^)ZiePAQgZdy!yU>9p<shw-UGG{R&So-*6FEJoi3OmX=jlji`N5Z~6RTXrTuI_kR3+lO zKCN$VY!8LQHM27_j_nwPrBN0N%sWSc3tGukOyf%1l(Rrxc)n1QR1(JA!NAYDH4vYe zC&mZZg{l_uSLI>}WeFTCcFQqSk!^6PtVLycHa$P)`@zCatgZEfs0&LOP9{ap@qJ}% zV)Bp_Z$g4)5$aQcd88RnoX7JL&-0a3(~duFcvJGG~@8PJw#~dns4tZ7N5% z9Q9aH1A8umY-DEhrsuh1nygWfWpQApATjVRTeb`xJ#$9v?r3gORH6MR-~WELzP{dZ zY=@7><7F;%ht8;KplB{X0Blb4j|a&aAyrjmO`UFQX}NOcbsK8Z>9m4ftb2kW{o)tDkO&aZ zDHieN3E-VUwMB5J+P-~zIgJvOSVV0B%q1<$0^<1;@fh(O4#G#WBnA~nmE2~gb5Ys* zTv_lg#D5n6U8+c8@nhC*ls$J{hk5$!atdWNj8v72CX5c2XKOWH zxq==I%Tf^+`DirC{mZ}ni)UssdEz49C)`+WeoB&MS!F!anuHrH-U{(plbS=g()1f$ zZYDH217~2721dkj7|Dj?dA`G9zOLu$re#sq#|Q%^lm<-p3z!SsSAip|Y(7`^yhs^W zOl{Um6tVSlX+sRUK`IZb+R;_HSi)E;Jy#SlD+AO`-^M<6bj#K))6N6ODWa9&%IU+D z6)QNd6Y)Ji!%;abNs?`GoWqhJ7$H2htO6pkvdBS(E~_;#tDj^~wr<@T?3+@`vm_2x z3$+l!Itlui_40LHk9nOL7j#g-Up9spZ0Ln_$^{QJORB6C3YSsJN)9m!-p_M5*~mAB z7T7wfQk9D!n>TNkH*emoa0u}@gll--WAmElDCc8Az=Fs}`LHDq4-b3HNmZJzR#Z0& z)F@a5gpem-EPJMB_`Z**NLAZ)11q&yES|01xm>)^@pwG&urUsxWy_X@Y=>$b#}S|U z9LHl;oKR#;l(yqAVs;d?Q7MOy*kc8_XV=7M(YWBys<3rn5r}UFX15?{1mj{=|5oK< zNdPP=Np-BkPQy6FQ@#NM4hganQ)MxVDJ9IV7I{%^Zf+LJ>@&J3i6W-Rn;jcV3xZHY z7*k0TVBdIebfP@|@#1f+T37FY2D}*(|J7 zDQ>vj0LkIL^4g`ZuTSPhzQ%DJU!h7^RpmNSR_h$sF$7iM zRE=+JYiqnYUKz*V^z5==xl>I#Y~CU)&gxmdzc)|8$AF$4iIv(K8% zq$ihkcf7B)si}Q*bkvQjaRKV;RMRl##-}F^);Bh5CYUTskWkOHJy*cUvK*0+%QhF9 zHF0{u{tesA@uJ9Mj!Q7}*KwUJrJi3VZbz&bp;WYRHs?98uPaF#i>&Bi`y%zsK+AIo zd7eQvHL7$hZEIq$96efQ0;x(>UW0VCcg5Q~TCV`u=}VfVD5?;a1WrL%q+_!g>z2Oj zuWf5;P7&mZg+d{%gcJhP)5NhIVR~$SI3b33zvx?{KouV$rng*4k~9mU%af6VF%9(f z^(}yB*|KGtZ8-(%`KIrAge5?$s+w?IPhfe^60ZcTl?%lRWj2n0d9t#tqJx9th7zDl z+e@GqL=6z8B&Ub$3!C`~n~x=17x6dr`4p}KHf?M@eS!hB&) z>S%AQTeoucXTp&XbzFxzZs~pBzK>DvScc&UE|5^cG-r99=MW>G38+031Tuiga)2+A zTvU?j{{H@;kSO?r0)#e%d>;`V0TmI?fhbB0eJGvDLVcnpCrVI&rqK2yFaFmqZd7D={P$YIR;EVs-wEgIHBHM(I? z)3I3mtOU(U_wGf^lO+JN4umBV_dqiT-Br%!N>cEF<5kvP1+#FHs%RT~dO+NYKFscL zRV`eVS0&rGZx5`}>lOr#I*v7I8M$K7%7g9O;Cgm=Y^=_! zbR)?YigOeZg*E=h0>~WC`K;|NLAoXIFwJ9rfhYR4%0-U@x6jT70A{>Db|m6Y%uW?q zpCXq<$5`eDre-!@v;q?uQ5IGTT8S=XUjv zfc{{{b#}INsFGNtYD&-_iP*&FFct+YM>3-s-_5&X(JW@+as+UHf4`q8QR=|HefxyG zl;`S_DUMK|Q$yk^R(_z!va9RH7%Q)2CN<;1Dltu#yw0pJ;cW45rO2UD*H8v@d97S} z4V6Ms#0800Yp&|u*uaKt*?Mu?I9BEDBRh8NAX~O<5qX|ZOpH!X&dkr{R&*`7K@|9D z0-hBKtBH85HcWiVG7KRWjE45RU|UAurKpUV{T>ALzvOY_oZ2}fj2 zQ?<*M6N{pFiEThwAyD7-k!w>pa#$H5Vy15;7rIi+AN*TH zowLx1p{$vM?d6=@(6c^HSb0S8(35?GQe5A-Cu}sG= z8oKXOFYqc?wC+L{)8RXTk5aQpIA-Pc7zN_Vc1 z%Y`*w8$UgsU$v?O%Zj9Z<$ry>AWK+c-5f5KTy!(jahzgM+`I#*WJ@?bmT_IrO{=mb zW6XIxk2RkXH$t1fFq2Gmnk#IL>8S&|hlq&81Q)c1NOq z5U|!w%e04hQ3!=13a`it@+nbr1w-dKA>g$NG;q04p;R(hiusoKp(`ADOiJio=zYYP z@q)j35JUw7^&z6w-n6u@p_bXyFVfM;Se*#+5L||Rcqaiiqxdb;f)Z=hSO=a`N8N`*etw+jnZBpWZ zUdX=+G_Sv~23}y~C^tQ0>*tCYEebLwzDc8zSj)9*R<927va61)s=PvBl}6pSc1uOG z7}5*oh=UESt9$tsJuB8q#G_VBQ!P!>nDdnarpUA{n3V8uItkN{{Dan{$j|*1aN>RGj5s=#sPnj6tx|IJR!s_upYkI zT*k*70!5bP`E1s;ZM#GXOM~M6bDUJnbgr1zB`v(rnK016W#*2+lf?@9HNafp$bzP( zt~<(R^hG?qs=N&ZvtMiK*R)~)Emt%$t9n*8U3=xVS2?yr=F{0sV|_%mEE_P#BbG!Y z$Q-F_YW&dccii&-Em_jO6j9EboS1(uszmsF-X%rDuy{_$L_!h9SzcV<6B@smlusAk zgg(0SsH@6YV)P?QVcd;0H{(pU$kNf67Yj2IcSVw%`Aj~m>n7nbk64EVW-c$(DpV3V z%Kw5*3%_4jFA%wUG+{d{drj4Kw3lo2tq`UF*#u_(!QdvimqrJKM?^_Wk8A-*?e^17DJAB!(u3IC)8uWY$Zjr@7f28=sod4RahY zUci#ev)zfvbc3O;ccnbE9;XjuyfRJ5$YSt$vGy)Fo9 z!0=HMtLwaO^HsO7aoe^ByhBx~%FD`j2zcDML_Bt#?US^qNr`pq*Wc#&4&?Nr9Sv)$ z#3?YJnP-Kv;CdFyXLVQ4nTI-B8pF0@foZv;`}Q3?N_{#pTPV0omUQ+Aq7ciQ#(eM@ z+qYlT`FKf^9eku&RMP_DuDyHrTEoZ2#tM03q)^DS+DN)$`I3-fSO)P)f#pLp>zD<^ z%VZlR09UG$$f9u4La_tm*ToCHluFxKR+fSgrcfKN6R`+E2legSAFHNSR^>v3(ZtYC z{MIL0P1B5pBGJ?@f9jL}*w)msdT40GJT-hepQ=gJ_)fsX28@_7V1=Rl>6X?+M@vg{ zhh^Awe0DNj8;?e1i3^{ZnBZ3SbbmAw3B~UD@h?uW@aoQ^JMm=?U?db=-^q!UJI?gT z$cSl~&l2BfP00L~rUp@qsF9he^oSy{=I20NpK01s@87dUgsilQuw3VoSskQQ2E&>8 zQm#xYkE8@J5?O%+)#MO#v~_>%5B~J?A7)?5wr$(0TDU4NDU=39>9Dk;={f`+Zfa>> zA!~wUSXnwadOAnI6B_I4qqglb78ryvrmpY7Tz-Bw5>XpiUccp9D6glFy95=eLyX3T zWKs~6BoO@dp~Hu#*Q{B?DT7L&*e=^d4)#ki6@$lofXs4vA!l3G_}tt)9U2*d#+o`< z(%#ZGpUDphT#47&X1BHlBFaiubcGMP)ONJIWuZUPLSaLtZWtkuWDX*c5Nb%ZG{tMQ zUtzCr-@g5#m2Xw$Ei07$k_F`sPE<8TR+ou9P)%06gC`H=I$P@7WM1M}ZUQcM3~?O~ zP7jTar&6iSJQ9)X`QGH@{9vlKHtf2E>7tSK;?YRlv`bN=RjX{0OeU?%UIVjoLc}4L zwHRf2r47))PmIrwV*qPvV#=AEn}RFWu5K6@A2Mg>vo41*wp{xxCv+L0rS&c<^$iYw z&gM*)pj(OumsmVXu*aB3ipoJU-r({gtp3v9e(}9b3*UYBmZ}!6%6Wy+!Lm8>nLqeU zo9DQ7z~MQM_@TCjW<&|iOihiQY^kf~Fko6*@?lC4!K0Joa~M(7(cGRgO$*GTY0OT| z&B!6%&l-Gk?aH1mO%d8Arly`}pYzZ|8FK&q_g~gdEGDI5XbYL+A>)%Rg;n{d_sl+g7iBuk6 z0z0c8)`0jHP1Q8XvZV4#jQ@3ZJX^L@TaH%c{IYW8%3#iRH?>|nlgSQ+BC$rxHvR6l zCOCTH`0Ndvu3XcUY7SHcn{zBvUl2G*X9~MJ+d7gVRSDK~g?!;z!}aoURm}_x4%%H^ zE$tkZvOoX9Blj=*xQiMUxpWmfQSfjihzHZ2o*wbQ6GtZV`NC6{Wjb@&v>6FUn5o;E z&SobiQ4B7MAP&7Ov|#mrXd*z3?iA$&DN}3*3$+qhJaO; zrTP+rSPnxvokcaV_zL2&pa@2z6BAE%wKT6Q5sTF^)|hNI zmz$WMYEe{aNwH{S4QsBdN~xLonMcc|#g}C6T{4Z--{0>_L_&;Pch@&}S)I!}gcHY3 zofHIdE*^<<*+L#V+gqE*r=|<@nT#j!JhDBvtZB=cR^{8(SyQ^?%vvy|*?>}em7q~t z3Ghq{Gp=Yv5qZzydAeXgpu)WgZ5G#n|d1y*C)vAirv3^zW z=b59Ts)eg^uJh`Xzy0AZ!^ml-?O=+CyrQ!UWz(6oVHti$Lz}{AozE&1ifqe+XjJQH zZ|PWx2u}^mfRV}3qNZwQNKvBA&CLznE4mwPOCQ|*^ZrxpeUCl%7`_x8LoOA71C+Ja zv}RO^1aYs&?z>^)WdD&<>ACsizGFi=TX2zpp`)!eIy5pq%rP^V&=SdAa9|aZ_fka? z3pM%F1qS|wcyX}qEd#a^6E3fXnbpE7Mrslb5{J2bB&`1C=Rg0MKnqu5epPvU%l0yL zPS27R|1GEIX>)V)wHTIz)i&{1aAM0=wttk}aW8f{TUFk)+L!#% zw%^;3E#%gPV+lTyOm^19qEt5x-*nAnbE;AGT#vG}UG~ohMnHjqmBC7!1RUanrCy12QM6Fg@Kp9hEENkUYic*Twl^|=;sonZ2nP=yG%vOT>rw-_WJXibu5mH}!B=}M z=A9WHKQ%W$e@YWYZE|)_?`&&H08lYKpBrP%zHQH@j4PTUk!R_W&4Xu|V7erRmsP(ELb zC1Q~#MV1mX)7j_PB`#Zhmm~^u$-^ox&J|Tb4FH@d2i-SWW2Wt~CelDE_MU`Pm zcU!~o_{3pJ3c3|iBjCvnTCbFNvQVj8Ln%I7O1Z2Q`d3PXO8~|tXIA;t*f<9INH~h3 z;iN0e{IYHT{dYgIV+SI4-+gyAr1UnGZQHhCmSFVh&waWJA+n;mv86r~4)@5sWVSZe z9M2Hs5de?_3Z5Iw8LZIas{2P zS-E`qiGiUh+X%WXV&;u5g_BS~*v=kHG+5Lu1P(!`AsL*Q^!CXGcLV+DyYDwv4Or zua%-7KB&A3JYtNhJm`iF`;Q*lyS!`3b(ZanTc$sjOhr4I8tUHdBf2a2wCxK`|1Qb0 z?NV!@Ab4%qzA53wwUYc>S&QeQq#!T#5mK!j^|K| z);BA**dpuLc;XCkX%9br#Fq@N~`!Bz|R+Plsqp>(Ig+etUMRXccNq%g47`l?(;Q3CV44C7J&AC!^ zq?BC5rX4SE=+xkuLN?VHCTUuITc82d-MPk%fBt4;qS~u<9hVZr!?kLqh|4YVUKqYinzCNf4sL!{els+tDE5ElBKO$sb(sn&;UdiVCzfuMi2fctz#^BGAG+ ztJzs^7umM0k7rik_P_nJtpxm@M15ltal(pZB<6Rvv`D#P7Q$jQu<)3Z>TGw+QX&yc zGGd-*$Ms-nWN4%{)sjoaYMOui^9LS&&n-88aQ*6@&B>aY<+fu#3p;oEJ9lmk@WnLn zr8=kA1Hi#50)fi+_4R4&0*0dzXa9jCvl1sg(@>uh$0uiU%}q`G$|cL{XXfWed4Xpd zlQ0l7&$7TA3FVf-OgopHS(UZ$BEGB&pvyVZ?7i&Ez(@f?&ER>eripdB@MM*I>`!<6 z&drQ==Tfats&cu>4a|g2`X0SKZy9HnF7LV7v30V(XB9t_nPef8z{X?Rx02~s0xt=y zK{oq1j$>gaG%`MQ-_nj{S3Y<6@VA#P>1^-rZhJ>6mC#Jb$c&vC*~_NjmMuF2^?j+d zFO%2J6N@!FS0p50tMol{wjtF(9(nwU2Skp;j_dGwy$Ic%ZP%U}8a~46ESbkDXXk?p z$YG)Ekv?1K&j3squSq#3Tq*dkbU9?;Eoor}^1vMiuA2tN9o^Bq8ihk)T3;Xi+s}OF zkHTfMsv1^$3(A)A&H3)T?`EZ~-ny&T{I+2kP4#sxD`i>YI+~kkDiMQ`$>d_Z1PPK~~m?JCbc_YLFj(WcSh8nc1mSDjA-dp7WcU8*4a> z^OFP|TcuXmBPBDtl<09bL#wnF4vHm$&=OnYU=#w^D}v({ zS#k$gA6rJFkX=Q8h9cC2YleTV#1Pu0?ivZLrZ!>5I8h3Jykeydf)&S(8mAx>Ahe7=$-Gs zwXLnL(XuVSm^ZBF4xIQV3xcp5>%P)qT+#*uua^c!umgw(#lp=u->ePXKVSu{ z=1&fsd|VbqaDCtBL=JkEFTL{6vEzGL`WlaTT6B%zLb7mDK=!^U7n;R*DO4zFD_hTI zXR!>Lg)fAW-to0y+S9?`kw_HPM!Tp>jdxvp?YbnpJ68)G-da*D77=qGvH92C-7O&w zZ5tXJKd@%?`s<@HRcxnWN>W@2n+NDFD$^F3mEb^u`ZuN0Q6m^P(EjRj{z zr3K5PeoCe6EL76MSPm+cEPEbU@kAn8i#i%tEL(T|@*M!ZLODKIHKpGo(%;|jvV~5Uol6b(N?k&1$H6n{ z8AreZcjENvy*FKb)hD%xB)g7}3YmhvckjOMZn||7|IKfH(`xH$6L;?1dCA-Ryq?)e z=&7fkGCje=L5bn6U9<5>gzVjSU`)>&2U9h*VAvLsWC8WATXo~Xz;qV+vje(qzxS$jK+ z58l0{+LrXKB6r_?H^)rpKltMn;FduBL88eIHJboCKAR0MD1^ zRlo#OS%#G(Ab|^uwF~uaDCDetw(nu+w@2=JizeSu^TeoiYf8$SnwO5eP?+%YoKDA^?&pJXpv8%O_ z+q7ye_xy?Hd`tI9G!!cVHmErYdLF$%0kB8Qvyf>TbZTxcJ2X5p)OXG1&vFVDOv8j? zzTh1?di<;Uj6OY<9qdvg(i*0$7O2}Up}g+-!29msR{$Rrw%&9L?nHZA6Zg$;fA9Is zT=rCRV^`giKo1r zb3j;XhX?cXb0d46?tkRU-Yc$D!=dK!iP=I;w1z)3I(px~{KGeX!9KewnfPQ)BJ{-T zb*`^BfCJ=-M6~aJ;{GIyk8uPtVJ$56KX>SR(NGA&nkGz5&C<@c_O7Yf>2xu#yUY<~ z`)*Jx92hm_a^A{vx0GIaw#cE9;BhuS%ra;ghWWtUcHBI0T%@$Fap$16wjPUo$X~bO z%J=;KpMC1L*`2#u?C_=)#Oi#R@8ccUU-SR+dQJ$1YeVgA?VX;h(<@hPKrBqT`_SXi z)zB>kG*{)u@@(Sxi}=?Vc-fp|o@fp@bokWJ<8AFN?X~fmji=5Grd3H&#>U2HpMU1? z7yJ79ihX^3p)(^>kN*8%eSNSTje1M1ftds+R9{~|p)Q=l5j7luwA?2T92S-= zr%MKXs!NRo{7@)_PYet_cVgi5sZDFveSAKhJsnXapePbQIx_XWd%pYA{emE9QbM}w z$ccfVgz)u}H=?92C~%sff8F)(W?eBw4x1a6Z$i(V*bCU>pfTPO^xdk^61zSlR4j3>1*S6zn#@x{6u9xJ zDgB9k`@XSh{pvd{)0ik2IWrOqC&ou+2A@B8>YtY`U8<-lb!~fV;}!3|p+8*?N4_o% z=HH0PSt@XtHS@9~$Jz73bm3G(LnHdpPw(Gzdhkr4wW*1b-XT^^)ZW_KIWRmpUt;HA z&IHC#a+Z2F*kM`IGA+#3!wd1?3VY>3o0d|ZG{-S#m3o>f77xyz%EZsR}qxzD-3`G>!9Yi(`luZE{5e{#e1H+;afO)LtO zzW>JCwXvB&G&4H|9myp?5!1Y-dh_7sPnlvUrBDWiFoD&EsAbvS{f|8Mm6hGgSIL6X zJT^J=a5NT40U|IwHuAHd-}R%BlP6E=_4SDjswh6ZW5=cUJTDgf2G(Rc6sX7OcJ}Pq zqstsJ3vtm8yZ_BXG;@xx#Ihd=&0Gsh^U zw5o|OUwQP=M+43K>%aTUw{70A?%v6{*=M_#EWZLcR692}UA*JEn*`5y(c#n2Lvu}M zS<685Zi0oBh>4%wI{!pT>Rcz~MTQxpD>fCzOjk3hcWY@C~xv6jiC=yHN3Eo;N zO(j+kmOM*mj7ww?h-GpaFflVdwrTB}51bwvI^|e?vG1zQp9+VR_`{Fy`TMbrhzv`QoWBDfLYf`m!S58ih(VMTjMr=wo@OuwDP9>~CB2*JZEGnK8 z$2Wr_mlDfM$)aO($|EkLNyJY+|MVlPx>iKhkkD}aSWIzOJQjlhq8c;63?9y>i# zTbsn}ZkR6=prx@{n$6BzhGDp(Afmig1e+yrRAN(Qe&~u*qEhHkabhhbV3wQ{ixNCI zpag^E7~pyah(=~>Zks6WIyjkXXfgCG?QU*EZK-BBK6seb@N%tn zEzFEwXq;!b#bElE!bX9=89eF-q>3De;qZyWxg*C;9&77py7|!YqfcWAuf1y1hO36h zh9|yv_s_r7nye{iAT!4oc|OPu&O`C_*1m5vfP<6Wvu97iaYV9f*Dgob^-%;!ltiRI z{q+8W>G>Rqh9lH4Ez0u(R~xShpBOq-R3rs#*M*{Of=k@9#S1|)O)2=s?zC*JT;VdR zu@6`>KW!jHCvG>o51-%ffiCMhChn1+(sA-yY^ytyjM0~qxU?w4eVsCG6 zA|gc62=QDj9F5tIE%iTlZ~$YBYvT#fO_Mg(Hz>B_7^lyi$%a%7EXM-fHOsNPGOZ5N zx@FGj1yi~lP6|p6D`xcqEo~`D3u6I%&jsDgfa^J+NKK&Y^I*AC(AwI;i@fNsUb69q zk8b_--M{vGx8A~%J=prInz41!WnopsmR9VMg&NByGcX4cLa6>P|L)JebN%M4ehUd4 z$+|wWY!?^u^R%fpMVnKNFmPr7PK}&|gjxdvi#o6!Q54+NOU>X*PAg_MR}wc|-}S>G zjXN_m{^PEe&Ne~hmVfvA_x{ms?|R3_V~KF_`g4%Wr0 z+{!9Yz;MQ(vui0A(lo!lX+`5ZuKU1`|M&}^{9SgpW@b~h4Dq5!pk2#AK$|yjPJ}|9 z$SR7U6pLpjGkxcl>+bmSKmYB&UbkWMZ5{%j$ST%#6M0S%Rxe$G+8P?s_~bZ@ObtRZ z(g+eSmZ8PK>jq0J#wf`;EU+*ob9yo-mgBipk_2vKWc>Jn<3|kO;N1`Jdg{xoSFBie z<)*c_A3Sy-w{PF!ztT7{XZco+1CB3PVC;Q#?|8ZE;2R>BO};O4P}rPZH#agj*R{Uu zPSbWQH5{(3tE<21rW-bXpuR38&1cgAyX5%fxNjP!)ziIP%M>#~9j7L%AYqo=b(TZA z5-sKt2O=j2cil=1Ie4|~jSPOHGK~sB;QD$HD^`^lh@uL%I|YCpNY(WG|LwgAoLqNx zCi-9QUi;qFtJG3!Y3-J*#hYww92t`U2AgGgApwR=c=HInU-FyxG8t?qlb7}R4df+c zNW#Eli){kIF~$UAEbj{~S-aHQy4791)po1u*4>x)JNMS@E*Y4~BoIq>9gw@by1TlT z`~A;3-#Oold@!LX3FhLXYjR~ zcL0BkjA${^!g8I>z?MuSyuI6w3fUg6=2gkEL%d<3BeQDyzcUwkh-JO0qOzx%+82M_;kE}KNU=^C!B*DS$m9{v7Ny|aDr%?)6% zYj3?a<6_t5I4&uQ(Hj|tu`p&+o41VJy#J=X@8vkw)eW71)a>!elSVOE&WtcO=jc^kC1Q&!_j_UVLN5nYdc@gP$ z9XYOo3YiV4-B!K1xsq!;Ha#)<($fz<`e(oV!ejC@L;#=u^rwB%@Ynmbyb26Mzd0y_ zf@i(`_Py!C$iO?x^UYa{aVLK8_dlq9?7x1D{rsd59A$$6hpwRps26jTLmc*37I)T{2HHm}s+T^ZeRIJR z+rgV1k&;LdBFM00WZE^7I0XNOKX~BNpPFBsyS=hh`PQp1O@7gD^6@->5P#1v?%8_hEjNB< z`??)B4-Sqx2CI?N=ckZsoA~-2d(dEh04ugsXfMM8QmSrKmCY|scvWq3GYq+MJ&Sb{!xZ~FQc5K@a zZ8RI;1@XfCg032Eb$soHbX#vD%LWBXf+D{V+Ar`Te4{!DVW~kcX zb%WUN(g6@dxd9_dOd&xKeP&wG=1?I!jv`_j&CFbI+ii`=%`!TE=EWzTe)$Lg=M#T- z=0|WppT4)}jbH0=-}~P8hQJl>|D)gf6@g`nQ z;Qi~YpoNIaaP=T$_De^=ID1esa^sO0DbLHte&-K=|5NXN+q?JcZu`b!zEIeG&Grq) zPM; zUJPLunVvseJ#+TR1Ap=P|L?Eci)aZv35>VBnZxSt92X=Ve*HIo<&K>@cl^F#wQ>t{ zi+}ig|LM>E6uuGG@PF-7AKATQ>*hZmF81w5h(Q*$ot9(LwxXc*Lu=6X^;=O3s<3q# z4sxVf3gTEEv4raolffKscz8qED5yXH>ji42o=;jyqJZj^ru_LYfBiSMuirEri$uo6 z1bh9itviR8T8rG@Kl1qR9XfdOYx$8}MAvl}>$a&k^|@omj@7${(qC8W?rgbYOmKv1 zc2zdj)N4o6$=n*pcD!^vC5ocRzj*k_Jj-m)MWUzcMu8BE#e4wjpu(`sR>IFgcoleP2XQolY@{Mpt02!y`<^%% zTa9XsNu(JS)R!G6>&7;q(%dDN5GGT|_Wgc;-|+hn9e@6JF~$#v7x9S`Cw?vquC8EK zncjEbeGbOhLF-ZM|N7{EyepT>{B|atU011A&VB8nukL^Dd#6r>;j%a6rT*XF{*&J> zw&@c?AcRLRn;tx7o!3vy^j|KW7X=>kLbGI zh$kcITW{L4X}-MRsY)AMr(JE-tX0J!mg8Bbt*eM3Oh9ZAIHQ)gs|PDfJBKGv1s}v< z;RbepwfJSP%F6AI17!#mF;qaJ5cPqqTUFFhE}>L%7!`Btkz+aDY-z^TRE?Gvr@ZO8 zvtNDn_`yGV=nFqO{w4r2fEM3(-zJQMFxa=>`NQA*z=qMWKj_QnhZh!><_{l#_0Rt5 zOJ8`XHEW&^^Wcwu@|WLp{m#ArY2E6zTU&Y))eXgMXexEQ_7bmuCTz&h- zv9&k%5B6;V@E<#Q_V8nmJn=gW!Zkv8x7nyJMU&A8qcdlIo+9mUC;$Uu_S|;UnwOq< z`rO<9m$zS!F}}&Mh&;A_?450`ZI7-R7}#~qmaQ{$GoI_Xm{Q^_RF{nL)$0U=JeBYp=C7&N*CLh;lmGoTk*AP{=-E6ns|U`Acxbucgy)R&=o#6vtEK@lmAJZvFZ zxr9uojRp&wQ6aqwRjZ5c?CcDI?Gj6snf793;fw$H%=iEFdk?-e75oi?9(?fK^qzZs z_5Lg3H2)VEzSp}K{~q~0H{4G(yd{zPT(4& z(2|DEMZb?Tb!aZVpENa6}Su zMXp&ReFKc+xwzF<5W})4E=GMd%M~%aAUA?H9d3aO0XDAtU-^r{my_Yngv!e7aUj?a zr3ni;uHnnn0!uB4F{Bz*RBle9M6`eg3hR-;M!dQCl3T4-Dd<-(EG^25wV7|fdi;m~ z?aQBg)?YsfkK@6I9;6RH{4jd>;j0SFuriZ;{Nv*oM)^9o3BUU9KXAjgO`Co>F2$~2 zH8MPkDMhawoj7#t)X5iY!(M7EC@;S-@ydx!+eiD~cH4n}KRP^md$CZ=E4tz~EEP31 z1*M}&ynWp^Z=`>KG#U*gH|nU>Y9US2{LVVqTSPJ2<#{{$&4?iAcLwkVv%Vma10ERr z%ko%;u`SnOzW>zIe_}M9Q-dRgPh#vvw{9FC)hx|xsVaWq#lxR1O;=A!0wcY2`1oV* zdHeo%nTC1zFCX~g_rkrx=nAqc8o*&~m~olt3qO2e`oo|4&~G)Giomdvn25*LHPm+F znyu^iZQQVC1YbEz9$X>XrJWhu+PLph@mXv z!kqXrzy=d4%S?5rb_^D5h%o%HjblY5NeRTVENbgZNYTqE9?POaW;J4|;MJ;CcX4T+ z+O~z}%QIf9UH`$<%*D_B@%?`TVrcDdFeCIZI&otB$_ukSgzvuRZm8X&E>a4i9E$$t zuYdgg`E35~{(SM~)x(1@$6h^JDlJSBj7?3|T8q`WW7l7M-PUdEcYJL1 z$nb!yw9tHW!C?fJ!pXXBaGked^;&;Rd!tcDbs31NHgaqm!5am@WC*+5u!B2Y?GRlV zu8B+-4}<}g5Y!m^xiUhYg9xF|yzugWn?67P+@?)y?@uJ+&d@-A7EwiUoGjX^{JA&OX*M^X4Y6o|AcGVG=w zW*2j#h$7LeR;%t(Ws#bu?t7Msyl}SOsC?_f^yCx&^?`4}QY@%j`3B_9!w)}bw_un7t=KkzQ^<-H#p#FN zm(7mmvsqt*+a#V8$nwnUG8~@ z6v|&y>$&jQ$)Tgi|MB?ob6+1E$bPu5P{?c?Tf3I>gj-yyYEM7&$`>wOn0t<8nZ{yS zUf8{J{hjf6)c${d_s{>}O7y%}3xGqz*WYse__>qkruN@;>pqN}{g&zK*<5ZXq&9L* zF5PnLp7-|^@`X#YGfpxdr|@>Sb;T>D3m9_UYOC%)S(s#EC}<4AKRD=vwo3@h;LV3F zFCKDYpP&!QNP(6XJdBkb*_rGna=zwnHlWO}{n7`o>CX)9 zDdzIOl1`=9 zlL~6^b4&(eZ-zAQFW~y|lal;MQ-jaw$20r&EfAzZ^@c!Iaq`Z*EFcTk-q8DB`=%GV9#+q!!}Shnf6_2Lj@r_ z+s}zv8f-lDcPDj<{_uxb%mfV|g!S{R@F8>)TmI)^E9P>#9E92+3=V8H+{SV|5;{Z| zq+~5^3AJIZix@$BK-Wv!1?1QjciNgZ`^m?=*ZBT zy@^ETric((lTGK>CSx%aiHIl~k0T;5j_tX`uxwnHD_*%)K}xIT#3ONU^P0_8I+lh# zLByjjnyr-FQ|C`(f#Ka*RLN>XX8cKmDg@) zlaGDx>8~uzSEi9?FUYm#OePZ>&F0ga{`T>QKI=_+M(2IMBEfsL02s`N$-cMl%exNk zQ_aTAwr$(~o$Z*M7>N(_oRE{7tr^|YjXU0QZqI9R9(8JwyLdm-$-AyxTf$)rPZ!QB8eiUOvH5Tw(VH%^?SD8T*znEmg`lAWf%%0Wzbd$ zbzz2K5C8)}3;-5b7&q%QQFaKbWwBa95RW;;0OQ%&$rMV{hC>pU_frQXOL$)UUcpz& z3|eMkI08H}fPv$9UxA=)G*DBo_`7*xkvK}n^C%I^BIIJv&`r11Qh>xE?RE>Bmg$R( zw{-<+y5_U8rm3}cQ?ICo+N6Z4uIuU^wpx{1ZPqZfNrqu;!hp-7!=8gyfoe=#ishma zX*19A8BvrF#|y}FK+2 zA=WNr`)GfrpGn0MsNHU%xuueQdiq>*zEW}=2Stw+B9SRxkFRYh4)bxp?%QTJW5`A+OH&wS<& z|NMok0lZfmfR}C0{ynSC9Y0sybL*ZRnZEP~FeY{+l3ZgLX2rp_<9UvA?as{~C}cB& z+-SI_Z4r?ZeDVW6@*Ia?wiv=`Fo_F*9nK0vKJ2e?fn{n0BNnqhi2W6(e)t>^S}P3o z@)&F9`*5!-wPRl$+=2evK)0F_&Id5$$vh_^mScQYT(g=;Gn;8QIfn`v*PHcjvK0i3FF~fs|L9`Ow=Qep`L3KlucxDG24e?V$5%6 zoGP84KYihJ?Zo7%$x5SQ3>62)*AB1kA01ki94ZciAObQ>8#SACWSSThC z-Md#raU`C3<&1O7owwYQ?N7a{tyv4{WOi4xC7)@l>LMndb<0hAKU6Fh*3B)>!v#u! z;RH;<1A&%qx@LOOZjD>O* zMI0l*VdDwB=)vREHPb(c7|MC3W#EPKBIZ~Ir~nTR3_*xsP~mJUEkqeMkmf|5U6IR- zW~!d&I`&en!kUieiabkz?1Ra!m>5TrAmMZ@i(*pDcZPDaVa+eiw@zO;Q9ga)%yenK zw3Lj;QXAK7SeuMT3;9$rnNJr3vJc{VKYD=Y6zDMn_dqb{b9dm{iGsjFMHMgrd7!M~ z_yC$H(_=~@J=o26P$OkrT82uPxJQkua?BMp%Bd7lI zufOonL)*8HC+fB4Mb7i!^&p4{7Ara23uor0?%uuIx)RGpZxjG88*FvRdwJ&2^y%o^ z?zs8iNFskNLY!%smTp<*e5>86AP3ubz2o*zWO9kIa-h0fJ?DhG|(9WVxq$nfL+(hsCIXfB~`# zyh-4dp&UXHp#(t*EC8G2KR%Xn05<>UO~*j0(L$7Bq#13bS*G8(plEFb&xbP9wJpzf zJAK-R7t=quus_$#P}UJxk;37SP9%Wb!-;4DMTIEhxhMiI z2}q^3s-l`))-KJL7B0?QDAyWQT9vCBMzp@Ve@tDVzSt-=ejl%czImY9M~ zBh6#aJ}Y0zgw-1gz>rez+qW;9NF*A^&mYge^=-F&QV_*WwR(HPbwQWR)iEV0jv>z5 zZom0`sa$gPQl;#vZA}vefwvutMtBiXmhr0cA2Mt-=66(2)kIf@L9O8iubm zrWvZ%Y}K_|v!TqF=d_kui&>UsYDT-2Nn{5iLY$^zNhXuXMId*F$L{a&v+YnHp%cV( z1QS*qY?p-_G%mjdbgL|X?{i0Z5I6@n$anpax^tg~Z6a0IP_5p)`0|gAex;B}8P{EV z%_k_&dyT;$2M(Q|7#4-@-wrQAM!jm$THLxL~)Q|Xy&bNx$*XFE;&@K z*W7BoW>HKWLWv;ooX2xKg$tf%IiDzkxnjsW0W1KLkTCHVjCZ^-_|u@&1fK=Bg=;y2 z9mQRu0inb3LD@MNI)e0m!$IcEW=ifrlxqRs+bHRoN8z?CDd|Vi%-TiuoV!13+#P&Boj7E zE{o6Ewp)|ys%q+_G`sZVsWTT|+PiDp_1m|OAAs}#bj;*hvprK<{PxAE`6H&TmMz0x zte52lO=&hW#Y|q6)kVfJl(Nn17bYee=qk0V9l*Yp77#fT6BF*}_~^j(x9$7iScVaf zA3ys|L?|1NNYUbO@is|_4DQ~w^~O{tmaNHjry)1m9LsW+VYXwjSR@ja1Q*sw66y=v z0l@_)KU|~$V1KVt=j8(L9LyI7IkI0;ExWITOYxKoI_T@VOEvhwDt1 zq3i2-2OphIo*T;C_#QW0O4oTMgDrv>HC5GAHI?yP)IM=)^3kT;T-dyM!yWyF{Cd;1 zJWVyUO0D*z*_p-f96NdbOg57mCa$9#I&tdIP1o!kiKn8wPoJ6mPU%$X@VRs6pvUs6 zwyPe%UFKgOxm+%K``vH(l;dFb%;`&ya14%S2QsO_{-F#INSh)gCAlNi5l!_<(i^>lV!6CarqXBqdxXJ*)zBlgn#GQ+J z*(-O2kRiy!2ko=N{_h4M`pU4y%Fl)2WOxV!lDIxC-TA)1h`JME23|HuL4)u4#8{`v z0U`#-PdS#w&;!o+0Q@kF2tQ!|FHJa`gjdEoo#jn{ zey>#67TMt){B*)!n*$R>fh3M$8IJ3EpwW&>rG>N4z4Xf0*N&{-zG`@IUm_jL!F|-q zvU1||*)L9CT6opethN}HB2kGinvT`pzkBDHVdz_5Jv#Z@Km3Ou9=iGFo3*QKc;pQa zV6gCdk?#4o@A(bN^Z3Gvx$l)K)8@^$?7xHOg`KmrrI+4&*PR~`V%&JM*>pkTpQDUd ztI5;zm8Ehl9$ht=q?ciEdHWHC2O~#X~Wq}8CJU7^V<(Hg%;Q6!0EE1SN z=_Am_3jw?mLI%0;$^#BB|IqXJ%xu^H{O@-0KFgnVJYE=@!~MgDl{Ko}+oi+&JZwG* z3)TK_2flE*GpqZzR)#j9+7i-rKHy&CaW4yoJBLj;xdp%Lk72?g6Ygscms@r2Gw5IL zJetnE*^cd*j_o;~OM-4K)oeDEA6}Z7e__-5b#IBMBG(W~qK0l-wTk@ekrSuC*=Q(p zJmpzRDa~XPW2t0peJ-6|Ez9z$XAi#or$@SY?<+BoOY;bJHoGULi5%R2|zWzS&T()KVvMjKi2SREvxbQ&2*3X|Yv}&$+rZB%< zDJ!kyirr*csNWTY*8!+QPu9KEd^ttDJTSe?yRV#QD_-)l+%xo)%U}x_;qbAA@aj$n zRy=6{%J7Z?;02!tP+a*-e$L$?rGNu~w4?LqgW7wzy{glb4s~7C&=ICVxv8bJ(Z%W6 ze_UFsOl(-QZX}b5zncmS50TK)LZv!eT6*^4)a(z-HMx|GMpKX}XVb~S^{YnqCKK`W ziODk$eeKKN{Lb3G;WFJ#<%!R~{$g}LD|;gX7>taMk25Dvp0wZo{=`~nwWV4i=Gf+iQ&3AKZgM{sF}KeG&gu81@w zox<_O(83O|0N|mjNXLtY_zyj?@3lj(+(nC{6=a6B*UQ3N|966@as`?}xu#PsyF9Y! zg!U^3!{u48U+U?S5A;)o&f^I*tAfYnd+bj1pqqxLnFa;|uB~YGbC;%GWfAjYf3`0Z zO-Xl4F)5|CHB_zF7Z(?*PgQEQ^QBVxIQA&dFobbT8|$XkzVW&pAEbz!`05uQ`r`S; zxyDCUp78`P_(MrV< zY=5CxWI2Wf@c>8@Im&=j4ldeGGd>H=8YCQr3+kBNJ}qRemx0wWzihQkiiH-0;mee7 zsOt5aUhS2Pa|Ke%A$cc^4T-XqI}g2d=(mIXGYrcE26^QW3PBu34FPC_a626E@N2*R z^XlZoJ|P#Bg5dwqtB#>Z-O#+I(o!wgp^oL)wWeH}n<+mv(m&AG*Pp*GoloRIk)vFx zou8X4zk2@SrSDw2SSsamnHWR3cq}eNDP}k!!qZ|dwYyxAAA9s4zW>bNy4CXT;c@x% zpa1+D#_PT*0NgRWbLUQGZf=ep-!s0AVVSjRTb*`2%*Qjy!9uR@R@-(OgTuK^$aACl zT$YO`BBPaBl@N^is1)%Q7MGrV`S8&vuid`w+Og4*xAOvrOatn!u2qq1i_QW zGnP&#H$sGK=&*YSc?=X*{SX*0m!3e63%v4*GYmY`3V?zQi_2bex!>A-4Fz2f0>fY5lE z*zyq4Z6JX$cwM)tG}U%ntBQh{(KMqLkpwOtjf-MbNa%)+D%E;*>eBoVrY@BZRu*fM z5`@a8>8YyT+PP!XyI77LIC<*aR|fmD!*aEG`sDF*m-+{@rGt+iyejLv|5SUU0~n0# z+__Vjn3zyOu9joCHPF=VAtJ?-$)QLhHmbFCdte|xkj*DIx{g!sE9Q1G6!$@mRFXs( z1(-fOar|?&x@_Hc^GzSfY@{-Vo7UXbC@tIqS;yfE8N-L>Rh;-YhJ_c9X;bnf01HxAQlHZ3SJqaaT?p zmUR4_*nc=AXl3kTmV-#6yLbkNRXEHM3>X9lRG8%gG35YvfHYJ0%UR8q(jwp_$o*7sQxEjF!%Ngs}NcvUu&TEx)SiX5-MI zlaH_z8|N=voPb&8TzY8s;G>TMc)Ka{H=Mm00PI_*K^=|m+!;&7`D`pMtu+n9Ut5~U zWQGVrg+waemrBLgGAtI8i6|))a@Q#BHe8XWB#5!aa`lNPfB52;cJJ7{W&OG}AB)DM zWH1fmB3-kzxrN2^ZLPg9G|)eujK%tWITzjZVL>SIS!)0=C}sx4S1>RU)R4Q=)NsF3 zP)=C^Wp{wxWnD5qAu|?w)n#IEcO48w&c4Ny~{m^#g7>-2Lk9sU}@xZY|i zla7rTMbYu*@wMZLM06})NZ)A~=7r~OMwROHBCM@_ql5QN1>lbP zZzzc6)wOz2N+xm)@kCCNQkhI{tLr!viWd27Y7kTGCE}5EvA=JxBnbuAbv<6-m}XNc zpFDN;FD{&)K70M%owtn)4ZI^7mr^kK1EAClqkaD3^dv(G+uxTTj7FkK=;ktvz+*#= z*>B(Q8##hJcje#8Eb@vVCm4Vjo}b_4=JN#DgWha7q9{v| zrQ3FCVezGznfW7A7w4WbO{;0TmTT#*BT7Pa*EQq6G(1pzpW`?Smr4tdoIi8%P&^(% zQ`4nMt>s+GNs|0VDG~is?9B#XF#iezlFQ}Y9A$)p5akMjD5NOi99H1ceiSD#I3AA< zGaOSKT{V1bzAux9PAwQXXf+jYuC(y@(UX&3^(@D{{ni^lR48QcWMC}Nar|zn(&9qB zT&|pTMa2e(16I-313yVvzEE_u5T)XoTI;R3p4GktXcvghH zm=vM#`WTi8+leqtgTXUxHI=jVM(gO*)XcF{r_Ub|IMJ~D60l1~Ru?kaOnQ4hpZ>*c zCcS>XRQckuqm$oXTwJPAj%g{aMhm0J%&}*mg^dYsDtj{mxZB9qeOt5Clj6}d+n-1! zHaf1SIX1Q_&!%k4);XSy#$(a`R60IBGTeVdu`icoD6y9+wRW{uZ_UolKk?!#N4~pt zeEo)9+qT@0&cxm-i4y4Apti1gx@O38ON;(wdp?^T5_o|MJQo3}7k?@UbSgls7zPnK z-ABHc_C?&n;R%A+PXDv-rMuYVfPucukNVc#Xc2_y%b9TJ_y`E4m0^NVW;ZD7xXU4N z$Q%d0)d#HK{^lr`>79>50ZVuH*M-Fm;!(-cbyFcW=Htm|Ze*l? zcRHQeD~e*=alFZ;O7)djUVe4Lc5N+@j3T{Z&Y4xEGBGhx>qPQzI`D$^0Q?$Ty?JwC z*S?MK;{;};wkSV6KT|Ftftb9=38v=o8#b;x5Kl$6N+K_=9Ua+~NXFvud$Qa>%~t#D z?Cktwr%q2!WV5O9@%3YO6#EO?l5rS=g5SeJc1I!eSVR@QJsOY3>YAg!yfFxIn9^VVWj1FI$#n!AK*|vk{Oqs>_Y0TyGw$ zRO?4=%dXXH&7-Bo$#k1!wK8Fx$Zr-?lQ#>AdMw}S(f85!Zyn?giykOY6wg*fmZ`; zLlZ>kt-HN%IQ_KJZrQ~KcLWT=cRv`4=w!404+9JBEO%CgM3|rRE_Y-3`L6FyE$Asl zm_Tp1=X#FqIy&+&!}5#(BKPo%mT5H`t=0?Ga^uChg@t)bv($RC)mm7nloHXHG&0h^ zx=_qsFGZwng23|};a+~_<)cq{#7!6al7o)rRHmmE4$e+pEQzkz&gF9YmA0ek^|Bs- ze}VzQ1jnmj zW2t0(Oq6&I?nhA+)NHlRv|8#T^~L%zUJ^3tZ1TobB7So;7D@9w=YvC2b)@Th+k&+X z7>l-T6VPjd-b2T>TdJzbn4uPSQ56PkSeAhtT4E?oyB=Z~ifq@#3`F4_2knCmJmqrN zSErjQ%z6DeXFuN!C!2zBxx14J4hsN$MoiyKf%r0r;m_x3JlD2X0xDE5vVP z8Mauh$tNl^i)RqX-b6T&7r02FklSKAUfgyaDVt6WY*@GYns_p{o>CHFJ07>$P;^yO zU%EI|dP=U!^OnxxPcC*fUH^MJ{SVFSlWs zP20AXJcLp#Lko`Q*^oW^Esvl}Pe_DgIZ6oSLBQH^UD$XJsARx#gP;gQ7+C2EL=M6@WHA~O3mnG}c#dmZmc7(yDod4e{j6!(MmilIa80wd zv{Zj_^7yGq8jZLlH@tZ6@y9`12Ol_afPGwh+(8FX&ja@W{69Y6I{BjSd2V~>ZR^E| zxD!;+nyPwXX|6JlY+|x;8l@fShsnc$0Rx}YE z%x4o@(y7$7j_1}HOl;k-Y8R*GexPZ(N(o6aloF0(E!XoF8dZ5=X09|tY`&5#vhBC- zcxxLPaMrF}%W@p&!GKE7^R8IW1OL~Ge zyu?xxPsLYdGpTDbsZ^1rG{<7bQvi678@s_0uz2sH67Yt3clJR(w7bP(!2{amwyXtJ!FE-jP~R~8yG98V+B zXe1tsi)l+U&sJ)+3$2EtX}V^bhSQ3RoHp^y#5}z2`}gms<#L(yfculJ2jG7$kjKZl zU)V9Uny1Vbint-&(C4P6<`21ux1v0bA<&{@SVoBQDaZ7p(P*TPvW(PM%&yAkGNYo% zZskQGFA6*Z0MvBDuuXf45WLuEwkAt+3+LrVvsSG(6j2iRSR^7vW07Jy5lgcyjYnhA zH5g;a$8(g>9E8aXr99x(mzeYrc0G@A9M>TP!!RxgWPw8lQ9I~4wjJA1VWt4sVuDfI z@jOj!YfFYE3kPiK;e9dIzgljV3m z4|%z5+lJ$~hG|-}<#PRnTD>`^sb*W`nMg7bD`0}vrTOx!m0E3nYPPgcNDoL7!x)<5 zD2Q*GOdTS9gw%f3KKJ<5FMIRzv>t%}8TMM??>q0lbAXE6KG$)RmSs=1>h+3TlWUSF zF__}G%MqS>3}!_>h7gHax<&KF?8rcWZeJuK4Mt*OUJ!($>$)uHG_x#2P19@??6H5j^lYY;bLTqpczfE zrfYf}Q_P8?*e3~sq-eUWw3I5#u~EmiP1AB3YFkxQ-KcRaW2V!IJf&=cW0?#k7z?7% z=Qxha^IV?ixVR_^d4{EN8+4UDWFimM4BaT1wmm;zsvJ=b!=)afnrXEeidl}Qykofy z+j8b-mS)bnE!XATF#>=MYK^Bx1H~+YG~)6QiRBx{CCfUoYzc_+R?^t!Uz) z1HXD8#~SQ9({+Y9N;%W=j?K?hXC21kMGvtij}jaeMNVLO3Vik*Q_efF++%;SZ4^=X*Ju6VpQyp$D#u)%cKZQIbwKh5ASpkhype6f`0> z+}nl^)EU(6>WZ!XkwvR=W97>ma1jgWe7jajW*>k*s(`UnjiaDVf%0P z&h%uDp{tW4a%)|<>^pZ7Dte63?eU8(G^@4lbWAy(_h%Ex>DSi2{Yk0msqQ25UOzI8 zu-##L9_ z?(9Hk_52o)=Hw_o<2qE)vB=o2%*P7h`PFMvZ;&5*0t~e3&;x>~oCR#Mt`XuBF5FRJ zg_+cIa6%avrj-@X@Hr@DP19&OX{XJNZ7TV6mahA?1!4B2`C(!BenX0^RxYYJ(3Oq z9OP|9=Abu+@m-5advVjU8{&H$`0ANYXI6C99vPCV?F#LK92xiQy(JxG$Vf*BdnN`4 zi8_nci?jR5NZ4qzYoh>5< zA|$fxG4kCoR}$PfSCQN@0hVTB#wPgZM)J`CFlZIMm4Yw|qrdzI?3Y!T*oDwJ6z3ty zJTj;{m(EzrX2$=*um7-I(MUY%tc*U=x)yW0*~dkI-=Z;1l;dDd^JF%rZmX7rh8<8sAq(*hSNrdG}-m5uNy(PC;@HB?NBPxQt zH8Z3vUrQX`j5?&WqsoEPrF^6Y-E6;sqacbTEkoId{>N;W^|mQk7AQtRV~Wa|cS7Aj z*A(CiEGe)K9H9D)T5~B=+Jcg~7#Ql%opf@d(Nh7jhyM3@$I4&{GNT}SoQ|`X{1y^e z)9(~2_V?l-RuoW99faa6@G|d(hMFrngo3FP@7-4Y;)6NA(Z%1lU}{pF!Do~1lp+jj zNi_r=ndeE7SvP=a--+>euHUK-V@+Cz`5f-8!4rWFvT!4`ato(btgc_ocynMNzX{nF zY(phN=>WPg7^|t{4%!5%X$_KcQHO0}_OFY1rGlz2mzEeX6t}R?`Be2#MXAB$)vNsq zZS(34Q%!egCh>$v3%th4q%$Qg8%Bh7z8dn(IKHenQxCaC& zz?(ClHps)=0uh?tq1?MP7E)SG>3y$w zZE$OCXSOr6B(V^g>#rxU91z)i2l(MY9d0-!&uD + + + 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/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 + } +} From ecdc18a67305567e4923d17a93c8be28875886c8 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 8 Sep 2026 18:43:40 -0500 Subject: [PATCH 2/4] chore: bump to 1.1.18 versionCode 20, versionName 1.1.18; package.json and the bridge's appVersion follow. Home Screen widgets; the pin stays at a3e638fc (2.46.0 plus the lockfile fix). --- android/app/build.gradle | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- src/bridge/mobile-bridge.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) 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/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 { From a6df18ff4a475ca016971ae2850a0093e0426ff1 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 8 Sep 2026 18:43:59 -0500 Subject: [PATCH 3/4] docs: 1.1.18 release pack (widgets) --- .../v1.1.18/PLAY_STORE_DESCRIPTION.txt | 46 ++++++++++++++++++ docs/releases/v1.1.18/PLAY_STORE_METADATA.md | 43 +++++++++++++++++ .../v1.1.18/PLAY_STORE_REVIEW_NOTES.md | 26 ++++++++++ docs/releases/v1.1.18/PROMOTIONAL_TEXT.txt | 1 + docs/releases/v1.1.18/RELEASE_NOTES.md | 47 +++++++++++++++++++ docs/releases/v1.1.18/SHORT_DESCRIPTION.txt | 1 + docs/releases/v1.1.18/SOCIAL.md | 6 +++ docs/releases/v1.1.18/WHATS_NEW.md | 8 ++++ docs/releases/v1.1.18/twitter-post.md | 5 ++ 9 files changed, 183 insertions(+) create mode 100644 docs/releases/v1.1.18/PLAY_STORE_DESCRIPTION.txt create mode 100644 docs/releases/v1.1.18/PLAY_STORE_METADATA.md create mode 100644 docs/releases/v1.1.18/PLAY_STORE_REVIEW_NOTES.md create mode 100644 docs/releases/v1.1.18/PROMOTIONAL_TEXT.txt create mode 100644 docs/releases/v1.1.18/RELEASE_NOTES.md create mode 100644 docs/releases/v1.1.18/SHORT_DESCRIPTION.txt create mode 100644 docs/releases/v1.1.18/SOCIAL.md create mode 100644 docs/releases/v1.1.18/WHATS_NEW.md create mode 100644 docs/releases/v1.1.18/twitter-post.md 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..5f96450 --- /dev/null +++ b/docs/releases/v1.1.18/WHATS_NEW.md @@ -0,0 +1,8 @@ +Home Screen widgets. + +• New Note: one tap starts a note in your Inbox +• Recent Notes: pinned notes first, then the ones you edited last, with a + to start one +• Today's Tasks: due today, overdue, or undated, overdue first; tap a task to land on its line +• The widgets wear your theme and update within seconds of a change + +No new permissions. The widgets read a small on-device summary, never your 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. From 1f0324725a7a30cdb27e879462bcce26414de7df Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 9 Sep 2026 14:03:09 -0500 Subject: [PATCH 4/4] docs: 1.1.18 Play notes also carry the app core 2.46 changes 1.1.17 (versionCode 19) was merged without a Play submission, so 20 (1.1.18) is the first Play update with app core 2.46. The submitted notes mention both the widgets and the 2.46 changes; this mirrors the text sent to Play (496 chars). --- docs/releases/v1.1.18/WHATS_NEW.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/releases/v1.1.18/WHATS_NEW.md b/docs/releases/v1.1.18/WHATS_NEW.md index 5f96450..3cd72ea 100644 --- a/docs/releases/v1.1.18/WHATS_NEW.md +++ b/docs/releases/v1.1.18/WHATS_NEW.md @@ -1,8 +1,9 @@ -Home Screen widgets. +Home Screen widgets and app core 2.46. • New Note: one tap starts a note in your Inbox -• Recent Notes: pinned notes first, then the ones you edited last, with a + to start one -• Today's Tasks: due today, overdue, or undated, overdue first; tap a task to land on its line -• The widgets wear your theme and update within seconds of a change +• 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. The widgets read a small on-device summary, never your note bodies. +No new permissions. Widgets read a small on-device summary, not note bodies.