Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ updates:
dependency-type: development
ignore:
# The shell's React (and its types) must match the pinned ZenNotes
# source (.zennotes-commit); a second React or diverging @types/react
# package candidates (vendor/zennotes/manifest.json); a second React or diverging @types/react
# breaks the pinned-source typecheck. These move with the pin only.
- dependency-name: react
- dependency-name: react-dom
Expand Down
7 changes: 3 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ concurrency:

jobs:
verify:
name: TypeScript, source pin, and Android build
name: TypeScript, package boundary, and Android build
runs-on: ubuntu-latest
steps:
- name: Check out repository
Expand All @@ -36,13 +36,12 @@ jobs:
- name: Install mobile dependencies
run: npm ci

- name: Prepare exact ZenNotes source
run: npm run source:prepare
- name: Verify installed core packages
run: npm run boundaries:check

- name: Reject high-severity production advisories
run: |
npm audit --omit=dev --audit-level=high
npm --prefix .zennotes-source audit --omit=dev --audit-level=high

- name: Test and typecheck bridges
run: |
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,5 @@ docs/releases/*
# Signing (local only — never commit)
android/upload-keystore.jks
android/keystore.properties

/dist-boundary-check/
1 change: 0 additions & 1 deletion .zennotes-commit

This file was deleted.

29 changes: 17 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ derived from the iPhone shell at `../zennotesiphone` — the two shells share th
same structure and bridge modules; platform-specific divergences are noted
below.

The zennotes repo is consumed **read-only at the exact commit in
`.zennotes-commit`**. `npm run source:prepare` checks that commit out under the
ignored `.zennotes-source/` directory and installs its locked dependencies.
Every typecheck and release build verifies the pin; no ambient sibling checkout
can silently change a mobile binary.
The app consumes the public `@zennotes/app-core`, `@zennotes/bridge-contract`,
and `@zennotes/shared-domain` packages. The local migration candidates are
vendored under `vendor/zennotes/` with their source identity and checksums;
`package-lock.json` pins the complete install. No source checkout is used.
Run `npm run boundaries:check` to verify archives, installed versions, singleton
editor/React peers, and imports. These candidates have not been published.

## Architecture

Expand All @@ -31,7 +32,7 @@ src/
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
phone drawer behavior through public core APIs
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)
Expand Down Expand Up @@ -117,7 +118,7 @@ Key decisions (all forced by "don't modify the zennotes repo"):
the misspelling is intentional and load-bearing), same `.zennotes/`
metadata (vault.json, workspace.json, comments/), same naming/collision
rules, same NoteMeta extraction regexes, `systemFolderPaths` remaps honored
via `@shared/system-folder-paths`.
via `@zennotes/shared-domain/system-folder-paths`.
- **Share sheet → quick capture**: Android needs no app extension — a
`text/plain` `ACTION_SEND` intent-filter on MainActivity stashes captures in
SharedPreferences; the app-local `ShareInbox` plugin (same `jsName` and
Expand Down Expand Up @@ -163,7 +164,7 @@ Key decisions (all forced by "don't modify the zennotes repo"):
task rows, kanban cards, and calendar day cells), subtask rollups, archived
notes retiring their tasks, inline mermaid while writing, text
replacements, configurable tab size, manual kanban card order, and
absence-aware remote reads (`@shared/remote-absence`).
absence-aware remote reads (`@zennotes/shared-domain/remote-absence`).

## Build & run

Expand All @@ -183,10 +184,12 @@ points at the SDK. Dev loop against a browser (no emulator): `npm run dev` —
Capacitor plugins are absent in a plain browser, so vault I/O won't work; use
the emulator for real testing.

`npm run upstream` verifies the generated checkout matches `.zennotes-commit`
and typechecks the bridge against that exact source. To adopt a newer core,
update the pin to a reviewed full commit SHA and commit it with the dependent
mobile changes.
`npm run upstream` verifies the package boundary and typechecks the host.
To adopt a new core candidate, copy its three immutable archives into
`vendor/zennotes/`, update the manifest and `file:` dependencies, then run
`npm install`, the boundary check, tests, typecheck, production build, and native
runtime checks together. Keep the previous validated package version available
for rollback. A package update must not require private core imports.

## Boot-order gotcha (load-bearing)

Expand Down Expand Up @@ -251,3 +254,5 @@ signed object upload, completion, manifest, and cleanup with a deterministic
(`useSystemBackClose` is Android-only: iOS has no system back gesture).
Underline stayed out on purpose: ZenNotes markdown has no underline
construct on any platform — an upstream schema decision, not a shell one.

For device-level package checks, see [native boundary validation](docs/native-boundary-validation.md).
8 changes: 8 additions & 0 deletions android/app/src/androidTest/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<!-- Installed only with the disposable instrumentation test APK. -->
<provider android:name="md.zennotes.SafFixtureProvider"
android:authorities="md.zennotes.test.saf-fixture"
android:exported="true" android:grantUriPermissions="true" />
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package md.zennotes;

import android.database.Cursor;
import android.database.MatrixCursor;
import android.os.ParcelFileDescriptor;
import android.provider.DocumentsContract.Document;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.net.Uri;
import android.provider.DocumentsContract;
import java.io.File;
import java.io.FileNotFoundException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;

/** A real Android resolver boundary with deliberate provider failure modes. */
public class SafFixtureProvider extends ContentProvider {
private static final String[] COLUMNS = { Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME,
Document.COLUMN_MIME_TYPE, Document.COLUMN_SIZE, Document.COLUMN_LAST_MODIFIED };

@Override public String getType(Uri uri) { return "text/markdown"; }
@Override public Uri insert(Uri uri, ContentValues values) { throw new UnsupportedOperationException(); }
@Override public int update(Uri uri, ContentValues values, String selection, String[] args) { throw new UnsupportedOperationException(); }
@Override public int delete(Uri uri, String selection, String[] args) { throw new UnsupportedOperationException(); }
@Override public boolean onCreate() { return true; }
@Override public Cursor query(Uri uri, String[] projection, String selection, String[] args, String sortOrder) {
String parent = DocumentsContract.getTreeDocumentId(uri);
if (parent.equals("null-list")) return null;
if (parent.equals("denied")) throw new SecurityException("Provider access revoked");
MatrixCursor cursor = new MatrixCursor(COLUMNS);
cursor.addRow(new Object[] {"present", "present.md", "text/markdown", 0, 0});
cursor.addRow(new Object[] {"directory", "directory", Document.MIME_TYPE_DIR, 0, 0});
cursor.addRow(new Object[] {"unreadable", "unreadable.md", "text/markdown", 0, 0});
return cursor;
}
@Override public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
String id = DocumentsContract.getDocumentId(uri);
if (!id.equals("present")) throw new FileNotFoundException("Provider cannot open this document");
try {
File file = new File(getContext().getCacheDir(), "saf-fixture.md");
Files.write(file.toPath(), "Exact café 日本語. \n".getBytes(StandardCharsets.UTF_8));
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
} catch (Exception error) { throw new FileNotFoundException(error.getMessage()); }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package md.zennotes;

import static org.junit.Assert.*;
import android.content.Context;
import android.provider.DocumentsContract;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import com.getcapacitor.JSObject;
import com.getcapacitor.PluginCall;
import org.junit.Test;
import org.junit.runner.RunWith;

@RunWith(AndroidJUnit4.class)
public class SafReadInstrumentedTest {
private static class Result extends PluginCall {
String code, message;
JSObject value;
Result(String root, String path) {
super(null, "SafFs", "fixture", "read", new JSObject().put("root", root).put("path", path));
}
@Override public void resolve(JSObject data) { value = data; }
@Override public void reject(String message, String code, Exception error, JSObject data) {
this.message = message; this.code = code;
}
}
private Result read(String rootId, String path, boolean base64) {
SafFsPlugin plugin = new SafFsPlugin() {
@Override public Context getContext() { return InstrumentationRegistry.getInstrumentation().getContext(); }
};
String root = DocumentsContract.buildTreeDocumentUri("md.zennotes.test.saf-fixture", rootId).toString();
Result result = new Result(root, path);
if (base64) plugin.readBase64(result); else plugin.readText(result);
return result;
}
@Test public void onlyVerifiedAbsenceReturnsNotFound() {
for (boolean base64 : new boolean[] {false, true}) {
assertEquals("ZN-SAF-NOT-FOUND", read("root", "missing.md", base64).code);
assertEquals("ZN-SAF-IS-DIRECTORY", read("root", "directory", base64).code);
for (String root : new String[] {"null-list", "denied"}) {
Result failed = read(root, "missing.md", base64);
assertNotNull(failed.message);
assertNotEquals("ZN-SAF-NOT-FOUND", failed.code);
}
Result unavailable = read("root", "unreadable.md", base64);
assertNotNull(unavailable.message);
assertNotEquals("ZN-SAF-NOT-FOUND", unavailable.code);
}
}
@Test public void nativeProviderReadsPreserveExactBytes() {
Result text = read("root", "present.md", false);
assertNull(text.message);
assertEquals("Exact café 日本語. \n", text.value.getString("data"));
Result binary = read("root", "present.md", true);
assertNull(binary.message);
assertArrayEquals("Exact café 日本語. \n".getBytes(java.nio.charset.StandardCharsets.UTF_8),
android.util.Base64.decode(binary.value.getString("data"), android.util.Base64.DEFAULT));
}
}
39 changes: 34 additions & 5 deletions android/app/src/main/java/md/zennotes/SafFsPlugin.java
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ private Map<String, Entry> listChildren(Uri tree, String dirDocId) {
},
null, null, null
)) {
if (c != null) {
if (c == null) throw new IllegalStateException("Document provider did not return a directory listing");
{
while (c.moveToNext()) {
String id = c.getString(0);
String name = c.getString(1);
Expand Down Expand Up @@ -249,7 +250,14 @@ public void readText(PluginCall call) {
try {
String rel = clean(call.getString("path"));
Entry e = resolve(tree, rel);
if (e == null || e.isDir) throw new FileNotFoundException(rel);
if (e == null) {
call.reject("File does not exist: " + rel, "ZN-SAF-NOT-FOUND");
return;
}
if (e.isDir) {
call.reject("Cannot read a directory: " + rel, "ZN-SAF-IS-DIRECTORY");
return;
}
JSObject ret = new JSObject();
ret.put("data", new String(readAll(docUri(tree, e.docId)), StandardCharsets.UTF_8));
call.resolve(ret);
Expand All @@ -265,7 +273,14 @@ public void readBase64(PluginCall call) {
try {
String rel = clean(call.getString("path"));
Entry e = resolve(tree, rel);
if (e == null || e.isDir) throw new FileNotFoundException(rel);
if (e == null) {
call.reject("File does not exist: " + rel, "ZN-SAF-NOT-FOUND");
return;
}
if (e.isDir) {
call.reject("Cannot read a directory: " + rel, "ZN-SAF-IS-DIRECTORY");
return;
}
JSObject ret = new JSObject();
ret.put("data", Base64.encodeToString(readAll(docUri(tree, e.docId)), Base64.NO_WRAP));
call.resolve(ret);
Expand Down Expand Up @@ -385,8 +400,21 @@ public void rename(PluginCall call) {
String movedId = DocumentsContract.getDocumentId(moved);
String targetName = baseName(to);
if (!baseName(from).equals(targetName)) {
if (DocumentsContract.renameDocument(resolver(), docUri(tree, movedId), targetName) == null) {
throw new Exception("Rename after move refused");
try {
if (DocumentsContract.renameDocument(resolver(), docUri(tree, movedId), targetName) == null) {
throw new Exception("Rename after move refused");
}
} catch (Exception renameError) {
// A rename promise must not reject after silently changing parents.
try {
Uri restored = DocumentsContract.moveDocument(resolver(), docUri(tree, movedId),
docUri(tree, toParentId), docUri(tree, fromParent.docId));
if (restored == null) throw new Exception("Rollback move refused");
} catch (Exception rollbackError) {
throw new Exception("FOLDER_STATE_UNCERTAIN: Rename failed and could not be restored: "
+ rollbackError.getMessage(), renameError);
}
throw renameError;
}
}
}
Expand All @@ -395,6 +423,7 @@ public void rename(PluginCall call) {
if (src.isDir) listings.remove(cacheKey(tree, src.docId));
call.resolve();
} catch (Exception e) {
listings.clear();
call.reject("rename failed: " + e.getMessage());
}
}
Expand Down
63 changes: 63 additions & 0 deletions docs/native-boundary-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Native package boundary validation

The host consumes the three archives pinned in `vendor/zennotes/manifest.json`.
There is no main-repository checkout or private editor/store import in the build.

## Package checks

```sh
npm ci
npm run boundaries:check
npm run typecheck
npm test
npm run build
```

Run the same checks in a fresh copy containing the package manifest/lock, vendor
archives, source, public assets, tooling, TypeScript/Vite/Tailwind/PostCSS config,
and native project. Omit `node_modules` and any historical source clone.

## Disposable native runtime

Use a newly created simulator/emulator with no real account or vault. The fixture
creates a uniquely named test vault and writes notes and attachments. Do not put
this fixture in a release or install it on a personal device.

1. Run `npm run build:boundary-fixture`. Only this explicit command adds
`tooling/native-boundary-fixture.ts` to the app; ordinary `npm run build` does not.
2. In a disposable checkout, copy `dist-boundary-check/` into `dist/`, then run
`npx cap sync android` and build the native debug/simulator app as below.
3. Install and launch on the disposable device. The fixture checks native typing,
exact Unicode and trailing whitespace, search, task observation, attachments,
note rename, comments, trash/restore, and whole-vault rename under the public
workspace transition lock.
4. Read `boundary-validation.json` in the private Data directory in the app data container. It must
report `passed-awaiting-restart` with 20 checks and no error.
5. Terminate and relaunch the app without clearing its data. The report must now
be `restart-passed`, including vault identity, selected note and exact bytes.
6. Remove the disposable device when finished. Before a normal build, use
`npm run build` and `npx cap sync android` to replace the fixture assets.

The fixture uses public core APIs and the native filesystem bridge. It needs no
account credentials. It does not prove live Cloud sync, iCloud account behavior,
or every third-party storage provider; those remain separate release checks.

## Android native checks

With a disposable AVD running, set `ANDROID_SERIAL` to its identifier. From
`android/`, using the project's supported JDK and Android SDK, run:

```sh
./gradlew --no-daemon :app:testDebugUnitTest :app:lintDebug :app:assembleDebug
./gradlew --no-daemon :app:connectedDebugAndroidTest
```

Instrumentation includes a test-APK-only content provider. It exercises the real
Android resolver and `SafFsPlugin` for text/base64 reads, verified absence,
directories, revoked access, null listings, and unreadable documents. Provider
failures must never become missing-file results in optional sidecar reads.

For fixture reports, use the isolated WebView's debug connection to call
`Capacitor.Plugins.Filesystem.readFile` with directory `DATA`, path
`boundary-validation.json`, and encoding `utf8`. Reinstall the debug APK after
instrumentation if Gradle removed it, then launch the fixture separately.
Loading