Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
45 changes: 45 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,51 @@
</intent-filter>
</activity>

<!-- Home Screen widgets (md.zennotes.widgets): rendered from the
snapshot WidgetBridgePlugin writes; taps are zennotes:// view
intents into MainActivity. -->
<receiver
android:name=".widgets.NewNoteWidgetProvider"
android:exported="true"
android:label="@string/widget_new_note_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_new_note_info" />
</receiver>
<receiver
android:name=".widgets.RecentNotesWidgetProvider"
android:exported="true"
android:label="@string/widget_recent_notes_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_recent_notes_info" />
</receiver>
<receiver
android:name=".widgets.TasksWidgetProvider"
android:exported="true"
android:label="@string/widget_tasks_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_tasks_info" />
</receiver>
<service
android:name=".widgets.RecentNotesWidgetService"
android:exported="false"
android:permission="android.permission.BIND_REMOTEVIEWS" />
<service
android:name=".widgets.TasksWidgetService"
android:exported="false"
android:permission="android.permission.BIND_REMOTEVIEWS" />

<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
Expand Down
7 changes: 7 additions & 0 deletions android/app/src/main/java/md/zennotes/MainActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,16 @@ public void onCreate(Bundle savedInstanceState) {
registerPlugin(FolderPickerPlugin.class);
registerPlugin(SafFsPlugin.class);
registerPlugin(DirectUploadPlugin.class);
registerPlugin(WidgetBridgePlugin.class);
super.onCreate(savedInstanceState);
// Cold-start share: the launch intent IS the share. Stash it now; the
// WebView drains the inbox after the vault opens (importPendingShares).
ShareInboxPlugin.stashFromIntent(this, getIntent());
// Widget taps are zennotes:// view intents; the WebView consumes the
// newest at boot (WidgetBridgePlugin.consumeLaunchLink). On a recreate
// into an existing task this one is the task's original intent and
// the real tap follows in onNewIntent, which stashes over it.
WidgetBridgePlugin.stashLaunchLink(getIntent());
neutralizeDoubleKeyboardInset();
installCrashProofWebViewClient();
// Fullscreen writing (#22, #42): while the JS shell hides the
Expand Down Expand Up @@ -137,5 +143,6 @@ protected void onNewIntent(Intent intent) {
// Warm share (singleTask): stash before onResume so the foreground
// appStateChange listener drains it in the same wake.
ShareInboxPlugin.stashFromIntent(this, intent);
WidgetBridgePlugin.stashLaunchLink(intent);
}
}
85 changes: 85 additions & 0 deletions android/app/src/main/java/md/zennotes/WidgetBridgePlugin.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package md.zennotes;

import android.content.Context;
import android.content.Intent;
import android.net.Uri;

import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;

import java.io.IOException;

import md.zennotes.widgets.WidgetSnapshot;
import md.zennotes.widgets.WidgetUpdater;

/**
* Android counterpart of the iOS WidgetBridgePlugin (same jsName, same
* update()/clear()/consumeLaunchLink() contract, so src/bridge/widgets.ts
* and ui-mobile/deep-links.ts run unchanged). The WebView publishes the
* widget snapshot; this writes it into the app's private files and
* re-renders every placed widget (md.zennotes.widgets). The widgets never
* see the vault itself.
*/
@CapacitorPlugin(name = "ZenWidgets")
public class WidgetBridgePlugin extends Plugin {

/**
* The latest zennotes:// view intent this process has seen, for the
* WebView to consume at boot. Capacitor's getLaunchUrl captures the
* activity's intent once, at bridge creation — and a singleTask activity
* recreated into its old task reports the task's ORIGINAL intent there,
* while the widget tap that actually woke it arrives through onNewIntent
* (as a retained appUrlOpen the Cloud auth listener, registered first,
* consumes). MainActivity stashes both, so the newest wins.
*/
private static volatile String pendingLaunchLink;

static void stashLaunchLink(Intent intent) {
if (intent == null || !Intent.ACTION_VIEW.equals(intent.getAction())) return;
Uri data = intent.getData();
if (data == null || !"zennotes".equals(data.getScheme())) return;
pendingLaunchLink = data.toString();
}

@PluginMethod
public void consumeLaunchLink(PluginCall call) {
String link = pendingLaunchLink;
pendingLaunchLink = null;
JSObject ret = new JSObject();
ret.put("url", link);
call.resolve(ret);
}

@PluginMethod
public void update(PluginCall call) {
String json = call.getString("snapshot");
if (json == null || json.isEmpty()) {
call.reject("snapshot is required");
return;
}
final Context context = getContext().getApplicationContext();
new Thread(() -> {
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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading