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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ and `@zennotes/shared-domain` packages. The exact archives are vendored under
`vendor/zennotes/` with their source identity and checksums (`manifest.json`),
and `package-lock.json` pins the complete install. No source checkout is used.
The vendored set is the core release
[core-2.75.0-core.h4b1bd3ddd69c2aab](https://github.com/ZenNotes/zennotes/releases/tag/core-2.75.0-core.h4b1bd3ddd69c2aab)
(desktop commit `7e6506db`, clean tree). Run `npm run
[core-2.76.0-core.h076cc71aa1ce0bbe](https://github.com/ZenNotes/zennotes/releases/tag/core-2.76.0-core.h076cc71aa1ce0bbe)
(desktop commit `61628218`, clean tree). Run `npm run
boundaries:check` to verify archives, installed versions, singleton
editor/React peers, and imports; it refuses an archive built from a dirty
upstream tree unless `ZEN_ALLOW_DIRTY_CORE=1` is set for a local try-out.
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 48
versionName "1.1.45"
versionCode 49
versionName "1.1.46"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
Expand Down
132 changes: 132 additions & 0 deletions docs/system-bar-insets-validation-2026-10-23.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# System bar insets: verification, 23 October 2026

Branch `fix/system-bar-insets`, cut from `origin/main` at 65fda06 (1.1.45).
Committed locally, not pushed. Changes: `src/ui-mobile/mobile.css` and two
new contract tests in `src/ui-mobile/mobile-layout.test.ts`. No native code,
no Capacitor config and no app-core change.

## Report

Dave (Discord, 2026-10-23, Android 1.1.45 from Play, 3-button navigation):
the navigation buttons make app buttons ("Go back" and others) hard to tap.
His screenshots of Cloud's **Review sync changes** sheet show its last line,
"Loading the versions kept for you…", drawn under back/home/recents.

## Cause

Capacitor 8's SystemBars plugin picks one of two inset models by WebView
version (`SystemBars.java`, `insetsHandling: css`, the default):

- **WebView < 140:** it pads the WebView natively, so the page sits between
the bars and every `env(safe-area-inset-*)` reads 0.
- **WebView 140 and later** (with `viewport-fit=cover`, which `index.html`
has): it drops the native padding and hands the insets to the page. The
page now draws under the status bar and the navigation bar, and
`env(safe-area-inset-*)` carries the real values.

The emulators used so far ship WebView 133, so the shell's CSS was only ever
seen in the first model. Phones that update WebView from Play are in the
second, where three things were wrong:

1. **Dialog sheets overflow into the bottom inset.** `mobile.css` turns core
dialogs into bottom sheets capped at `86dvh`, with the bar's height as
`padding-bottom` *inside* that cap (border-box). Core sizes some dialog
bodies in `vh` for the desktop: Review sync changes has a `72vh`
scroller under its header. Once the body reaches its cap, header + body +
inset exceed `86dvh`, and since `overflow: hidden` clips at the padding
edge, the body's bottom paints straight into the strip under the bar. On a
360x800 phone the body ran 56 px past the sheet's content box: exactly
Dave's screenshot, and the reason buttons that scroll through that strip
("Go back", "Keep this version") are hard to hit.
2. **Full-screen surfaces ignore the bottom inset.** The search/command
palettes' results run to the screen bottom (the last rows can never
scroll clear of the bar), and Settings' footer note sits under it.
3. **Nothing honors the side insets.** In landscape, 3-button navigation
moves to the right edge (left at 270 degrees) and the camera cutout takes
the other side. The app shell, the More/Create sheets, dialog sheets,
palettes, Settings, side panels, the drawer and toasts all ran under them
("Finish later" sat under Back).

`env()` itself is reliable on WebView 145: measured top 51 / bottom 48 with
3-button navigation, bottom 24 with gestures, and bottom 0 while the
keyboard is up, matching Capacitor's injected `--safe-area-inset-*`.

## Fix (shell CSS only)

- Dialog sheets: block panels become a flex column, so a vh-capped scroller
(the dialog body) shrinks to fit the cap and the header and footer stay in
view; panels core already lays out as `flex` or `grid` are left alone.
`overflow-y: auto` replaces the clip, so anything that still does not fit
scrolls with the inset at the end of the scroll instead of painting into
it. Left/right insets are padded.
- Palettes and Settings pad all four insets; their absolute corner buttons
(Cancel, Done, back) add the side inset.
- App shell, shell sheets and onboarding pad the side insets; the drawer
widens by the left inset; the note-list column pads left; side panels are
inset (not padded, since core gives them their own side padding) and their
Done button moves in; the toast host clears both sides.

Deliberately not done: no switch back to native padding on new WebViews (it
would also paint the status and navigation bar strips in the fixed dark
window color for every theme), no change to Immersive Mode, and no app-core
edit. App-core does not need one for this; optionally, `CloudConflictDialog`
could lay its panel out as `flex flex-col` with a `min-h-0 flex-1` body in
place of the fixed `max-h-[72vh]` so other hosts get the same fit.

## Verification

Throwaway AVD `zn-navbar-insets-37` (Pixel 8, Google APIs **API 37, WebView
145.0.7632.218**), debug APK, driven over the WebView's CDP socket. Density
480 (360x800 CSS px, a common phone size) unless noted. A stand-in for
Review sync changes was mounted with the exact core 2.75.0 Modal, Header and
Body class strings (six queued files, resolver still loading), since the AVD
has no Cloud account to raise a real conflict; the shell CSS lays it out as
it does the real dialog.

| Case | Before (1.1.45) | After |
| --- | --- | --- |
| Review sync changes, portrait, 3-button | last line at 757 to 841 px, bar from 752: under the buttons (Dave's screenshot) | body shrinks to end at 752; scrolled to the end the last line ends at 736 |
| Same, portrait, gesture | last line under the handle and off-screen | ends at 760, handle from 776 |
| Same, landscape 90 / 270, 3-button | "Finish later" under Back; body's last 21 px off-screen | every button and the last line inside the safe area, both rotations |
| Same, landscape, gesture | body's last line clipped by the screen edge | inside the safe area |
| Home and More sheet, landscape, 3-button | editor text, Read button and list rows under the side bar | probe finds nothing under any bar |
| Search palette, portrait | results scroller ends at 800, bar from 752 | ends at 752; with the keyboard up it still meets the keyboard (inset 0) |
| Settings, portrait | "Settings save automatically on this device." under the bar | footer ends at 751 |
| Drawer, side panel (Outline), landscape 270 | under the left bar | drawer rows start at 60 (inset 48); panel spans 48 to 756 |
| Trash confirm, Rename prompt | | unchanged layout; Rename sits directly on the keyboard |
| WebView 133 (API 36 AVD), portrait and landscape | | insets all 0 as before, app shell padding unchanged, conflict stand-in fully reachable |

The probe flags any button, link, status, input or text block whose
clipped box reaches into a non-zero inset. Before/after screenshots and the
two composites (`before-after-review-sync-portrait.png`,
`-landscape.png`) are in the side-session notes folder
`android-system-bar-insets/`, not in the repo.

Automated: `npm test` 308 passed (the two new layout contracts fail on the
`origin/main` CSS and pass here); `npm run typecheck` clean;
`npm run build` and `./gradlew assembleDebug` succeed.

Not verified: a real Cloud conflict on a real device (no account on the
AVD), WebView 140 to 143 (the AVD images ship 133 or 145), API 29 with a
modern WebView (`neutralizeDoubleKeyboardInset` path, untouched here), and
TalkBack.

## How to test locally

1. `npm ci && npm run build && npx cap sync android && (cd android && ./gradlew assembleDebug)`.
2. Boot an emulator whose WebView is 140 or newer (the API 37 Google APIs
image ships 145), then:
`adb shell cmd overlay enable-exclusive --category com.android.internal.systemui.navbar.threebutton`
and, for a phone-sized viewport, `adb shell wm density 480`.
3. `adb install -r android/app/build/outputs/apk/debug/app-debug.apk`, launch,
"Get started", "Start writing".
4. Portrait: open the ensō, Search, dismiss the keyboard: the results end at
the top of the bar (before: under it). More, Settings: the footer note sits
above the bar.
5. Landscape (`adb shell settings put system user_rotation 1`): the editor
text, the More sheet and the Cloud footer stay clear of the right-hand
buttons; `user_rotation 3` moves the bar left, and the drawer (Browse)
clears it.
6. With a Cloud account and two devices, edit one note on both and sync:
Review sync changes keeps its last line and buttons above the bar,
portrait and landscape.
34 changes: 17 additions & 17 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "zennotes-android",
"private": true,
"version": "1.1.45",
"version": "1.1.46",
"type": "module",
"description": "ZenNotes for Android — Capacitor shell over the ZenNotes app core",
"homepage": "https://zennotes.org",
Expand Down Expand Up @@ -43,9 +43,9 @@
"@lezer/highlight": "^1.2.5",
"@replit/codemirror-vim": "^6.3.0",
"@xyflow/react": "^12.12.0",
"@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.75.0-core.h4b1bd3ddd69c2aab.tgz",
"@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.75.0-boundaries.h755475e033d56c59.tgz",
"@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.75.0-boundaries.h755475e033d56c59.tgz",
"@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.76.0-core.h076cc71aa1ce0bbe.tgz",
"@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.76.0-boundaries.he867787f4c1f2ebc.tgz",
"@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.76.0-boundaries.he867787f4c1f2ebc.tgz",
"codemirror": "^6.0.1",
"dompurify": "^3.4.17",
"function-plot": "^1.26.0",
Expand Down
46 changes: 46 additions & 0 deletions src/bridge/cloud-sync-staging.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,52 @@ describe('native Cloud staging', () => {
}), /changed on this device/)
assert.equal(h.files.get('note.md')!.toString(), 'changed again')
})

it('puts the other device\'s version where it went and moves this device\'s different file beside it', async () => {
// Dave's phone: the computer trashed the note the phone edited, and the
// phone had trashed an Untitled.md of its own into the same slot.
const edited = Buffer.from('# Untitled\n\nEdited on the phone.\n')
const scratch = Buffer.from('# Untitled\n\nThe phone\'s own scratch.\n')
const other = Buffer.from('# Untitled\n\nAlready the second one.\n')
const h = harness({
'inbox/Daily Notes/Untitled.md': edited,
'trash/Daily Notes/Untitled.md': scratch,
'trash/Daily Notes/Untitled 2.md': other
})
const cloud = Buffer.from('# Untitled\n\nWritten on the computer.\n')
const ref = h.reference('item', 3, cloud, 'utf8')
h.objects.set('https://objects.example.test/item/3', cloud)
const file = await h.staging.stage(h.source(ref, 'https://objects.example.test/item/3'))
await h.staging.resolve({
expected_path: 'inbox/Daily Notes/Untitled.md', expected_sha256: hash(edited),
cloud_path: 'trash/Daily Notes/Untitled.md', file
})
assert.deepEqual(h.files.get('trash/Daily Notes/Untitled.md'), cloud)
assert.deepEqual(h.files.get('trash/Daily Notes/Untitled 3.md'), scratch)
assert.deepEqual(h.files.get('trash/Daily Notes/Untitled 2.md'), other)
assert.equal(h.files.has('inbox/Daily Notes/Untitled.md'), false)
})

it('moves this device\'s file back when the other device\'s version cannot be published', async () => {
const edited = Buffer.from('edited')
const scratch = Buffer.from('scratch')
const h = harness({ 'inbox/a.md': edited, 'trash/a.md': scratch })
const cloud = Buffer.from('cloud')
const ref = h.reference('item', 2, cloud, 'utf8')
h.objects.set('https://objects.example.test/item/2', cloud)
const file = await h.staging.stage(h.source(ref, 'https://objects.example.test/item/2'))
const rename = h.native.rename
h.native.rename = async (from, to) => {
if (from.startsWith('.zennotes/sync/downloads/')) throw new Error('publish failed')
await rename(from, to)
}
await assert.rejects(h.staging.resolve({
expected_path: 'inbox/a.md', expected_sha256: hash(edited), cloud_path: 'trash/a.md', file
}), /publish failed/)
assert.deepEqual(h.files.get('trash/a.md'), scratch)
assert.equal(h.files.has('trash/a 2.md'), false)
assert.deepEqual(h.files.get('inbox/a.md'), edited)
})
})

const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'
Expand Down
Loading
Loading