From 64fea2074798fe033cd5785fa76adca1ddb2e473 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Fri, 11 Sep 2026 12:29:21 -0500 Subject: [PATCH 1/7] fix: keep Android Cloud sync responsive (#52) Cache acknowledged unchanged file scans, yield while decoding assets, and coalesce refreshes without hiding partial failed pulls. Cover cache invalidation, conflicts, restore reads, and the host integration. --- src/bridge/cloud-sync-refresh.test.ts | 60 ++++ src/bridge/cloud-sync-refresh.ts | 30 ++ src/bridge/cloud-sync-repository.test.ts | 317 ++++++++++++++++++ src/bridge/cloud-sync-repository.ts | 297 ++++++++++++++++ src/bridge/cloud-sync-rescan.test.ts | 31 ++ src/bridge/cloud-sync-work.test.ts | 41 +++ src/bridge/cloud-sync-work.ts | 41 +++ .../mobile-cloud-sync.integration.test.ts | 306 +++++++++++++++++ src/bridge/mobile-cloud-sync.ts | 43 ++- src/bridge/vault-fs.ts | 24 +- tooling/load-mobile-module.ts | 46 +++ 11 files changed, 1205 insertions(+), 31 deletions(-) create mode 100644 src/bridge/cloud-sync-refresh.test.ts create mode 100644 src/bridge/cloud-sync-refresh.ts create mode 100644 src/bridge/cloud-sync-repository.test.ts create mode 100644 src/bridge/cloud-sync-repository.ts create mode 100644 src/bridge/cloud-sync-rescan.test.ts create mode 100644 src/bridge/cloud-sync-work.test.ts create mode 100644 src/bridge/cloud-sync-work.ts create mode 100644 src/bridge/mobile-cloud-sync.integration.test.ts create mode 100644 tooling/load-mobile-module.ts diff --git a/src/bridge/cloud-sync-refresh.test.ts b/src/bridge/cloud-sync-refresh.test.ts new file mode 100644 index 0000000..196ec0d --- /dev/null +++ b/src/bridge/cloud-sync-refresh.test.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { trackCloudSyncChanges } from './cloud-sync-refresh.ts' + +describe('cloud sync refresh', () => { + it('retains a failed refresh for the next host operation', async () => { + const state = { changed: false } + const fs = { readdir: async () => [], stat: async () => null, readBase64: async () => '', + writeText: async () => {}, writeBase64: async () => {}, deleteFile: async () => {}, rename: async () => {} } + const first = trackCloudSyncChanges(fs, async () => { throw new Error('unavailable') }, state) + first.markChanged() + await assert.rejects(first.refresh(), /unavailable/) + let refreshes = 0 + const next = trackCloudSyncChanges(fs, async () => { refreshes++ }, state) + await next.refresh() + await next.refresh() + assert.equal(refreshes, 1) + }) + function fixture(failWrite = false) { + let refreshes = 0 + const changes = trackCloudSyncChanges({ + readdir: async () => [], stat: async () => null, readBase64: async () => '', + writeText: async () => { if (failWrite) throw new Error('disk full') }, + writeBase64: async () => {}, deleteFile: async () => {}, rename: async () => {} + }, async () => { refreshes++ }) + return { changes, count: () => refreshes } + } + + it('does not refresh the editor after a read-only / no-op sync', async () => { + const { changes, count } = fixture() + await changes.fs.readdir('') + await changes.fs.readBase64('note.md') + await changes.refresh() + assert.equal(count(), 0) + }) + + it('refreshes once for a batch of pulled notes, assets, moves and deletions', async () => { + const { changes, count } = fixture() + await changes.fs.writeText('note.md', 'new content') + await changes.fs.writeBase64('image.png', 'AA==') + await changes.fs.rename('note.md', 'renamed.md') + await changes.fs.deleteFile('old.md') + await changes.refresh() + assert.equal(count(), 1) + }) + + it('still refreshes partial filesystem changes when a sync fails', async () => { + const { changes, count } = fixture(true) + await assert.rejects(changes.fs.writeText('note.md', 'partial'), /disk full/) + await changes.refresh() + assert.equal(count(), 1) + }) + + it('refreshes externally changed files found during scanning without a pull', async () => { + const { changes, count } = fixture() + changes.markChanged() + await changes.refresh() + assert.equal(count(), 1) + }) +}) diff --git a/src/bridge/cloud-sync-refresh.ts b/src/bridge/cloud-sync-refresh.ts new file mode 100644 index 0000000..34f5ba7 --- /dev/null +++ b/src/bridge/cloud-sync-refresh.ts @@ -0,0 +1,30 @@ +import type { PortableCloudSyncFileSystem } from '@zennotes/shared-domain/cloud-sync-portable-filesystem' + +/** One host operation owns this tracker. Mark before writes: a native write + * can change the disk before rejecting. A no-op sync must not rebuild every + * editor surface, but partial failed pulls still need to become visible. */ +export function trackCloudSyncChanges( + fs: PortableCloudSyncFileSystem, + refresh: () => Promise, + state = { changed: false } +) { + const markChanged = () => { state.changed = true } + return { + markChanged, + fs: { + ...fs, + writeText: async (path: string, value: string) => { markChanged(); await fs.writeText(path, value) }, + writeBase64: async (path: string, value: string) => { markChanged(); await fs.writeBase64(path, value) }, + deleteFile: async (path: string) => { markChanged(); await fs.deleteFile(path) }, + rename: async (from: string, to: string) => { markChanged(); await fs.rename(from, to) } + }, + async refresh() { + if (!state.changed) return + state.changed = false + try { await refresh() } catch (error) { + state.changed = true + throw error + } + } + } +} diff --git a/src/bridge/cloud-sync-repository.test.ts b/src/bridge/cloud-sync-repository.test.ts new file mode 100644 index 0000000..292c82e --- /dev/null +++ b/src/bridge/cloud-sync-repository.test.ts @@ -0,0 +1,317 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { describe, it } from 'node:test' +import type { CloudSyncContent, CloudSyncMutation } from '@zennotes/bridge-contract/cloud-sync' +import type { + CloudSyncLocalItem, CloudSyncState, CloudSyncStoredConflict +} from '@zennotes/shared-domain/cloud-sync-engine' +import type { CloudSyncRepository } from '@zennotes/shared-domain/cloud-sync-coordinator' + +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +const { CachedCloudSyncRepository } = await loadMobileModule('./src/bridge/cloud-sync-repository') +const { CloudSyncCoordinator } = await loadMobileModule('@zennotes/shared-domain/cloud-sync-coordinator') + +type StoredFile = { bytes: Buffer; mtime: number } +function harness(initial: Record = { 'note.md': 'Hello' }) { + const files = new Map( + Object.entries(initial).map(([path, body]) => [path, { bytes: Buffer.from(body), mtime: 1000 }]) + ) + let cache: unknown = null + let state: CloudSyncState | null = null + let clock = 1000 + const reads: string[] = [] + const failures = { cacheRead: false, cacheWrite: false, stateRead: false, directory: false, file: false } + let onRead: ((path: string) => void) | undefined + const put = (path: string, body: string | Buffer) => { + files.set(path, { bytes: Buffer.from(body), mtime: ++clock }) + } + const stat = async (path: string) => { + const file = files.get(path) + if (file) return { type: 'file' as const, size: file.bytes.length, mtime: file.mtime } + if ([...files.keys()].some((name) => name.startsWith(path + '/'))) { + return { type: 'directory' as const, size: 0, mtime: 1000 } + } + return null + } + const readdir = async (directory: string) => { + if (failures.directory) throw new Error('Directory unavailable') + const entries = new Map() + const prefix = directory ? directory + '/' : '' + for (const [path, file] of files) { + if (!path.startsWith(prefix)) continue + const relative = path.slice(prefix.length) + const [name, nested] = relative.split('/') + entries.set(name, { + name, type: nested ? 'directory' : 'file', + size: nested ? 0 : file.bytes.length, mtime: nested ? 1000 : file.mtime + }) + } + return [...entries.values()] + } + const readBase64 = async (path: string) => { + reads.push(path) + if (failures.file) throw new Error('File unavailable') + onRead?.(path) + const file = files.get(path) + if (!file) throw new Error('File missing') + return file.bytes.toString('base64') + } + const fs = { + readdir, + stat: async (path: string) => (await stat(path))?.type ?? null, + readBase64, + writeText: async (path: string, data: string) => put(path, data), + writeBase64: async (path: string, data: string) => put(path, Buffer.from(data, 'base64')), + deleteFile: async (path: string) => { files.delete(path) }, + rename: async (from: string, to: string) => { + const file = files.get(from) + if (!file) throw new Error('File missing') + files.set(to, file) + files.delete(from) + } + } + const native = { readdirStrict: readdir, readBase64, statOrNull: stat, stat } + const store = { + loadTracked: async () => { + if (failures.stateRead) throw new Error('State unavailable') + return state + }, + loadCache: async () => { + if (failures.cacheRead) throw new Error('Cache unavailable') + return cache + }, + saveCache: async (next: unknown) => { + if (failures.cacheWrite) throw new Error('Cache unavailable') + cache = structuredClone(next) + } + } + const repository: CloudSyncRepository = new CachedCloudSyncRepository(fs, native, store) + function acknowledge(items: CloudSyncLocalItem[]) { + state = { + version: 1, vault_id: 'vault-1', cursor: 1, + items: Object.fromEntries(items.map((item, index) => [`item-${index}`, { + item_id: `item-${index}`, path: item.path, kind: item.kind, revision: 1, + sha256: item.content.sha256, byte_length: item.content.byte_length, + media_type: item.content.media_type + }])) + } + } + const coordinator = () => { + const mutations: CloudSyncMutation[] = [] + const remote = { + manifest: async () => ({ data: [], cursor: state?.cursor ?? 0, next_page: null }), + changes: async () => ({ data: [], cursor: state?.cursor ?? 0, has_more: false }), + mutate: async (_vaultId: string, body: { mutations: CloudSyncMutation[] }) => { + // Serialization is deliberately real: a cache placeholder must never be uploaded. + mutations.push(...JSON.parse(JSON.stringify(body.mutations))) + return { + acknowledged: body.mutations.map((mutation) => ({ + operation_id: mutation.operation_id, item_id: mutation.item_id, revision: 2, sequence: 1 + })), + conflicts: [], cursor: 1 + } + } + } + let id = 0 + return { + mutations, + service: new CloudSyncCoordinator('vault-1', remote, repository, { + load: async () => state, + save: async (next: CloudSyncState) => { state = structuredClone(next) } + }, { itemId: () => `new-${++id}`, operationId: () => `op-${++id}` }) + } + } + return { + files, reads, failures, repository, acknowledge, coordinator, put, + setReadHook: (hook: typeof onRead) => { onRead = hook }, + get cache() { return cache }, set cache(next: unknown) { cache = next }, + get state() { return state }, set state(next: CloudSyncState | null) { state = next } + } +} + +function content(text: string): CloudSyncContent { + return { + encoding: 'utf8', data: text, sha256: createHash('sha256').update(text).digest('hex'), + byte_length: Buffer.byteLength(text), media_type: 'text/markdown' + } +} + +function pending(path: string, local: CloudSyncContent, cloud = content('Other device')): CloudSyncStoredConflict { + return { + id: 'conflict-1', item_id: 'item-0', kind: 'content', sequence: 2, + base: { path, revision: 1, kind: 'text', content: content('Base') }, + local: { path, revision: null, kind: 'text', content: local }, + cloud: { path, revision: 2, kind: 'text', content: cloud } + } +} + +describe('cached mobile Cloud scan', () => { + it('reads and hashes new text and binary files with the same portable semantics', async () => { + const bytes = Buffer.from([0, 255, 1, 128]) + const h = harness({ 'note.md': 'Hello', 'assets/photo.png': bytes, '.zennotes/cache.json': '{}' }) + const items = await h.repository.scan() + assert.deepEqual(items.map((item) => item.path), ['assets/photo.png', 'note.md']) + assert.equal(items[0].kind, 'binary') + assert.equal(items[0].content.data, bytes.toString('base64')) + assert.equal(items[0].content.sha256, createHash('sha256').update(bytes).digest('hex')) + assert.deepEqual(items[1].content, content('Hello')) + }) + + it('does not reread unchanged acknowledged files on the next sync', async () => { + const h = harness({ 'a.md': 'A', 'b.md': 'B', 'assets/p.png': Buffer.from([0, 255]) }) + h.acknowledge(await h.repository.scan()) + h.reads.length = 0 + const result = await h.coordinator().service.sync() + assert.deepEqual(h.reads, []) + assert.equal(result.pushed, 0) + assert.equal(result.pendingConflicts.length, 0) + }) + + it('rereads only the edited file and uploads its complete current bytes', async () => { + const h = harness({ 'a.md': 'A', 'b.md': 'B' }) + h.acknowledge(await h.repository.scan()) + h.put('a.md', 'Changed') + h.reads.length = 0 + const c = h.coordinator() + const result = await c.service.sync() + assert.deepEqual(h.reads, ['a.md']) + assert.equal(result.pushed, 1) + assert.equal(c.mutations[0].type, 'upsert') + if (c.mutations[0].type === 'upsert') assert.equal(c.mutations[0].content.data, 'Changed') + }) + + + it('rereads a same-size edit when its modification time changes', async () => { + const h = harness() + h.acknowledge(await h.repository.scan()) + h.put('note.md', 'World') + h.reads.length = 0 + assert.equal((await h.repository.scan())[0].content.data, 'World') + assert.deepEqual(h.reads, ['note.md']) + }) + + it('rereads a size change even if a provider preserves the modification time', async () => { + const h = harness() + h.acknowledge(await h.repository.scan()) + h.files.set('note.md', { bytes: Buffer.from('Longer content'), mtime: 1000 }) + h.reads.length = 0 + assert.equal((await h.repository.scan())[0].content.data, 'Longer content') + assert.deepEqual(h.reads, ['note.md']) + }) + + it('rereads files whose scanned content has not yet been acknowledged', async () => { + const h = harness() + await h.repository.scan() + h.reads.length = 0 + assert.equal((await h.repository.scan())[0].content.data, 'Hello') + assert.deepEqual(h.reads, ['note.md']) + }) + + it('rereads an unacknowledged edit even when its fingerprint is cached', async () => { + const h = harness() + h.acknowledge(await h.repository.scan()) + h.put('note.md', 'Local edit') + await h.repository.scan() + h.reads.length = 0 + assert.equal((await h.repository.scan())[0].content.data, 'Local edit') + assert.deepEqual(h.reads, ['note.md']) + }) + + it('keeps rename and deletion semantics intact without uploading cached bytes', async () => { + const h = harness({ 'a.md': 'A', 'b.md': 'B' }) + h.acknowledge(await h.repository.scan()) + h.files.set('renamed.md', h.files.get('a.md')!) + h.files.delete('a.md') + h.files.delete('b.md') + const c = h.coordinator() + await c.service.sync() + assert.deepEqual(c.mutations.map((mutation) => mutation.type).sort(), ['delete', 'move']) + assert.equal(c.mutations.find((mutation) => mutation.type === 'move')?.path, 'renamed.md') + assert.deepEqual(Object.keys(h.cache as object), ['renamed.md']) + }) + + for (const failure of ['cacheRead', 'stateRead', 'cacheWrite'] as const) { + it(`falls back safely when ${failure} fails`, async () => { + const h = harness() + h.acknowledge(await h.repository.scan()) + h.reads.length = 0 + h.failures[failure] = true + const items = await h.repository.scan() + assert.equal(items[0].content.sha256, content('Hello').sha256) + if (failure !== 'cacheWrite') assert.deepEqual(h.reads, ['note.md']) + }) + } + + it('rebuilds a lost or malformed cache from actual file content', async () => { + const h = harness() + h.acknowledge(await h.repository.scan()) + for (const damaged of [null, { 'note.md': { mtime: 1000, sha256: 'not-an-entry' } }]) { + h.cache = damaged + h.reads.length = 0 + assert.equal((await h.repository.scan())[0].content.data, 'Hello') + assert.deepEqual(h.reads, ['note.md']) + } + }) + + for (const mtime of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + it(`does not trust unavailable or invalid modification time ${mtime}`, async () => { + const h = harness() + h.files.get('note.md')!.mtime = mtime + h.acknowledge(await h.repository.scan()) + h.reads.length = 0 + assert.equal((await h.repository.scan())[0].content.data, 'Hello') + assert.deepEqual(h.reads, ['note.md']) + }) + } + + it('does not cache a file that changes while its bytes are being read', async () => { + const h = harness() + h.setReadHook((path) => { h.put(path, 'World') }) + h.acknowledge(await h.repository.scan()) + h.setReadHook(undefined) + // Restore the old metadata as can happen with coarse timestamps: the + // unstable read must not leave a reusable cache entry for that fingerprint. + h.files.get('note.md')!.mtime = 1000 + h.reads.length = 0 + await h.repository.scan() + assert.deepEqual(h.reads, ['note.md']) + }) + + for (const failure of ['directory', 'file'] as const) { + it(`fails closed on a ${failure} read error, without manufacturing a deletion`, async () => { + const h = harness() + h.acknowledge(await h.repository.scan()) + const priorCache = structuredClone(h.cache) + h.put('note.md', 'Unreadable new edit') + h.failures[failure] = true + const c = h.coordinator() + await assert.rejects(c.service.sync(), /unavailable/) + assert.deepEqual(c.mutations, []) + assert.deepEqual(h.cache, priorCache) + }) + } +}) + +describe('cached scan with the pinned conflict coordinator', () => { + it('returns real bytes for review when pending local content matches acknowledged content', async () => { + const h = harness() + h.acknowledge(await h.repository.scan()) + h.state!.pending_conflicts = { 'conflict-1': pending('note.md', content('Hello')) } + h.reads.length = 0 + const details = await h.coordinator().service.getConflict('conflict-1') + assert.equal(details.local.text, 'Hello') + assert.deepEqual(h.reads, ['note.md']) + }) + + it('can locate and read a moved pending-conflict file even when its new path is acknowledged', async () => { + const h = harness({ 'renamed.md': 'Hello' }) + h.acknowledge(await h.repository.scan()) + h.state!.pending_conflicts = { 'conflict-1': pending('old.md', content('Hello')) } + h.reads.length = 0 + const details = await h.coordinator().service.getConflict('conflict-1') + assert.equal(details.local.path, 'renamed.md') + assert.equal(details.local.text, 'Hello') + assert.deepEqual(h.reads, ['renamed.md']) + }) +}) diff --git a/src/bridge/cloud-sync-repository.ts b/src/bridge/cloud-sync-repository.ts new file mode 100644 index 0000000..cf5063f --- /dev/null +++ b/src/bridge/cloud-sync-repository.ts @@ -0,0 +1,297 @@ +/** + * PortableCloudSyncRepository with a scan cache. The upstream portable scan + * reads and hashes every file's full bytes across the native bridge on every + * sync run — a 60-second background cadence on app-core's auto-sync — which + * scales battery and memory cost with vault size. This subclass skips the + * read for files that are provably not needed: + * + * skip ⇔ (mtime AND size unchanged since the last real read) + * AND (that read's hash equals the acked sync state's hash) + * AND (the file is not involved in a pending conflict) + * + * The engine (cloud-sync-engine planCloudSyncMutations) touches + * `content.data` only for items whose hash differs from the tracked state or + * that the state does not know. The host disables skipping for review and + * restore actions, which need real bytes even for acknowledged content. + * A skipped item's `data` property THROWS if unexpectedly consumed, rather + * than silently pushing content we never read. + * + * A lost cache or unknown timestamp causes a full read. Like other + * metadata-based caches, this relies on the provider updating mtime or size + * when content changes; same-size writes preserving mtime cannot be detected. + */ +import type { + CloudSyncContent, + CloudSyncItemKind +} from '@zennotes/bridge-contract/cloud-sync' +import { + cloudSyncPathKey, + normalizeCloudSyncPath, + shouldSyncVaultPath, + shouldTraverseCloudSyncDirectory +} from '@zennotes/shared-domain/cloud-sync' +import { + PortableCloudSyncRepository, + type PortableCloudSyncFileSystem +} from '@zennotes/shared-domain/cloud-sync-portable-filesystem' +import type { CloudSyncLocalItem, CloudSyncState } from '@zennotes/shared-domain/cloud-sync-engine' +import type { NativeFs } from './native-fs' +import { cloudSyncWorkBudget, decodeCloudSyncBase64 } from './cloud-sync-work' + +export interface ScanCacheEntry { + mtime: number + size: number + sha256: string + kind: CloudSyncItemKind + byte_length: number + media_type: string +} + +export type ScanCache = Record + +export interface ScanCacheStore { + loadTracked(): Promise + loadCache(): Promise + saveCache(cache: ScanCache): Promise +} + +export class CachedCloudSyncRepository extends PortableCloudSyncRepository { + constructor( + fs: PortableCloudSyncFileSystem, + private readonly native: Pick, + private readonly store: ScanCacheStore, + private readonly onChanged: () => void = () => {} + ) { + super(fs) + } + + override async scan(): Promise { + const trackedSha = trackedShaByPath(await this.store.loadTracked().catch(() => null)) + const cache = normalizeScanCache(await this.store.loadCache().catch(() => null)) + const nextCache: ScanCache = {} + const items: CloudSyncLocalItem[] = [] + await this.walkCached('', trackedSha, cache, nextCache, items) + if (Object.keys(cache).some((path) => !nextCache[path])) this.onChanged() + // Cache loss is only a slow next scan — never let it fail the sync run. + await this.store.saveCache(nextCache).catch(() => {}) + return items.sort((left, right) => left.path.localeCompare(right.path)) + } + + private async walkCached( + directory: string, + trackedSha: Map, + cache: ScanCache, + nextCache: ScanCache, + items: CloudSyncLocalItem[] + ): Promise { + // readdirStrict entries carry mtime and size, so validating the cache + // costs no extra stat calls on a hit. Fresh reads are checked again + // before their fingerprints can be reused on a later scan. + const entries = await this.native.readdirStrict(directory) + const checkpoint = cloudSyncWorkBudget() + for (const entry of entries) { + await checkpoint() + const relPath = directory ? `${directory}/${entry.name}` : entry.name + if (entry.type === 'directory') { + if (shouldTraverseCloudSyncDirectory(relPath)) { + await this.walkCached(relPath, trackedSha, cache, nextCache, items) + } + continue + } + if (!shouldSyncVaultPath(relPath)) continue + + const path = normalizeCloudSyncPath(relPath) + const cached = cache[path] + if ( + cached && + cached.mtime === entry.mtime && + cached.size === entry.size && + trackedSha.get(cloudSyncPathKey(path)) === cached.sha256 + ) { + nextCache[path] = cached + items.push(itemFromCache(path, cached)) + continue + } + + const item = await this.readItemFresh(path) + if (!cached || cached.mtime !== entry.mtime || cached.size !== entry.size || cached.sha256 !== item.content.sha256) { + this.onChanged() + } + // Never seed a reusable fingerprint from an unstable native read. + // Unknown provider timestamps are deliberately always cache misses. + const after = await this.native.statOrNull(path).catch(() => null) + if (validFingerprint(entry) && after?.type === 'file' && + after.mtime === entry.mtime && after.size === entry.size && + item.content.byte_length === entry.size) nextCache[path] = { + mtime: entry.mtime, + size: entry.size, + sha256: item.content.sha256, + kind: item.kind, + byte_length: item.content.byte_length, + media_type: item.content.media_type + } + items.push(item) + } + } + + // --------------------------------------------------------------------- + // Preserve upstream readItem's encoding/hash semantics while yielding + // during large base64 decoding and avoiding a binary re-encode. + // --------------------------------------------------------------------- + + private async readItemFresh(path: string): Promise { + const { bytes, base64 } = await decodeCloudSyncBase64(await this.native.readBase64(path)) + const text = decodeText(path, bytes) + return { + path, + kind: text === null ? 'binary' : 'text', + content: { + encoding: text === null ? 'base64' : 'utf8', + data: text === null ? base64 : text, + sha256: await sha256(bytes), + byte_length: bytes.byteLength, + media_type: mediaType(path, text !== null) + } + } + } +} + +function itemFromCache(path: string, cached: ScanCacheEntry): CloudSyncLocalItem { + const content = { + encoding: cached.kind === 'text' ? 'utf8' : 'base64', + sha256: cached.sha256, + byte_length: cached.byte_length, + media_type: cached.media_type + } as CloudSyncContent + Object.defineProperty(content, 'data', { + enumerable: true, + get(): string { + throw new Error( + `Cloud sync tried to push ${path} from the scan cache without reading it — the engine's hash-equal items must never need content.` + ) + } + }) + return { path, kind: cached.kind, content } +} + +function trackedShaByPath(state: CloudSyncState | null): Map { + const out = new Map() + if (!state || state.version !== 1 || !state.items) return out + for (const item of Object.values(state.items)) { + if (item && typeof item.path === 'string' && typeof item.sha256 === 'string') { + out.set(cloudSyncPathKey(item.path), item.sha256) + } + } + // Conflict review/resolution consumes actual bytes, even if a version is + // already acknowledged. Hash exclusions also cover moved local versions. + const paths = new Set() + const hashes = new Set() + for (const conflict of Object.values(state.pending_conflicts ?? {})) { + for (const snapshot of [conflict.base, conflict.local, conflict.cloud]) { + if (snapshot?.path) paths.add(cloudSyncPathKey(snapshot.path)) + if (snapshot?.content?.sha256) hashes.add(snapshot.content.sha256) + } + for (const path of conflict.paused_paths ?? []) paths.add(cloudSyncPathKey(path)) + } + for (const [path, hash] of out) { + if (paths.has(path) || hashes.has(hash)) out.delete(path) + } + return out +} + +function validFingerprint(entry: { mtime?: number; size?: number }): boolean { + return typeof entry.mtime === 'number' && Number.isFinite(entry.mtime) && entry.mtime > 0 && + typeof entry.size === 'number' && Number.isSafeInteger(entry.size) && entry.size >= 0 +} + +function normalizeScanCache(raw: unknown): ScanCache { + if (!raw || typeof raw !== 'object') return {} + const out: ScanCache = {} + for (const [path, value] of Object.entries(raw as Record)) { + const entry = value as Partial | null + if ( + entry && + validFingerprint(entry) && + typeof entry.sha256 === 'string' && + (entry.kind === 'text' || entry.kind === 'binary') && + typeof entry.byte_length === 'number' && + typeof entry.media_type === 'string' + ) { + out[path] = entry as ScanCacheEntry + } + } + return out +} + +// Mirrored from upstream cloud-sync-portable-filesystem.ts — keep in lockstep. + +const TEXT_EXTENSIONS = new Set([ + '.base', + '.css', + '.csv', + '.excalidraw', + '.htm', + '.html', + '.ini', + '.js', + '.json', + '.jsx', + '.md', + '.mdx', + '.svg', + '.toml', + '.ts', + '.tsx', + '.txt', + '.xml', + '.yaml', + '.yml' +]) + +const MEDIA_TYPES: Record = { + '.css': 'text/css', + '.csv': 'text/csv', + '.gif': 'image/gif', + '.htm': 'text/html', + '.html': 'text/html', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.js': 'text/javascript', + '.json': 'application/json', + '.md': 'text/markdown', + '.pdf': 'application/pdf', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.toml': 'application/toml', + '.txt': 'text/plain', + '.webp': 'image/webp', + '.xml': 'application/xml', + '.yaml': 'application/yaml', + '.yml': 'application/yaml' +} + +function decodeText(path: string, bytes: Uint8Array): string | null { + if (!TEXT_EXTENSIONS.has(extension(path))) return null + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes) + } catch { + return null + } +} + +function extension(path: string): string { + const name = path.slice(path.lastIndexOf('/') + 1) + const dot = name.lastIndexOf('.') + return dot < 0 ? '' : name.slice(dot).toLowerCase() +} + +function mediaType(path: string, text: boolean): string { + return MEDIA_TYPES[extension(path)] ?? (text ? 'text/plain' : 'application/octet-stream') +} + +async function sha256(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest('SHA-256', bytes.buffer) + return [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, '0')) + .join('') +} diff --git a/src/bridge/cloud-sync-rescan.test.ts b/src/bridge/cloud-sync-rescan.test.ts new file mode 100644 index 0000000..9c3fb95 --- /dev/null +++ b/src/bridge/cloud-sync-rescan.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict' +import { it } from 'node:test' +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: { getItem: () => null } }) +const { MobileVault, onVaultChange } = await loadMobileModule(['./src/bridge/vault-fs', './src/bridge/events']) + +it('batches a foreground/cloud refresh into one resync without triggering local autosync events', async () => { + const events: unknown[] = [] + const unsubscribe = onVaultChange((event: unknown) => events.push(event)) + const invalidated: string[] = [] + const vault = { + settingsCache: {}, + metaCache: new Map([ + ['deleted.md', { meta: { updatedAt: 1 }, size: 1 }], + ['changed.md', { meta: { updatedAt: 1 }, size: 1 }] + ]), + listNotes: async () => [ + { path: 'changed.md', updatedAt: 2, size: 2, folder: 'inbox' }, + { path: 'new.md', updatedAt: 2, size: 2, folder: 'inbox' } + ], + invalidateMeta: (path: string) => invalidated.push(path), + folderOf: async () => 'inbox' + } + try { + await MobileVault.prototype.rescan.call(vault) + assert.deepEqual(events, [{ kind: 'change', path: '', folder: 'inbox', scope: 'resync' }]) + assert.deepEqual(invalidated, ['deleted.md']) + assert.equal(vault.settingsCache, null) + } finally { unsubscribe() } +}) diff --git a/src/bridge/cloud-sync-work.test.ts b/src/bridge/cloud-sync-work.test.ts new file mode 100644 index 0000000..61a2480 --- /dev/null +++ b/src/bridge/cloud-sync-work.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { decodeCloudSyncBase64, yieldToUi } from './cloud-sync-work.ts' + +describe('cloud sync cooperative work', () => { + it('decodes exact bytes, including padding, whitespace and data URLs', async () => { + for (const size of [0, 1, 2, 3, 49_151, 49_152, 49_153, 300_001]) { + const expected = Buffer.alloc(size) + for (let i = 0; i < size; i++) expected[i] = i % 256 + const base64 = expected.toString('base64') + const result = await decodeCloudSyncBase64(`data:image/png;base64,\n${base64}\n`) + assert.deepEqual(Buffer.from(result.bytes), expected) + assert.equal(result.base64, base64) + } + }) + + it('lets pending input run before a large attachment finishes decoding', async () => { + const input = Buffer.alloc(8_100_000, 125).toString('base64') + let inputProcessed = false + const timer = setTimeout(() => { inputProcessed = true }, 0) + try { + const result = await decodeCloudSyncBase64(input) + assert.equal(inputProcessed, true) + assert.equal(result.bytes.length, 8_100_000) + assert.equal(result.bytes.at(-1), 125) + } finally { + clearTimeout(timer) + } + }) + + it('uses a real task boundary on older WebViews without scheduler.yield', async () => { + let inputProcessed = false + setTimeout(() => { inputProcessed = true }, 0) + await yieldToUi() + assert.equal(inputProcessed, true) + }) + + it('rejects malformed base64 instead of hashing damaged bytes', async () => { + await assert.rejects(decodeCloudSyncBase64('AA!A')) + }) +}) diff --git a/src/bridge/cloud-sync-work.ts b/src/bridge/cloud-sync-work.ts new file mode 100644 index 0000000..2f47c18 --- /dev/null +++ b/src/bridge/cloud-sync-work.ts @@ -0,0 +1,41 @@ +/** Shared with the iOS shell. Async filesystem calls do not move the + * following JavaScript off the editor's thread. Give input a real turn. + * https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/yield + * The timer fallback also supports iOS 15 / older Android WebViews. */ +export function yieldToUi(): Promise { + const scheduler = (globalThis as typeof globalThis & { + scheduler?: { yield?: () => Promise } + }).scheduler + return scheduler?.yield ? scheduler.yield() : new Promise((resolve) => setTimeout(resolve, 0)) +} + +export function cloudSyncWorkBudget(): () => Promise | undefined { + let started = performance.now() + return () => { + if (performance.now() - started < 8) return + return yieldToUi().then(() => { started = performance.now() }) + } +} + +/** Decode in bounded chunks instead of Uint8Array.from(string, callback), + * which visits every byte through an allocating JS iterator. Keep the + * original base64 for binary uploads rather than encoding the bytes again. */ +export async function decodeCloudSyncBase64(value: string): Promise<{ + bytes: Uint8Array + base64: string +}> { + if (value.length >= 262_144) await yieldToUi() + const base64 = (value.includes(',') ? value.slice(value.indexOf(',') + 1) : value).replace(/\s/g, '') + const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0 + const bytes = new Uint8Array(Math.max(0, Math.floor(base64.length * 3 / 4) - padding)) + const checkpoint = cloudSyncWorkBudget() + let offset = 0 + // Multiples of four keep each base64 chunk independently decodable. + for (let start = 0; start < base64.length; start += 65_536) { + const binary = atob(base64.slice(start, start + 65_536)) + for (let i = 0; i < binary.length; i++) bytes[offset++] = binary.charCodeAt(i) + await checkpoint() + } + if (offset !== bytes.length) throw new Error('Invalid Cloud file encoding.') + return { bytes, base64 } +} diff --git a/src/bridge/mobile-cloud-sync.integration.test.ts b/src/bridge/mobile-cloud-sync.integration.test.ts new file mode 100644 index 0000000..e481790 --- /dev/null +++ b/src/bridge/mobile-cloud-sync.integration.test.ts @@ -0,0 +1,306 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { describe, it } from 'node:test' +import type { + CloudSyncChange, CloudSyncContent, CloudSyncManifestItem, CloudSyncMutation +} from '@zennotes/bridge-contract/cloud-sync' +import type { CloudSyncState } from '@zennotes/shared-domain/cloud-sync-engine' +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +function textContent(text: string): CloudSyncContent { + return { + encoding: 'utf8', data: text, byte_length: Buffer.byteLength(text), + sha256: createHash('sha256').update(text).digest('hex'), media_type: 'text/markdown' + } +} + +/** Real mobile adapter -> host service -> coordinator -> cached repository. + * Only Capacitor storage/auth/layout and the server boundary are fakes. */ +async function fixture(initial: Record = { 'note.md': 'Original' }) { + const persisted = new Map() + const files = new Map(Object.entries(initial).map(([path, data]) => [ + path, { bytes: Buffer.from(data), mtime: 1000 } + ])) + const remoteItems = new Map() + const revisions = new Map() + const feed: CloudSyncChange[] = [] + const reads: string[] = [] + const refreshes: Record[] = [] + const uploaded: CloudSyncMutation[] = [] + let cursor = 0 + let clock = 1000 + let failWritePath: string | null = null + let beforeChanges: (() => void) | undefined + + const put = (path: string, bytes: string | Buffer) => { + files.set(path, { bytes: Buffer.from(bytes), mtime: ++clock }) + } + function remoteText(path: string, text: string) { + const previous = [...remoteItems.values()].find((item) => item.path === path) + const itemId = previous?.item_id ?? 'remote-' + path + const content = textContent(text) + const revision = (previous?.revision ?? 0) + 1 + const item: CloudSyncManifestItem = { + item_id: itemId, path, kind: 'text', revision, content, + sha256: content.sha256, byte_length: content.byte_length, media_type: content.media_type + } + remoteItems.set(itemId, item) + revisions.set(itemId + ':' + revision, structuredClone(item)) + feed.push({ + sequence: ++cursor, item_id: itemId, type: 'upsert', path, + previous_path: previous?.path ?? null, revision, content + }) + return item + } + const remote = { + listVaults: async () => ({ data: [{ id: 'vault-1', name: 'Test vault' }] }), + manifest: async () => ({ data: [...remoteItems.values()], cursor, next_page: null }), + changes: async (_vaultId: string, after: number) => { + beforeChanges?.() + return { data: feed.filter((change) => change.sequence > after), cursor, has_more: false } + }, + revision: async (_vaultId: string, itemId: string, revision: number) => { + const item = revisions.get(itemId + ':' + revision) + assert.ok(item) + return { data: { ...item, deleted: false } } + }, + mutate: async (_vaultId: string, request: { mutations: CloudSyncMutation[] }) => { + uploaded.push(...JSON.parse(JSON.stringify(request.mutations))) + const acknowledged = request.mutations.map((mutation) => { + const previous = remoteItems.get(mutation.item_id) + const revision = (previous?.revision ?? 0) + 1 + if (mutation.type === 'upsert') { + const item: CloudSyncManifestItem = { + item_id: mutation.item_id, path: mutation.path, kind: mutation.kind, + revision, content: structuredClone(mutation.content), + sha256: mutation.content.sha256, byte_length: mutation.content.byte_length, + media_type: mutation.content.media_type + } + remoteItems.set(item.item_id, item) + revisions.set(item.item_id + ':' + revision, structuredClone(item)) + feed.push({ + sequence: ++cursor, item_id: item.item_id, type: 'upsert', path: item.path, + previous_path: previous?.path ?? null, revision, content: item.content + }) + } else { + assert.ok(previous) + if (mutation.type === 'delete') remoteItems.delete(mutation.item_id) + else remoteItems.set(mutation.item_id, { ...previous, path: mutation.path, revision }) + feed.push({ + sequence: ++cursor, item_id: mutation.item_id, type: mutation.type, + path: mutation.type === 'move' ? mutation.path : previous.path, + previous_path: previous.path, revision + }) + } + return { operation_id: mutation.operation_id, item_id: mutation.item_id, revision, sequence: cursor } + }) + return { acknowledged, conflicts: [], cursor } + } + } + const native = { + rootPath: 'ZenNotes/Test', + async readdirStrict(directory: string) { + const entries = new Map() + const prefix = directory ? directory + '/' : '' + for (const [path, file] of files) { + if (!path.startsWith(prefix)) continue + const [name, nested] = path.slice(prefix.length).split('/') + entries.set(name, { + name, type: nested ? 'directory' : 'file', + size: nested ? 0 : file.bytes.length, mtime: nested ? 1000 : file.mtime + }) + } + return [...entries.values()] + }, + async statOrNull(path: string) { + const file = files.get(path) + return file ? { type: 'file' as const, mtime: file.mtime, size: file.bytes.length } : null + }, + async statVerified(path: string) { return files.has(path) ? 'file' : null }, + async readBase64(path: string) { + reads.push(path) + const file = files.get(path) + assert.ok(file) + return file.bytes.toString('base64') + }, + async writeText(path: string, data: string) { + put(path, data) + if (path === failWritePath) throw new Error('Native write failed after writing') + }, + async writeBase64(path: string, data: string) { + put(path, Buffer.from(data, 'base64')) + if (path === failWritePath) throw new Error('Native write failed after writing') + }, + async deleteFile(path: string) { files.delete(path) }, + async mkdir(_path: string) {}, + async rename(from: string, to: string) { + const file = files.get(from) + assert.ok(file) + files.set(to, file) + files.delete(from) + } + } + const vault = { + rootLabel: 'ZenNotes/Test', fs: native, + async rescan() { + refreshes.push(Object.fromEntries([...files].map(([path, file]) => [path, file.bytes.toString()]))) + } + } + const api = await loadMobileModule('./src/bridge/mobile-cloud-sync', { + '@capacitor/filesystem': { + Directory: { Data: 'DATA', Cache: 'CACHE' }, Encoding: { UTF8: 'utf8' }, + Filesystem: { + async readFile({ path }: { path: string }) { + if (!persisted.has(path)) throw Object.assign(new Error('Missing'), { code: 'OS-PLUG-FILE-0008' }) + return { data: persisted.get(path)! } + }, + async writeFile({ path, data }: { path: string; data: string }) { persisted.set(path, data) }, + async deleteFile({ path }: { path: string }) { persisted.delete(path) } + } + }, + '@capacitor/share': { Share: {} }, + './vault-fs': { MobileVault: class {} }, + './cloud-layout': { reconcileLayoutForCloudJoin: async () => {} }, + './mobile-cloud-auth': { + authenticatedCredential: async () => ({ base_url: 'https://sync.example.test', token: 'test-only' }), + authenticatedClient: async () => remote, + getMobileCloudAccountStatus: async () => ({ + state: 'connected', account: { base_url: 'https://sync.example.test' } + }) + } + }) + await api.linkMobileCloudVault(vault, 'vault-1') + const stateKey = () => [...persisted.keys()].find((path) => path.includes('/states/')) + return { + api, vault, files, reads, uploaded, refreshes, remoteText, put, + sync: () => api.syncMobileCloudVault(vault), + setFailWrite: (path: string | null) => { failWritePath = path }, + setBeforeChanges: (callback: typeof beforeChanges) => { beforeChanges = callback }, + clearState: () => { const key = stateKey(); if (key) persisted.delete(key) }, + get state(): CloudSyncState { + const key = stateKey() + assert.ok(key) + return JSON.parse(persisted.get(key)!) + }, + set state(value: CloudSyncState) { + const key = stateKey() + assert.ok(key) + persisted.set(key, JSON.stringify(value)) + } + } +} + +describe('mobile Cloud adapter wiring', () => { + it('does not rescan the vault or reread acknowledged bytes during a no-op sync', async () => { + const h = await fixture() + await h.sync() + h.refreshes.length = 0 + h.reads.length = 0 + const summary = await h.sync() + assert.equal(summary.pushed, 0) + assert.equal(summary.pulled, 0) + assert.deepEqual(h.refreshes, []) + assert.deepEqual(h.reads, []) + }) + + it('resynchronizes UI state once after a whole batch of pulled files', async () => { + const h = await fixture() + await h.sync() + h.refreshes.length = 0 + h.remoteText('note.md', 'Remote update') + h.remoteText('second.md', 'Second note') + h.remoteText('folder/third.md', 'Third note') + const summary = await h.sync() + assert.equal(summary.pulled, 3) + assert.equal(h.refreshes.length, 1) + assert.deepEqual(h.refreshes[0], { + 'note.md': 'Remote update', 'second.md': 'Second note', 'folder/third.md': 'Third note' + }) + h.refreshes.length = 0 + await h.sync() + assert.deepEqual(h.refreshes, []) + }) + + it('refreshes a newly discovered local edit even when nothing is downloaded', async () => { + const h = await fixture() + await h.sync() + h.refreshes.length = 0 + h.put('note.md', 'Local changes') + const summary = await h.sync() + assert.equal(summary.pulled, 0) + assert.equal(summary.pushed, 1) + assert.deepEqual(h.refreshes, [{ 'note.md': 'Local changes' }]) + const uploaded = h.uploaded.at(-1) + assert.equal(uploaded?.type, 'upsert') + if (uploaded?.type === 'upsert') assert.equal(uploaded.content.data, 'Local changes') + }) + + it('exposes partial native writes when a pull fails, and can retry safely', async () => { + const h = await fixture() + await h.sync() + h.refreshes.length = 0 + h.remoteText('note.md', 'Remote update') + h.remoteText('second.md', 'Partially written') + h.setFailWrite('second.md') + await assert.rejects(h.sync(), /Native write failed/) + assert.deepEqual(h.refreshes, [{ 'note.md': 'Remote update', 'second.md': 'Partially written' }]) + h.setFailWrite(null) + await h.sync() + assert.equal(h.files.get('note.md')?.bytes.toString(), 'Remote update') + assert.equal(h.files.get('second.md')?.bytes.toString(), 'Partially written') + }) + + it('keeps a conflicting local edit visible when a remote version arrives', async () => { + const h = await fixture() + await h.sync() + h.refreshes.length = 0 + h.remoteText('note.md', 'Cloud replacement') + h.setBeforeChanges(() => { + h.setBeforeChanges(undefined) + h.put('note.md', 'Local replacement') + }) + const summary = await h.sync() + assert.equal(summary.pending_conflicts.length, 1) + assert.equal(h.files.get('note.md')?.bytes.toString(), 'Local replacement') + assert.equal(h.refreshes.at(-1)?.['note.md'], 'Local replacement') + const details = await h.api.getMobileCloudConflict(h.vault, summary.pending_conflicts[0].id) + assert.equal(details.local.text, 'Local replacement') + assert.equal(details.cloud.text, 'Cloud replacement') + }) + + it('loads full pending-conflict review bytes despite an acknowledged warm scan cache', async () => { + const h = await fixture() + await h.sync() + const state = h.state + const [item] = Object.values(state.items) + state.pending_conflicts = { + [item.item_id]: { + id: item.item_id, item_id: item.item_id, kind: 'content', sequence: 2, + base: { path: item.path, revision: 1, kind: 'text', content: textContent('Base') }, + local: { path: item.path, revision: null, kind: 'text', content: textContent('Original') }, + cloud: { path: item.path, revision: 2, kind: 'text', content: textContent('Cloud replacement') } + } + } + h.state = state + h.reads.length = 0 + const details = await h.api.getMobileCloudConflict(h.vault, item.item_id) + assert.equal(details.local.text, 'Original') + assert.equal(details.cloud.text, 'Cloud replacement') + assert.deepEqual(h.reads, ['note.md']) + }) + + it('loads full bootstrap review bytes when old scan cache survives a lost sync state', async () => { + const h = await fixture() + await h.sync() + const cloud = h.remoteText('note.md', 'Cloud replacement') + h.clearState() + h.reads.length = 0 + const details = await h.api.getMobileCloudBootstrapConflict(h.vault, { + code: 'BOOTSTRAP_CONTENT_CONFLICT', item_id: cloud.item_id, path: cloud.path, + local_sha256: textContent('Original').sha256, remote_sha256: cloud.sha256 + }) + assert.equal(details.local.text, 'Original') + assert.equal(details.cloud.text, 'Cloud replacement') + assert.deepEqual(h.reads, ['note.md']) + }) +}) diff --git a/src/bridge/mobile-cloud-sync.ts b/src/bridge/mobile-cloud-sync.ts index e4a7995..3854d96 100644 --- a/src/bridge/mobile-cloud-sync.ts +++ b/src/bridge/mobile-cloud-sync.ts @@ -26,7 +26,6 @@ import { type CloudSyncHostVault } from '@zennotes/shared-domain/cloud-sync-host-service' import { - PortableCloudSyncRepository, type PortableCloudSyncFileSystem } from '@zennotes/shared-domain/cloud-sync-portable-filesystem' import type { CloudSyncState } from '@zennotes/shared-domain/cloud-sync-engine' @@ -37,10 +36,12 @@ import { authenticatedClient, getMobileCloudAccountStatus } from './mobile-cloud-auth' -import { emitVaultChange } from './events' +import { CachedCloudSyncRepository, type ScanCache } from './cloud-sync-repository' +import { trackCloudSyncChanges } from './cloud-sync-refresh' import { isNotFoundError } from './fs-errors' const STORAGE_ROOT = 'zennotes-cloud-sync' +const refreshStates = new WeakMap() const persistence: CloudSyncHostPersistence = { async loadLink(vaultKey: string): Promise { @@ -103,13 +104,7 @@ export async function deleteMobileCloudVault(vault: MobileVault): Promise } export async function syncMobileCloudVault(vault: MobileVault): Promise { - const summary = await service.sync(hostVault(vault)) - - if (summary.pulled > 0) { - emitVaultChange({ kind: 'change', path: '', folder: 'inbox', scope: 'resync' }) - } - - return summary + return service.sync(hostVault(vault, true)) } export async function getMobileCloudConflict( @@ -276,7 +271,7 @@ export async function restoreMobileCloudBackupNote( return service.restoreBackupNote(hostVault(vault), backupId, snapshotItemId) } -function hostVault(vault: MobileVault): CloudSyncHostVault { +function hostVault(vault: MobileVault, cacheScan = false): CloudSyncHostVault { const fs: PortableCloudSyncFileSystem = { // Sync must distinguish an unreadable provider from an empty vault. readdir: async (directory) => @@ -297,13 +292,35 @@ function hostVault(vault: MobileVault): CloudSyncHostVault { } } + const vaultKey = vault.rootLabel + const state = refreshStates.get(vault) ?? { changed: false } + refreshStates.set(vault, state) + const changes = trackCloudSyncChanges(fs, () => vault.rescan(), state) return { - key: vault.rootLabel, - repository: new PortableCloudSyncRepository(fs), - refresh: () => vault.rescan() + key: vaultKey, + repository: new CachedCloudSyncRepository(changes.fs, vault.fs, { + loadTracked: async () => { + // Review/restore actions always receive real bytes, not scan placeholders. + if (!cacheScan) return null + const link = await readJson(await linkPath(vaultKey)) + if (!isRecord(link) || typeof link.base_url !== 'string' || typeof link.vault_id !== 'string') return null + return await readJson(await statePath(vaultKey, link.base_url, link.vault_id)) as CloudSyncState | null + }, + loadCache: async () => readJson(await scanCachePath(vaultKey)), + saveCache: async (cache: ScanCache) => writeJson(await scanCachePath(vaultKey), cache) + }, changes.markChanged), + refresh: changes.refresh } } +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' +} + +async function scanCachePath(vaultKey: string): Promise { + return `${STORAGE_ROOT}/scan-cache/${await fingerprint(vaultKey)}.json` +} + async function linkPath(vaultKey: string): Promise { return `${STORAGE_ROOT}/links/${await fingerprint(vaultKey)}.json` } diff --git a/src/bridge/vault-fs.ts b/src/bridge/vault-fs.ts index 3c028ff..e1b2ddd 100644 --- a/src/bridge/vault-fs.ts +++ b/src/bridge/vault-fs.ts @@ -1384,7 +1384,7 @@ export class MobileVault { } } - /** Foreground rescan: re-stat the tree, emit change events for anything new. */ + /** Foreground/cloud rescan: refresh cached metadata and notify once. */ async rescan(): Promise { // Vault settings may have changed externally (desktop edit synced via // iCloud) — drop the cache so the next read sees the file. @@ -1392,25 +1392,13 @@ export class MobileVault { const before = new Map(this.metaCache) const notes = await this.listNotes() const seen = new Set(notes.map((n) => n.path)) - for (const note of notes) { - const prev = before.get(note.path) - if (!prev) { - emitVaultChange({ kind: 'add', path: note.path, folder: note.folder, scope: 'content' }) - } else if (prev.meta.updatedAt !== note.updatedAt || prev.size !== note.size) { - emitVaultChange({ kind: 'change', path: note.path, folder: note.folder, scope: 'content' }) - } - } for (const [path] of before) { - if (!seen.has(path)) { - this.invalidateMeta(path) - emitVaultChange({ - kind: 'unlink', - path, - folder: (await this.folderOf(path)) ?? 'inbox', - scope: 'content' - }) - } + if (!seen.has(path)) this.invalidateMeta(path) } + // A single resync refreshes assets/settings as well as notes and keeps + // remote changes from scheduling another local-change autosync. The + // app-core resync handler preserves open notes with unsaved edits. + emitVaultChange({ kind: 'change', path: '', folder: 'inbox', scope: 'resync' }) } } diff --git a/tooling/load-mobile-module.ts b/tooling/load-mobile-module.ts new file mode 100644 index 0000000..98ee3e0 --- /dev/null +++ b/tooling/load-mobile-module.ts @@ -0,0 +1,46 @@ +import { createRequire } from 'node:module' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const require = createRequire(resolve(root, '.zennotes-source/package.json')) + +/** Load real TypeScript modules with this mobile app's pinned source aliases. */ +export async function loadMobileModule( + entry: string | string[], + mocks: Record> = {} +): Promise> { + const { build } = require('esbuild') + const result = await build({ + stdin: { + contents: (Array.isArray(entry) ? entry : [entry]).map((path) => `export * from ${JSON.stringify(path)};`).join('\n'), + resolveDir: root, + loader: 'ts' + }, + absWorkingDir: root, + tsconfig: resolve(root, 'tsconfig.json'), + bundle: true, + platform: 'node', + format: 'cjs', + write: false, + plugins: [{ + name: 'mobile-test-boundaries', + setup(plugin: any) { + plugin.onResolve({ filter: /.*/ }, (args: { path: string }) => + Object.hasOwn(mocks, args.path) ? { path: args.path, namespace: 'mobile-test-boundary' } : undefined + ) + plugin.onLoad({ filter: /.*/, namespace: 'mobile-test-boundary' }, (args: { path: string }) => ({ + contents: `module.exports = __mobileTestMocks[${JSON.stringify(args.path)}];`, + loader: 'js' + })) + } + }] + }) + const module = { exports: {} } + const execute = new Function( + 'require', 'module', 'exports', '__mobileTestMocks', + result.outputFiles[0].text + '\n//# sourceURL=' + entry + ) + execute(require, module, module.exports, mocks) + return module.exports +} From e4243250aa98d6d33c28079955c918d947a20035 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Fri, 11 Sep 2026 12:29:53 -0500 Subject: [PATCH 2/7] feat: support Android keyboard image paste (#54) Use bounded rich-content reads with temporary URI grants, cancellation-safe teardown, and note-context checks. Preserve WebP filenames for self-hosted vaults. Native keyboard verification remains pending. --- .../java/md/zennotes/ClipboardImageData.java | 37 +++++ .../java/md/zennotes/ClipboardImageRead.java | 45 +++++++ .../java/md/zennotes/ImagePastePlugin.java | 126 ++++++++++++++++++ .../java/md/zennotes/ImagePasteWebView.java | 36 +++++ .../main/java/md/zennotes/MainActivity.java | 1 + .../layout/capacitor_bridge_layout_main.xml | 7 + .../md/zennotes/ClipboardImageDataTest.java | 25 ++++ .../md/zennotes/ClipboardImageReadTest.java | 105 +++++++++++++++ src/bridge/remote-vault-paste.test.ts | 48 +++++++ src/bridge/remote-vault.ts | 6 +- src/ui-mobile/MobileShell.tsx | 2 + src/ui-mobile/image-paste-session.test.ts | 39 ++++++ src/ui-mobile/image-paste-session.ts | 21 +++ src/ui-mobile/image-paste.ts | 77 +++++++++++ 14 files changed, 572 insertions(+), 3 deletions(-) create mode 100644 android/app/src/main/java/md/zennotes/ClipboardImageData.java create mode 100644 android/app/src/main/java/md/zennotes/ClipboardImageRead.java create mode 100644 android/app/src/main/java/md/zennotes/ImagePastePlugin.java create mode 100644 android/app/src/main/java/md/zennotes/ImagePasteWebView.java create mode 100644 android/app/src/main/res/layout/capacitor_bridge_layout_main.xml create mode 100644 android/app/src/test/java/md/zennotes/ClipboardImageDataTest.java create mode 100644 android/app/src/test/java/md/zennotes/ClipboardImageReadTest.java create mode 100644 src/bridge/remote-vault-paste.test.ts create mode 100644 src/ui-mobile/image-paste-session.test.ts create mode 100644 src/ui-mobile/image-paste-session.ts create mode 100644 src/ui-mobile/image-paste.ts diff --git a/android/app/src/main/java/md/zennotes/ClipboardImageData.java b/android/app/src/main/java/md/zennotes/ClipboardImageData.java new file mode 100644 index 0000000..224e873 --- /dev/null +++ b/android/app/src/main/java/md/zennotes/ClipboardImageData.java @@ -0,0 +1,37 @@ +package md.zennotes; + +import java.io.IOException; +import java.io.InputStream; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + +final class ClipboardImageData { + static final int MAX_BYTES = 10 * 1024 * 1024; + static final class InvalidImage extends IOException { + InvalidImage(String message) { super(message); } + } + static byte[] read(InputStream stream, int limit) throws IOException { + if (stream == null) throw new InvalidImage("Could not read the pasted image."); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[16384]; + int size; + while ((size = stream.read(buffer)) != -1) { + if (size > limit - output.size()) throw new InvalidImage("Pasted images must be 10 MB or smaller."); + output.write(buffer, 0, size); + } + if (output.size() == 0) throw new InvalidImage("The pasted image is empty."); + return output.toByteArray(); + } + static String mimeType(byte[] b) throws IOException { + if (b.length >= 8 && (b[0] & 255) == 137 && b[1] == 80 && b[2] == 78 && b[3] == 71 && + b[4] == 13 && b[5] == 10 && b[6] == 26 && b[7] == 10) return "image/png"; + if (b.length >= 3 && (b[0] & 255) == 255 && (b[1] & 255) == 216 && (b[2] & 255) == 255) return "image/jpeg"; + if (b.length >= 6) { + String header = new String(b, 0, 6, StandardCharsets.US_ASCII); + if (header.equals("GIF87a") || header.equals("GIF89a")) return "image/gif"; + } + if (b.length >= 12 && new String(b, 0, 4, StandardCharsets.US_ASCII).equals("RIFF") && + new String(b, 8, 4, StandardCharsets.US_ASCII).equals("WEBP")) return "image/webp"; + throw new InvalidImage("Paste a PNG, JPEG, GIF, or WebP image."); + } +} diff --git a/android/app/src/main/java/md/zennotes/ClipboardImageRead.java b/android/app/src/main/java/md/zennotes/ClipboardImageRead.java new file mode 100644 index 0000000..38c369b --- /dev/null +++ b/android/app/src/main/java/md/zennotes/ClipboardImageRead.java @@ -0,0 +1,45 @@ +package md.zennotes; + +import java.io.IOException; +import java.io.InputStream; + +/** Own the temporary grant even while a provider has not returned a stream. */ +final class ClipboardImageRead implements AutoCloseable { + private final Runnable releasePermission; + private InputStream stream; + private boolean closed; + + ClipboardImageRead(Runnable releasePermission) { this.releasePermission = releasePermission; } + + InputStream attach(InputStream opened) throws IOException { + synchronized (this) { + if (!closed) { stream = opened; return opened; } + } + closeStream(opened); + throw new IOException("Image paste was cancelled."); + } + + synchronized boolean isClosed() { return closed; } + + synchronized void respondIfOpen(Runnable response) { + if (!closed) response.run(); + } + + @Override public void close() { + InputStream opened; + synchronized (this) { + if (closed) return; + closed = true; + opened = stream; + stream = null; + } + // Release the grant without waiting for provider I/O to finish. + try { releasePermission.run(); } + finally { closeStream(opened); } + } + + private static void closeStream(InputStream stream) { + if (stream == null) return; + try { stream.close(); } catch (IOException | RuntimeException ignored) { /* already closed/revoked */ } + } +} diff --git a/android/app/src/main/java/md/zennotes/ImagePastePlugin.java b/android/app/src/main/java/md/zennotes/ImagePastePlugin.java new file mode 100644 index 0000000..401bb38 --- /dev/null +++ b/android/app/src/main/java/md/zennotes/ImagePastePlugin.java @@ -0,0 +1,126 @@ +package md.zennotes; + +import android.content.res.AssetFileDescriptor; +import android.os.CancellationSignal; +import android.os.Handler; +import android.os.Looper; +import android.util.Base64; +import androidx.core.view.inputmethod.InputConnectionCompat; +import androidx.core.view.inputmethod.InputContentInfoCompat; +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; +import java.io.InputStream; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; + +@CapacitorPlugin(name = "ImagePaste") +public class ImagePastePlugin extends Plugin { + private final Map pending = new HashMap<>(); + private final Handler expiry = new Handler(Looper.getMainLooper()); + private final ExecutorService reader = Executors.newSingleThreadExecutor(); + private ClipboardImageRead activeRead; + private volatile boolean destroyed; + + @Override public void load() { + ((ImagePasteWebView) getBridge().getWebView()).imageReceiver = this::receive; + } + + @PluginMethod public void setEnabled(PluginCall call) { + boolean enabled = call.getBoolean("enabled", false); + getActivity().runOnUiThread(() -> { + if (destroyed) { call.reject("Image pasting is unavailable."); return; } + ((ImagePasteWebView) getBridge().getWebView()).setImagePasteEnabled(enabled); + call.resolve(); + }); + } + + private synchronized boolean receive(InputContentInfoCompat content, int flags) { + if (destroyed || !hasListeners("image") || activeRead != null || !pending.isEmpty() || !"content".equals(content.getContentUri().getScheme())) return false; + boolean supported = false; + for (String type : ImagePasteWebView.IMAGE_TYPES) supported |= content.getDescription().hasMimeType(type); + if (!supported) return false; + try { + if ((flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) content.requestPermission(); + } catch (RuntimeException error) { return false; } + String id = UUID.randomUUID().toString(); + pending.put(id, content); + JSObject event = new JSObject(); + event.put("id", id); + notifyListeners("image", event); + expiry.postDelayed(() -> release(id), 30000); + return true; + } + + private synchronized InputContentInfoCompat take(String id) { return pending.remove(id); } + private void release(String id) { + InputContentInfoCompat content = take(id); + if (content != null) releasePermission(content); + } + private static void releasePermission(InputContentInfoCompat content) { + try { content.releasePermission(); } catch (RuntimeException ignored) { /* provider already revoked it */ } + } + + // Plugin methods run off the UI thread. Never send arbitrary URIs through + // the JS bridge: read only the short-lived token from a user paste event. + @PluginMethod public synchronized void read(PluginCall call) { + if (destroyed) { call.reject("Image pasting is unavailable."); return; } + InputContentInfoCompat content = take(call.getString("id", "")); + if (content == null) { call.reject("The pasted image expired. Please paste it again."); return; } + CancellationSignal cancellation = new CancellationSignal(); + ClipboardImageRead read = new ClipboardImageRead(() -> { + releasePermission(content); + try { cancellation.cancel(); } catch (RuntimeException ignored) { /* provider already gone */ } + }); + activeRead = read; + try { reader.execute(() -> readImage(call, content, read, cancellation)); } + catch (RejectedExecutionException error) { + activeRead = null; + read.close(); + call.reject("Image pasting is unavailable. Please reopen the note."); + } + } + private void readImage(PluginCall call, InputContentInfoCompat content, ClipboardImageRead read, CancellationSignal cancellation) { + try { + if (read.isClosed()) return; + byte[] bytes; + try (AssetFileDescriptor descriptor = getContext().getContentResolver() + .openAssetFileDescriptor(content.getContentUri(), "r", cancellation)) { + InputStream stream = read.attach(descriptor == null ? null : descriptor.createInputStream()); + bytes = ClipboardImageData.read(stream, ClipboardImageData.MAX_BYTES); + } + JSObject result = new JSObject(); + result.put("mimeType", ClipboardImageData.mimeType(bytes)); + result.put("base64", Base64.encodeToString(bytes, Base64.NO_WRAP)); + read.respondIfOpen(() -> call.resolve(result)); + } catch (ClipboardImageData.InvalidImage error) { + read.respondIfOpen(() -> call.reject(error.getMessage())); + } catch (IOException | RuntimeException error) { + read.respondIfOpen(() -> call.reject("Could not read the pasted image. Please copy it again.")); + } finally { + read.close(); + synchronized (this) { if (activeRead == read) activeRead = null; } + } + } + + @PluginMethod public void discard(PluginCall call) { + release(call.getString("id", "")); + call.resolve(); + } + + @Override protected synchronized void handleOnDestroy() { + destroyed = true; + expiry.removeCallbacksAndMessages(null); + if (activeRead != null) { activeRead.close(); activeRead = null; } + reader.shutdownNow(); + for (InputContentInfoCompat content : pending.values()) releasePermission(content); + pending.clear(); + } +} diff --git a/android/app/src/main/java/md/zennotes/ImagePasteWebView.java b/android/app/src/main/java/md/zennotes/ImagePasteWebView.java new file mode 100644 index 0000000..7c296fe --- /dev/null +++ b/android/app/src/main/java/md/zennotes/ImagePasteWebView.java @@ -0,0 +1,36 @@ +package md.zennotes; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputMethodManager; +import androidx.core.view.inputmethod.EditorInfoCompat; +import androidx.core.view.inputmethod.InputConnectionCompat; +import androidx.core.view.inputmethod.InputContentInfoCompat; +import com.getcapacitor.CapacitorWebView; + +/** Preserve Capacitor's keyboard handling and add Android's rich-content API. */ +public class ImagePasteWebView extends CapacitorWebView { + interface Receiver { boolean receive(InputContentInfoCompat content, int flags); } + static final String[] IMAGE_TYPES = {"image/png", "image/jpeg", "image/gif", "image/webp"}; + private boolean imagePasteEnabled; + Receiver imageReceiver; + + public ImagePasteWebView(Context context, AttributeSet attrs) { super(context, attrs); } + + void setImagePasteEnabled(boolean enabled) { + if (imagePasteEnabled == enabled) return; + imagePasteEnabled = enabled; + InputMethodManager ime = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + if (ime != null) ime.restartInput(this); + } + + @Override public InputConnection onCreateInputConnection(EditorInfo info) { + InputConnection connection = super.onCreateInputConnection(info); + if (connection == null || !imagePasteEnabled) return connection; + EditorInfoCompat.setContentMimeTypes(info, IMAGE_TYPES); + return InputConnectionCompat.createWrapper(connection, info, + (content, flags, options) -> imagePasteEnabled && imageReceiver != null && imageReceiver.receive(content, flags)); + } +} diff --git a/android/app/src/main/java/md/zennotes/MainActivity.java b/android/app/src/main/java/md/zennotes/MainActivity.java index ce26fff..277d35e 100644 --- a/android/app/src/main/java/md/zennotes/MainActivity.java +++ b/android/app/src/main/java/md/zennotes/MainActivity.java @@ -30,6 +30,7 @@ public void onCreate(Bundle savedInstanceState) { registerPlugin(SafFsPlugin.class); registerPlugin(DirectUploadPlugin.class); registerPlugin(WidgetBridgePlugin.class); + registerPlugin(ImagePastePlugin.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). diff --git a/android/app/src/main/res/layout/capacitor_bridge_layout_main.xml b/android/app/src/main/res/layout/capacitor_bridge_layout_main.xml new file mode 100644 index 0000000..78f64c8 --- /dev/null +++ b/android/app/src/main/res/layout/capacitor_bridge_layout_main.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/android/app/src/test/java/md/zennotes/ClipboardImageDataTest.java b/android/app/src/test/java/md/zennotes/ClipboardImageDataTest.java new file mode 100644 index 0000000..9c90d4b --- /dev/null +++ b/android/app/src/test/java/md/zennotes/ClipboardImageDataTest.java @@ -0,0 +1,25 @@ +package md.zennotes; + +import org.junit.Test; +import static org.junit.Assert.*; +import java.io.ByteArrayInputStream; +import java.io.IOException; + +public class ClipboardImageDataTest { + @Test public void boundedReadPreservesBytes() throws Exception { + byte[] data = {1, 2, 3, 4}; + assertArrayEquals(data, ClipboardImageData.read(new ByteArrayInputStream(data), 4)); + } + @Test public void overLimitAndEmptyInputFail() { + assertThrows(IOException.class, () -> ClipboardImageData.read(new ByteArrayInputStream(new byte[5]), 4)); + assertThrows(IOException.class, () -> ClipboardImageData.read(new ByteArrayInputStream(new byte[0]), 4)); + } + @Test public void checksImageSignaturesNotUntrustedMimeOrFilename() throws Exception { + assertEquals("image/png", ClipboardImageData.mimeType(new byte[]{(byte)137,80,78,71,13,10,26,10})); + assertEquals("image/jpeg", ClipboardImageData.mimeType(new byte[]{(byte)255,(byte)216,(byte)255,0})); + assertEquals("image/gif", ClipboardImageData.mimeType("GIF89a".getBytes())); + assertEquals("image/webp", ClipboardImageData.mimeType("RIFF0000WEBP".getBytes())); + assertThrows(IOException.class, () -> ClipboardImageData.mimeType("".getBytes())); + assertThrows(IOException.class, () -> ClipboardImageData.mimeType(new byte[0])); + } +} diff --git a/android/app/src/test/java/md/zennotes/ClipboardImageReadTest.java b/android/app/src/test/java/md/zennotes/ClipboardImageReadTest.java new file mode 100644 index 0000000..e37c148 --- /dev/null +++ b/android/app/src/test/java/md/zennotes/ClipboardImageReadTest.java @@ -0,0 +1,105 @@ +package md.zennotes; + +import org.junit.Test; +import static org.junit.Assert.*; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +public class ClipboardImageReadTest { + @Test public void cancelledReadCannotDispatchAResponse() { + AtomicInteger responses = new AtomicInteger(); + ClipboardImageRead read = new ClipboardImageRead(() -> {}); + read.respondIfOpen(responses::incrementAndGet); + read.close(); + read.respondIfOpen(responses::incrementAndGet); + assertEquals(1, responses.get()); + } + + @Test public void teardownCannotInterleaveWithResponseDispatch() throws Exception { + AtomicInteger releases = new AtomicInteger(); + CountDownLatch responding = new CountDownLatch(1); + CountDownLatch finishResponse = new CountDownLatch(1); + CountDownLatch closing = new CountDownLatch(1); + ClipboardImageRead read = new ClipboardImageRead(releases::incrementAndGet); + var worker = Executors.newFixedThreadPool(2); + try { + var response = worker.submit(() -> read.respondIfOpen(() -> { + responding.countDown(); + try { assertTrue(finishResponse.await(2, TimeUnit.SECONDS)); } + catch (InterruptedException error) { throw new AssertionError(error); } + assertFalse(read.isClosed()); + })); + assertTrue(responding.await(2, TimeUnit.SECONDS)); + var teardown = worker.submit(() -> { closing.countDown(); read.close(); }); + assertTrue(closing.await(2, TimeUnit.SECONDS)); + assertEquals(0, releases.get()); + finishResponse.countDown(); + response.get(2, TimeUnit.SECONDS); + teardown.get(2, TimeUnit.SECONDS); + assertEquals(1, releases.get()); + } finally { + finishResponse.countDown(); + worker.shutdownNow(); + read.close(); + } + } + + @Test public void closesStreamAndReleasesPermissionExactlyOnce() throws Exception { + AtomicInteger releases = new AtomicInteger(); + AtomicInteger closes = new AtomicInteger(); + ClipboardImageRead read = new ClipboardImageRead(releases::incrementAndGet); + read.attach(new ByteArrayInputStream(new byte[]{1}) { + @Override public void close() { closes.incrementAndGet(); } + }); + read.close(); + read.close(); + assertTrue(read.isClosed()); + assertEquals(1, releases.get()); + assertEquals(1, closes.get()); + } + + @Test public void teardownWhileProviderIsOpeningReleasesGrantAndRejectsLateStream() { + AtomicInteger releases = new AtomicInteger(); + AtomicInteger closes = new AtomicInteger(); + ClipboardImageRead read = new ClipboardImageRead(releases::incrementAndGet); + read.close(); + assertEquals(1, releases.get()); + assertThrows(IOException.class, () -> read.attach(new ByteArrayInputStream(new byte[]{1}) { + @Override public void close() { closes.incrementAndGet(); } + })); + assertEquals(1, closes.get()); + assertEquals(1, releases.get()); + } + + @Test public void teardownClosesAnInFlightReadWithoutWaitingForWorkerCompletion() throws Exception { + AtomicInteger releases = new AtomicInteger(); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch closed = new CountDownLatch(1); + ClipboardImageRead read = new ClipboardImageRead(releases::incrementAndGet); + InputStream stream = read.attach(new InputStream() { + @Override public int read() throws IOException { + started.countDown(); + try { closed.await(); } + catch (InterruptedException error) { throw new IOException(error); } + return -1; + } + @Override public void close() { closed.countDown(); } + }); + var worker = Executors.newSingleThreadExecutor(); + try { + var result = worker.submit(() -> stream.read()); + assertTrue(started.await(2, TimeUnit.SECONDS)); + read.close(); + assertEquals(1, releases.get()); + assertEquals(Integer.valueOf(-1), result.get(2, TimeUnit.SECONDS)); + } finally { + read.close(); + worker.shutdownNow(); + } + } +} diff --git a/src/bridge/remote-vault-paste.test.ts b/src/bridge/remote-vault-paste.test.ts new file mode 100644 index 0000000..57bd474 --- /dev/null +++ b/src/bridge/remote-vault-paste.test.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +async function paste(mimeType: string, suggestedName?: string) { + const uploads: Array<{ name: string; data: string; directory: string }> = [] + const changes: unknown[] = [] + const { RemoteVault } = await loadMobileModule('./src/bridge/remote-vault.ts', { + './events': { emitVaultChange: (event: unknown) => changes.push(event) } + }) + const vault = new RemoteVault({ + async uploadAsset(name: string, data: string, directory: string) { + uploads.push({ name, data, directory }) + return { name, path: `${directory}/${name}` } + } + }, null, 'paste-test') + const result = await vault.importPastedImage({ + mimeType, suggestedName, data: new Uint8Array([1, 2, 3]) + }) + return { result, uploads, changes } +} + +test('remote keyboard WebP paste preserves its format and vault asset path', async () => { + const { result, uploads, changes } = await paste('image/webp') + assert.match(uploads[0].name, /^Pasted Image .*\.webp$/) + assert.equal(uploads[0].data, 'AQID') + assert.equal(uploads[0].directory, 'assets') + assert.equal(result.markdown, `![[assets/${uploads[0].name}]]`) + assert.equal(result.kind, 'image') + assert.deepEqual(changes, [{ + kind: 'add', path: result.path, folder: 'inbox', scope: 'content' + }]) +}) + +test('remote pasted-image names cannot introduce paths or break the embed', async () => { + const { result, uploads } = await paste('image/webp', '../picture [draft].webp') + assert.equal(uploads[0].name, 'picture -draft-.webp') + assert.equal(result.markdown, '![[assets/picture -draft-.webp]]') +}) + +test('remote paste retains PNG, JPEG and GIF extensions', async () => { + for (const [mime, extension] of [ + ['image/png', '.png'], ['image/jpeg', '.jpg'], ['image/gif', '.gif'] + ]) { + const { uploads } = await paste(mime, 'clipboard') + assert.equal(uploads[0].name, `clipboard${extension}`) + } +}) diff --git a/src/bridge/remote-vault.ts b/src/bridge/remote-vault.ts index ca9446a..5ecdde7 100644 --- a/src/bridge/remote-vault.ts +++ b/src/bridge/remote-vault.ts @@ -33,6 +33,7 @@ import type { VaultTask } from '@shared/tasks' import type { CustomTemplateFile, WriteTemplateInput } from '@bridge-contract/templates' import type { ImportedAsset } from '@shared/ipc' import { createAbsenceAwareReader } from '@shared/remote-absence' +import { pastedImageFilename } from '@shared/pasted-image' import { emitVaultChange } from './events' import { importedAssetFilename } from './imported-assets' import { RemoteClient, RemoteRequestError } from './remote-client' @@ -339,9 +340,8 @@ export class RemoteVault { async importPastedImage(input: PastedImageInput): Promise { const bytes = input.data instanceof Uint8Array ? input.data : new Uint8Array(input.data as ArrayBuffer) - const ext = input.mimeType === 'image/png' ? '.png' : input.mimeType === 'image/gif' ? '.gif' : '.jpg' - const base = (input.suggestedName ?? 'Pasted image').replace(/\.[a-z0-9]+$/i, '') - const meta = await this.client.uploadAsset(`${base}${ext}`, bytesToBase64(bytes), 'assets') + const name = pastedImageFilename(input, new Date()) + const meta = await this.client.uploadAsset(name, bytesToBase64(bytes), 'assets') emitVaultChange({ kind: 'add', path: meta.path, folder: 'inbox', scope: 'content' }) return { name: meta.name, path: meta.path, markdown: `![[${meta.path}]]`, kind: 'image' } } diff --git a/src/ui-mobile/MobileShell.tsx b/src/ui-mobile/MobileShell.tsx index 24543fc..40cb1b0 100644 --- a/src/ui-mobile/MobileShell.tsx +++ b/src/ui-mobile/MobileShell.tsx @@ -46,6 +46,7 @@ import { installNoteRowGestures, NOTE_ROW_SELECTOR } from './note-row-gestures' import { NoteActionSheet } from './note-actions' import { installEditorKeyboardScroll } from './editor-keyboard-scroll' import { installEditorNativeTyping } from './editor-native-typing' +import { installImagePaste } from './image-paste' import { getStartScreen, setStartScreen, type StartScreen } from './start-screen' import { useYouTubeLiteEmbeds } from './youtube-embed-shim' import { VaultsSheet, promptNewVault } from './MobileDrawer' @@ -2930,6 +2931,7 @@ function useEditorNativeTyping(): void { } function MobileShellRoot(): React.JSX.Element { + useEffect(() => installImagePaste(), []) usePhoneLayoutBoot() useDrawerAutoClose() useSettingsMobilizer() diff --git a/src/ui-mobile/image-paste-session.test.ts b/src/ui-mobile/image-paste-session.test.ts new file mode 100644 index 0000000..0227645 --- /dev/null +++ b/src/ui-mobile/image-paste-session.test.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { runImagePaste, type PasteContext } from './image-paste-session.ts' + +function setup() { + let context: PasteContext | null = { vault: {}, editor: {}, document: {}, path: 'note.md', from: 3, to: 3 } + const events: string[] = [] + return { + events, change: (patch: Partial) => { context = { ...context!, ...patch } }, + options: { + current: () => context, + read: async () => { events.push('read'); return 'image' }, + save: async (_: PasteContext, image: string) => { events.push(`save:${image}`); return 'asset' }, + insert: (_: PasteContext, asset: string) => { events.push(`insert:${asset}`) } + } + } +} +test('keyboard paste reads, saves in the captured vault and inserts once (#54)', async () => { + const s = setup() + await runImagePaste(s.options) + assert.deepEqual(s.events, ['read', 'save:image', 'insert:asset']) +}) +for (const patch of [{ path: 'other.md' }, { vault: {} }, { editor: {} }, { document: {} }, { from: 10 }]) { + test(`note/context change during image read cancels before saving: ${Object.keys(patch)[0]}`, async () => { + const s = setup() + await assert.rejects(runImagePaste({ ...s.options, read: async () => { s.change(patch); return 'image' } }), /changed/) + assert.deepEqual(s.events, []) + }) +} +test('switching notes during storage write never inserts into the new note', async () => { + const s = setup() + await assert.rejects(runImagePaste({ ...s.options, save: async () => { s.change({ path: 'other.md' }); return 'asset' } }), /saved/) + assert.deepEqual(s.events, ['read']) +}) +test('storage failure does not insert a broken reference', async () => { + const s = setup() + await assert.rejects(runImagePaste({ ...s.options, save: async () => { throw new Error('Disk full') } }), /Disk full/) + assert.deepEqual(s.events, ['read']) +}) diff --git a/src/ui-mobile/image-paste-session.ts b/src/ui-mobile/image-paste-session.ts new file mode 100644 index 0000000..c01b351 --- /dev/null +++ b/src/ui-mobile/image-paste-session.ts @@ -0,0 +1,21 @@ +export interface PasteContext { vault: object; editor: object; document: object; path: string; from: number; to: number } +export async function runImagePaste(options: { + current: () => PasteContext | null; + read: () => Promise; + save: (context: PasteContext, image: T) => Promise; + insert: (context: PasteContext, asset: R) => void; +}): Promise { + const context = options.current() + if (!context) throw new Error('Open an editable note before pasting an image.') + const unchanged = (): boolean => { + const current = options.current() + return current !== null && current.vault === context.vault && current.editor === context.editor && + current.document === context.document && current.path === context.path && + current.from === context.from && current.to === context.to + } + const image = await options.read() + if (!unchanged()) throw new Error('The note or cursor changed. Please paste the image again.') + const asset = await options.save(context, image) + if (!unchanged()) throw new Error('The image was saved in assets, but the note changed. Insert it from the vault assets.') + options.insert(context, asset) +} diff --git a/src/ui-mobile/image-paste.ts b/src/ui-mobile/image-paste.ts new file mode 100644 index 0000000..aa5c62c --- /dev/null +++ b/src/ui-mobile/image-paste.ts @@ -0,0 +1,77 @@ +import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core' +import type { EditorView } from '@codemirror/view' +import { useStore } from '@zennotes/app-core/store' +import { formatImportedAssetsForInsertion } from '@zennotes/app-core/lib/editor-drops' +import { activeVault } from '../bridge/mobile-bridge' +import { decodeCloudSyncBase64 } from '../bridge/cloud-sync-work' +import { runImagePaste, type PasteContext } from './image-paste-session' + +const ImagePaste = registerPlugin<{ + setEnabled(options: { enabled: boolean }): Promise; + read(options: { id: string }): Promise<{ base64: string; mimeType: string }>; + discard(options: { id: string }): Promise; + addListener(event: 'image', listener: (event: { id: string }) => void): Promise; +}>('ImagePaste') + +function current(): PasteContext | null { + const state = useStore.getState() + const view = state.editorViewRef + const path = state.selectedPath + if (!state.vault || !view?.hasFocus || view.state.readOnly || !path || path.startsWith('zen://') || + !view.contentDOM.contains(document.activeElement) || document.querySelector('[role="dialog"], [data-ctx-menu]')) return null + return { vault: activeVault(), editor: view, document: view.state.doc, path, + from: view.state.selection.main.from, to: view.state.selection.main.to } +} + +export function installImagePaste(): () => void { + if (Capacitor.getPlatform() !== 'android') return () => {} + let live = true + let enabled = false + let busy = false + const sync = (): void => { + if (!live) return + // Do not restart the IME mid-paste when a store update arrives. The + // receiver's single-flight guard rejects overlapping image operations. + const next = current() !== null + if (next === enabled) return + enabled = next + void ImagePaste.setEnabled({ enabled }).catch(() => { enabled = false }) + } + const onFocus = (): void => { queueMicrotask(sync) } + const listener = ImagePaste.addListener('image', ({ id }) => { + if (!live || busy || !current()) { void ImagePaste.discard({ id }).catch(() => {}); return } + busy = true + void runImagePaste({ + current: () => live ? current() : null, + read: async () => { + const result = await ImagePaste.read({ id }) + const { bytes } = await decodeCloudSyncBase64(result.base64) + return { data: bytes.buffer, mimeType: result.mimeType, suggestedName: null } + }, + save: (context, image) => (context.vault as ReturnType).importPastedImage(image), + insert: (context, asset) => { + const view = context.editor as EditorView + const before = context.from > 0 ? view.state.doc.sliceString(context.from - 1, context.from) : '' + const after = view.state.doc.sliceString(context.to, context.to + 1) + const insert = formatImportedAssetsForInsertion([asset], before, after) + view.dispatch({ changes: { from: context.from, to: context.to, insert }, + selection: { anchor: context.from + insert.length } }) + view.focus() + } + }).catch((error) => { + if (live) window.alert(error instanceof Error ? error.message : 'Could not paste the image.') + }).finally(() => { busy = false; sync() }) + }) + void listener.then(sync).catch(() => {}) + document.addEventListener('focusin', onFocus) + document.addEventListener('focusout', onFocus) + const unsubscribe = useStore.subscribe(sync) + return () => { + live = false + unsubscribe() + document.removeEventListener('focusin', onFocus) + document.removeEventListener('focusout', onFocus) + void listener.then((handle) => handle.remove()).catch(() => {}) + void ImagePaste.setEnabled({ enabled: false }).catch(() => {}) + } +} From 1846f0c3941305b3a205c080753642bb83f7d256 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Fri, 11 Sep 2026 12:30:01 -0500 Subject: [PATCH 3/7] fix: improve Android editor touch and overlay behavior Keep the Cloud controls clear (#48), require completed wikilink taps (#50), reserve selection-toolbar space (#51), and make sheet handles draggable (#53). Add opaque editor surfaces as an unverified flicker mitigation (#49). --- src/ui-mobile/MobileDrawer.tsx | 4 ++ src/ui-mobile/MobileShell.tsx | 28 ++++---- src/ui-mobile/SheetHandle.tsx | 52 ++++++++++++++ src/ui-mobile/editor-keyboard-scroll.ts | 18 +++-- src/ui-mobile/mobile-layout.test.ts | 33 +++++++++ src/ui-mobile/mobile.css | 71 +++++++++++++++++-- src/ui-mobile/note-actions.tsx | 2 + src/ui-mobile/selection-toolbar-space.test.ts | 18 +++++ src/ui-mobile/selection-toolbar-space.ts | 51 +++++++++++++ src/ui-mobile/sheet-drag.test.ts | 13 ++++ src/ui-mobile/sheet-drag.ts | 3 + src/ui-mobile/sheet-handle.test.ts | 48 +++++++++++++ src/ui-mobile/wikilink-touch.test.ts | 66 +++++++++++++++++ src/ui-mobile/wikilink-touch.ts | 69 ++++++++++++++++++ 14 files changed, 452 insertions(+), 24 deletions(-) create mode 100644 src/ui-mobile/SheetHandle.tsx create mode 100644 src/ui-mobile/mobile-layout.test.ts create mode 100644 src/ui-mobile/selection-toolbar-space.test.ts create mode 100644 src/ui-mobile/selection-toolbar-space.ts create mode 100644 src/ui-mobile/sheet-drag.test.ts create mode 100644 src/ui-mobile/sheet-drag.ts create mode 100644 src/ui-mobile/sheet-handle.test.ts create mode 100644 src/ui-mobile/wikilink-touch.test.ts create mode 100644 src/ui-mobile/wikilink-touch.ts diff --git a/src/ui-mobile/MobileDrawer.tsx b/src/ui-mobile/MobileDrawer.tsx index 7d0d7a6..89eb767 100644 --- a/src/ui-mobile/MobileDrawer.tsx +++ b/src/ui-mobile/MobileDrawer.tsx @@ -28,6 +28,7 @@ import { usePins, toggleNotePin, toggleFolderPin } from './pins' import { archiveNote, openNoteMenu, trashNote } from './note-actions' import { refreshVault } from './refresh' import { SwipeRow } from './SwipeRow' +import { SheetHandle } from './SheetHandle' import { getStoragePref } from '../bridge/icloud' import { ICLOUD_VAULT_ROOT_PREFIX, @@ -188,6 +189,7 @@ function NewVaultSheet({ <>
+
New Vault
void }): React.JSX.Ele <>
+
Vaults
{busyLabel !== null &&

{busyLabel}

} @@ -1329,6 +1332,7 @@ function MobileDrawerBody(props: { role="presentation" />
+ setFolderMenu(null)} />
{folderMenu.name}
diff --git a/src/ui-mobile/MobileShell.tsx b/src/ui-mobile/MobileShell.tsx index 40cb1b0..7bed1d2 100644 --- a/src/ui-mobile/MobileShell.tsx +++ b/src/ui-mobile/MobileShell.tsx @@ -46,6 +46,8 @@ import { installNoteRowGestures, NOTE_ROW_SELECTOR } from './note-row-gestures' import { NoteActionSheet } from './note-actions' import { installEditorKeyboardScroll } from './editor-keyboard-scroll' import { installEditorNativeTyping } from './editor-native-typing' +import { installWikilinkTouchNavigation } from './wikilink-touch' +import { SheetHandle } from './SheetHandle' import { installImagePaste } from './image-paste' import { getStartScreen, setStartScreen, type StartScreen } from './start-screen' import { useYouTubeLiteEmbeds } from './youtube-embed-shim' @@ -295,6 +297,7 @@ function ActionSheet({ onClose }: { onClose: () => void }): React.JSX.Element { <>
+
{dbTitle ?? title}
{hasNote && (
@@ -403,6 +406,7 @@ function CreateSheet({ onClose }: { onClose: () => void }): React.JSX.Element { <>
+
Create
@@ -732,22 +736,19 @@ function openWikilinkFromTouch(target: string): void { * Tap-to-follow wikilinks in the editor. The shared extension follows links * on `mousedown`, but on iOS the preceding touch moves the CodeMirror * selection, which reveals the raw `[[...]]` source and removes the rendered - * link before any mouse event fires — so taps just placed the caret (spec 06 - * wants tap = navigate). Intercepting `touchstart` runs before CodeMirror. + * link before any mouse event fires. Remember the target at touchstart, but + * follow only after a short, stationary tap ends (#50). */ function useWikilinkTapNavigation(): void { useEffect(() => { - const onTouchStart = (e: TouchEvent): void => { - const el = (e.target as HTMLElement | null)?.closest?.('.cm-wikilink') - const target = el instanceof HTMLElement ? el.dataset.target : undefined - if (!target) return - e.preventDefault() - e.stopPropagation() - openWikilinkFromTouch(target) - } - document.addEventListener('touchstart', onTouchStart, { capture: true, passive: false }) - return () => - document.removeEventListener('touchstart', onTouchStart, { capture: true } as never) + return installWikilinkTouchNavigation(document, { + open: openWikilinkFromTouch, + hasSelection: () => { + const selection = window.getSelection() + const view = useStore.getState().editorViewRef + return Boolean((selection && !selection.isCollapsed) || (view && !view.state.selection.main.empty)) + } + }) }, []) } @@ -2291,6 +2292,7 @@ function KanbanMoveSheet(): React.JSX.Element | null { role="presentation" />
+ setState(null)} />
Move to…
diff --git a/src/ui-mobile/SheetHandle.tsx b/src/ui-mobile/SheetHandle.tsx new file mode 100644 index 0000000..60522e2 --- /dev/null +++ b/src/ui-mobile/SheetHandle.tsx @@ -0,0 +1,52 @@ +import { useRef } from 'react' +import { shouldDismissSheet } from './sheet-drag' + +/** Only the handle owns the gesture; scrolling menu content stays native. */ +export function SheetHandle({ onDismiss }: { onDismiss: () => void }): React.JSX.Element { + const drag = useRef<{ id: number; x: number; y: number; at: number; sheet: HTMLElement } | null>(null) + const moved = useRef(false) + const reset = (): void => { + const sheet = drag.current?.sheet + sheet?.style.removeProperty('--zn-sheet-drag') + sheet?.removeAttribute('data-dragging') + drag.current = null + } + return +} diff --git a/src/ui-mobile/editor-keyboard-scroll.ts b/src/ui-mobile/editor-keyboard-scroll.ts index e2b50ce..3110a56 100644 --- a/src/ui-mobile/editor-keyboard-scroll.ts +++ b/src/ui-mobile/editor-keyboard-scroll.ts @@ -30,20 +30,23 @@ import { Keyboard } from '@capacitor/keyboard' import { EditorView } from '@codemirror/view' import { StateEffect } from '@codemirror/state' import { useStore } from '@zennotes/app-core/store' +import { installSelectionToolbarSpace, selectionToolbarInset } from './selection-toolbar-space' const TOOLBAR = '.zn-editor-toolbar' /** Bottom clearance CodeMirror must keep clear: the toolbar's height while it * is mounted (it renders only while the keyboard is up over the editor). */ -function toolbarClearance(): number { +function toolbarClearance(view: EditorView): number { const bar = document.querySelector(TOOLBAR) if (!bar) return 0 - // Extra so the caret line isn't flush against the toolbar's top edge. - return Math.round(bar.getBoundingClientRect().height) + 8 + // Only reserve actual overlap. When the selection bubble has already + // shrunk the scroller, adding the keyboard bar's full height again would + // needlessly consume the small amount of visible text above it. + return selectionToolbarInset(view.scrollDOM.getBoundingClientRect(), bar.getBoundingClientRect()) } -function marginSource(): { bottom: number } | null { - const bottom = toolbarClearance() +function marginSource(view: EditorView): { bottom: number } | null { + const bottom = toolbarClearance(view) return bottom > 0 ? { bottom } : null } @@ -86,6 +89,10 @@ export function revealCaretAboveKeyboardSoon(): void { /** Wire the margin source to every editor view and the reveal to the * keyboard lifecycle. Returns the uninstaller. */ export function installEditorKeyboardScroll(): () => void { + const removeSelectionSpace = installSelectionToolbarSpace( + () => useStore.getState().editorViewRef?.dom ?? null, + revealCaretAboveKeyboard + ) const initial = useStore.getState().editorViewRef if (initial) ensureMargins(initial) const unsubscribe = useStore.subscribe((state, prev) => { @@ -103,6 +110,7 @@ export function installEditorKeyboardScroll(): () => void { } window.addEventListener('resize', onResize) return () => { + removeSelectionSpace() unsubscribe() void didShow.then((h) => h.remove()).catch(() => {}) window.removeEventListener('resize', onResize) diff --git a/src/ui-mobile/mobile-layout.test.ts b/src/ui-mobile/mobile-layout.test.ts new file mode 100644 index 0000000..52ea69a --- /dev/null +++ b/src/ui-mobile/mobile-layout.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import postcss from 'postcss' + +const css = postcss.parse(readFileSync(new URL('./mobile.css', import.meta.url), 'utf8')) +function declaration(selector: string, property: string): string | undefined { + let result: string | undefined + css.walkRules(selector, (rule) => { + rule.walkDecls(property, (decl) => { result = decl.value }) + }) + return result +} + +test('Cloud footer reserves a touch-sized row below the phone floating navigation (#48)', () => { + assert.equal(declaration('.zn-phone:has([data-cloud-sync-status])', '--zn-cloud-footer-height'), '44px') + assert.equal(declaration('.zn-phone .h-8:has([data-cloud-sync-status])', 'height'), 'var(--zn-cloud-footer-height)') + assert.equal(declaration('.zn-phone [data-cloud-sync-action]', 'min-height'), '44px') + for (const selector of ['.zn-mobile-fab', '.zn-mobile-fab-menu', '.zn-mobile-fab-hint']) { + assert.ok(declaration(selector, 'bottom')?.includes('var(--zn-cloud-footer-height, 0px)'), selector) + } +}) + +test('Android editor compositing surfaces always paint the active theme (#49)', () => { + for (const selector of ['.zn-mobile body', '.zn-mobile #root', '.zn-mobile .cm-editor']) { + assert.equal(declaration(selector, 'background'), 'rgb(var(--z-bg))', selector) + } +}) + +test('selection toolbar space shrinks the viewport instead of hiding text (#51)', () => { + assert.equal(declaration('.zn-phone .cm-editor', 'padding-bottom'), 'var(--zn-selection-clearance, 0px)') + assert.equal(declaration('.zn-phone .cm-editor', 'box-sizing'), 'border-box') +}) diff --git a/src/ui-mobile/mobile.css b/src/ui-mobile/mobile.css index 62c5757..12fcd1e 100644 --- a/src/ui-mobile/mobile.css +++ b/src/ui-mobile/mobile.css @@ -15,6 +15,18 @@ .zn-mobile body { overscroll-behavior: none; -webkit-text-size-adjust: 100%; + background: rgb(var(--z-bg)); +} + +/* Android has no desktop window vibrancy behind the editor. An opaque, + theme-colored paint surface avoids exposing the WebView's backing color + when native selection handles cause a compositing update (#49). */ +.zn-mobile #root { + background: rgb(var(--z-bg)); +} + +.zn-mobile .cm-editor { + background: rgb(var(--z-bg)) !important; } /* App Store distribution: there is no in-app updater on iOS (any width — @@ -50,7 +62,7 @@ /* Balanced against the home-indicator zone: 20px in from the right, just above the indicator — hugging the edge with a tall bottom gap reads off. */ right: calc(1.25rem + env(safe-area-inset-right)); - bottom: calc(0.75rem + env(safe-area-inset-bottom)); + bottom: calc(0.75rem + env(safe-area-inset-bottom) + var(--zn-cloud-footer-height, 0px)); z-index: 41; display: none; align-items: center; @@ -97,7 +109,7 @@ .zn-mobile-fab-menu { position: fixed; right: calc(1.25rem + env(safe-area-inset-right)); - bottom: calc(0.75rem + 54px + 0.875rem + env(safe-area-inset-bottom)); + bottom: calc(0.75rem + 54px + 0.875rem + env(safe-area-inset-bottom) + var(--zn-cloud-footer-height, 0px)); z-index: 41; display: none; flex-direction: column; @@ -142,7 +154,7 @@ .zn-mobile-fab-hint { position: fixed; right: calc(1.25rem + env(safe-area-inset-right)); - bottom: calc(0.75rem + 54px + 0.875rem + env(safe-area-inset-bottom)); + bottom: calc(0.75rem + 54px + 0.875rem + env(safe-area-inset-bottom) + var(--zn-cloud-footer-height, 0px)); z-index: 41; display: none; flex-direction: column; @@ -237,16 +249,40 @@ box-shadow: 0 -18px 50px -20px rgb(0 0 0 / 0.6); padding: 0.5rem 0.875rem calc(0.75rem + env(safe-area-inset-bottom)); animation: zn-sheet-up 0.26s cubic-bezier(0.32, 0.72, 0.22, 1); + translate: 0 var(--zn-sheet-drag, 0px); + transition: translate 180ms ease-out; } -.zn-mobile-sheet::before { - content: ''; +.zn-mobile-sheet[data-dragging] { + transition: none; +} + +.zn-mobile-sheet-handle { align-self: center; + flex: 0 0 44px; + width: 80px; + display: grid; + place-items: center; + border: 0; + background: transparent; + touch-action: none; + cursor: grab; +} + +.zn-mobile-sheet-handle:focus-visible { + outline: 2px solid rgb(var(--z-accent)); + border-radius: 8px; +} + +.zn-mobile-sheet-handle span { width: 2.25rem; height: 0.3125rem; border-radius: 9999px; background: rgb(var(--z-grey-dim) / 0.55); - margin: 0.25rem 0 0.625rem; +} + +@media (prefers-reduced-motion: reduce) { + .zn-mobile-sheet { animation: none; transition: none; } } .zn-mobile-sheet-title { @@ -619,6 +655,21 @@ display: none; } +/* Cloud status is useful on phones; keep Connect/Retry/Review accessible. + The shared footer now uses responsive padding, so the old word-count + selector above no longer hides it. Reserve one row below the FAB (#48). */ +.zn-phone:has([data-cloud-sync-status]) { + --zn-cloud-footer-height: 44px; +} + +.zn-phone .h-8:has([data-cloud-sync-status]) { + height: var(--zn-cloud-footer-height); +} + +.zn-phone [data-cloud-sync-action] { + min-height: 44px; +} + /* Split view has no meaning on a phone. */ .zn-mobile.zn-phone button[aria-label^='Split mode'] { display: none; @@ -2116,6 +2167,14 @@ max-width: calc(100vw - 16px); } +/* The selection bubble must have its own space, including while native + selection handles are dragged (#51). This shrinks the flex scroller, not + merely its scrollable content. The shell measures the live bubble bounds. */ +.zn-phone .cm-editor { + box-sizing: border-box; + padding-bottom: var(--zn-selection-clearance, 0px); +} + /* Non-resizing keyboard mode (tablets — see wireKeyboard): the viewport keeps its full height, so lift the toolbar above the keyboard frame. */ .zn-kb-noresize .zn-editor-toolbar { diff --git a/src/ui-mobile/note-actions.tsx b/src/ui-mobile/note-actions.tsx index e409dee..a4aa6bc 100644 --- a/src/ui-mobile/note-actions.tsx +++ b/src/ui-mobile/note-actions.tsx @@ -24,6 +24,7 @@ import { confirmApp } from '@zennotes/app-core/lib/confirm-requests' import { promptApp } from '@zennotes/app-core/lib/prompt-requests' import { buildMoveNotePrompt, parseMoveNoteTarget } from '@zennotes/app-core/lib/move-note' import { getPinnedNotes, toggleNotePin, usePins } from './pins' +import { SheetHandle } from './SheetHandle' export type NoteRowKind = 'note' | 'archived' | 'trashed' @@ -242,6 +243,7 @@ export function NoteActionSheet(): React.JSX.Element | null { under the sheet, and the list views' keyboard shortcuts must not fire through it — exactly what the marker gates for its own menus. */}
+
{target.title}
diff --git a/src/ui-mobile/selection-toolbar-space.test.ts b/src/ui-mobile/selection-toolbar-space.test.ts new file mode 100644 index 0000000..62c9bd9 --- /dev/null +++ b/src/ui-mobile/selection-toolbar-space.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { selectionToolbarInset } from './selection-toolbar-space.ts' + +const editor = { top: 60, bottom: 440, left: 0, right: 390 } +test('selection menu gets real editor space, not just scroll padding (#51)', () => { + assert.equal(selectionToolbarInset(editor, { top: 290, bottom: 410, left: 61, right: 329 }), 158) +}) +test('selection clearance follows keyboard resize and menu growth', () => { + assert.equal(selectionToolbarInset({ ...editor, bottom: 340 }, { top: 190, bottom: 310, left: 61, right: 329 }), 158) + assert.equal(selectionToolbarInset(editor, { top: 250, bottom: 410, left: 61, right: 329 }), 198) +}) +test('hidden, external, or nonoverlapping menus reserve no space', () => { + assert.equal(selectionToolbarInset(editor, null), 0) + assert.equal(selectionToolbarInset(editor, { top: 450, bottom: 570, left: 61, right: 329 }), 0) + assert.equal(selectionToolbarInset(editor, { top: 290, bottom: 410, left: 400, right: 668 }), 0) + assert.equal(selectionToolbarInset({ ...editor, bottom: 280 }, { top: 290, bottom: 410, left: 61, right: 329 }), 0) +}) diff --git a/src/ui-mobile/selection-toolbar-space.ts b/src/ui-mobile/selection-toolbar-space.ts new file mode 100644 index 0000000..eca2b43 --- /dev/null +++ b/src/ui-mobile/selection-toolbar-space.ts @@ -0,0 +1,51 @@ +type Rect = Pick + +export function selectionToolbarInset(editor: Rect, toolbar: Rect | null): number { + if (!toolbar || toolbar.top >= editor.bottom || toolbar.bottom <= editor.top || + toolbar.left >= editor.right || toolbar.right <= editor.left) return 0 + return Math.ceil(Math.min(editor.bottom - editor.top, editor.bottom - toolbar.top + 8)) +} + +/** Reserve space in the flex editor itself. Padding inside its scroller is + * not sufficient: native selection handles can still enter an overlaid area. + * Measure the editor's border box, which stays stable as its scroller shrinks. */ +export function installSelectionToolbarSpace(getEditor: () => HTMLElement | null, reveal: () => void): () => void { + let editor: HTMLElement | null = null + let toolbar: HTMLElement | null = null + let frame = 0 + const schedule = (): void => { if (!frame) frame = requestAnimationFrame(update) } + const sizes = new ResizeObserver(schedule) + function update(): void { + frame = 0 + const nextEditor = getEditor() + const nextToolbar = document.documentElement.classList.contains('zn-phone') + ? document.querySelector('[data-selection-toolbar]') : null + if (editor !== nextEditor || toolbar !== nextToolbar) { + sizes.disconnect() + editor?.style.removeProperty('--zn-selection-clearance') + editor = nextEditor + toolbar = nextToolbar + if (editor) sizes.observe(editor) + if (toolbar) sizes.observe(toolbar) + } + if (!editor) return + const inset = selectionToolbarInset(editor.getBoundingClientRect(), toolbar?.getBoundingClientRect() ?? null) + const value = `${inset}px` + if (editor.style.getPropertyValue('--zn-selection-clearance') !== value) { + editor.style.setProperty('--zn-selection-clearance', value) + reveal() + } + } + const mutations = new MutationObserver(schedule) + mutations.observe(document.body, { childList: true, subtree: true }) + mutations.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) + window.addEventListener('resize', schedule) + update() + return () => { + cancelAnimationFrame(frame) + sizes.disconnect() + mutations.disconnect() + window.removeEventListener('resize', schedule) + editor?.style.removeProperty('--zn-selection-clearance') + } +} diff --git a/src/ui-mobile/sheet-drag.test.ts b/src/ui-mobile/sheet-drag.test.ts new file mode 100644 index 0000000..922666b --- /dev/null +++ b/src/ui-mobile/sheet-drag.test.ts @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { shouldDismissSheet } from './sheet-drag.ts' + +test('dragging the handle down dismisses its sheet (#53)', () => { + assert.equal(shouldDismissSheet(4, 90, 500), true) + assert.equal(shouldDismissSheet(2, 30, 35), true) +}) +test('a tap, short drag, horizontal swipe or upward drag keeps the sheet open', () => { + for (const [x, y, ms] of [[0, 0, 30], [0, 20, 500], [180, 90, 500], [0, -100, 200]]) { + assert.equal(shouldDismissSheet(x, y, ms), false) + } +}) diff --git a/src/ui-mobile/sheet-drag.ts b/src/ui-mobile/sheet-drag.ts new file mode 100644 index 0000000..fbd2a5f --- /dev/null +++ b/src/ui-mobile/sheet-drag.ts @@ -0,0 +1,3 @@ +export function shouldDismissSheet(dx: number, dy: number, elapsed: number): boolean { + return dy > Math.abs(dx) && (dy >= 72 || (dy >= 24 && dy / Math.max(1, elapsed) >= 0.6)) +} diff --git a/src/ui-mobile/sheet-handle.test.ts b/src/ui-mobile/sheet-handle.test.ts new file mode 100644 index 0000000..ad278ee --- /dev/null +++ b/src/ui-mobile/sheet-handle.test.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +const { SheetHandle } = await loadMobileModule('./src/ui-mobile/SheetHandle.tsx', { + react: { useRef: (current: unknown) => ({ current }) }, + 'react/jsx-runtime': { jsx: (type: unknown, props: unknown) => ({ type, props }) } +}) + +function setup() { + let dismissed = 0 + const style = new Map() + const attributes = new Set() + const sheet = { style: { setProperty: (k: string, v: string) => style.set(k, v), removeProperty: (k: string) => style.delete(k) }, + setAttribute: (k: string) => attributes.add(k), removeAttribute: (k: string) => attributes.delete(k) } + const target = { closest: () => sheet, setPointerCapture: () => {} } + const handle = SheetHandle({ onDismiss: () => { dismissed++ } }).props + const event = (x: number, y: number, timeStamp: number) => ({ + isPrimary: true, button: 0, pointerId: 1, clientX: x, clientY: y, timeStamp, currentTarget: target + }) + return { handle, event, style, attributes, dismissals: () => dismissed } +} +test('real sheet handle handlers move the sheet and dismiss exactly once', () => { + const s = setup() + s.handle.onPointerDown(s.event(50, 10, 0)) + s.handle.onPointerMove(s.event(50, 110, 500)) + assert.equal(s.style.get('--zn-sheet-drag'), '100px') + assert.equal(s.attributes.has('data-dragging'), true) + s.handle.onPointerUp(s.event(50, 110, 600)) + s.handle.onClick({ detail: 1 }) + assert.equal(s.dismissals(), 1) + assert.equal(s.style.has('--zn-sheet-drag'), false) + assert.equal(s.attributes.has('data-dragging'), false) +}) +test('short/cancelled drags snap back and do not become close-button clicks', () => { + for (const cancel of [true, false]) { + const s = setup() + s.handle.onPointerDown(s.event(50, 10, 0)) + s.handle.onPointerMove(s.event(50, 30, 500)) + if (cancel) s.handle.onPointerCancel() + else s.handle.onPointerUp(s.event(50, 30, 600)) + s.handle.onClick({ detail: 1 }) + assert.equal(s.dismissals(), 0) + assert.equal(s.style.has('--zn-sheet-drag'), false) + s.handle.onClick({ detail: 0 }) + assert.equal(s.dismissals(), 1, 'keyboard activation still closes') + } +}) diff --git a/src/ui-mobile/wikilink-touch.test.ts b/src/ui-mobile/wikilink-touch.test.ts new file mode 100644 index 0000000..0c539bf --- /dev/null +++ b/src/ui-mobile/wikilink-touch.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { installWikilinkTouchNavigation } from './wikilink-touch.ts' + +function setup() { + const target = new EventTarget() + let time = 0 + let selected = false + const opened: string[] = [] + const uninstall = installWikilinkTouchNavigation(target as unknown as Document, { + hasSelection: () => selected, + open: (link) => opened.push(link), + now: () => time + }) + const element = { closest: () => ({ dataset: { target: 'A note' } }) } + const send = (type: string, x = 20, y = 20, fingers = 1): Event => { + const event = new Event(type, { cancelable: true }) + Object.defineProperties(event, { + target: { value: element }, + touches: { value: Array.from({ length: type === 'touchend' ? 0 : fingers }, (_, i) => ({ identifier: i, clientX: x, clientY: y })) }, + changedTouches: { value: [{ identifier: 0, clientX: x, clientY: y }] } + }) + target.dispatchEvent(event) + return event + } + return { opened, send, uninstall, tick: (ms: number) => { time += ms }, select: () => { selected = true } } +} + +test('a wikilink opens only after a completed short tap (#50)', () => { + const s = setup() + s.send('touchstart') + assert.deepEqual(s.opened, []) + s.tick(100) + assert.equal(s.send('touchend').defaultPrevented, true) + assert.deepEqual(s.opened, ['A note']) + s.uninstall() +}) + +for (const action of ['drag', 'long drag', 'long press', 'selection', 'existing selection', 'scroll', 'cancel', 'multitouch']) { + test(`a ${action} does not activate a wikilink or its synthesized mouse event`, () => { + const s = setup() + if (action === 'existing selection') s.select() + s.send('touchstart') + if (action === 'drag') s.send('touchmove', 20, 70) + if (action === 'long drag') { s.send('scroll'); s.tick(2000) } + if (action === 'long press') s.tick(600) + if (action === 'selection') s.select() + if (action === 'scroll') s.send('scroll') + if (action === 'cancel') s.send('touchcancel') + if (action === 'multitouch') s.send('touchstart', 20, 20, 2) + s.send('touchend') + assert.deepEqual(s.opened, []) + assert.equal(s.send('mousedown').defaultPrevented, true) + s.tick(900) + assert.equal(s.send('mousedown').defaultPrevented, false) + s.uninstall() + }) +} + +test('cleanup removes gesture listeners', () => { + const s = setup() + s.uninstall() + s.send('touchstart') + s.send('touchend') + assert.deepEqual(s.opened, []) +}) diff --git a/src/ui-mobile/wikilink-touch.ts b/src/ui-mobile/wikilink-touch.ts new file mode 100644 index 0000000..b9aed2c --- /dev/null +++ b/src/ui-mobile/wikilink-touch.ts @@ -0,0 +1,69 @@ +type Options = { hasSelection: () => boolean; open: (target: string) => void; now?: () => number } + +export function installWikilinkTouchNavigation(root: Document, options: Options): () => void { + const now = options.now ?? Date.now + let tap: { target: string; id: number; x: number; y: number; at: number } | null = null + let suppressMouseUntil = 0 + let touchingLink = false + const linkAt = (event: Event): HTMLElement | null => + (event.target as HTMLElement | null)?.closest?.('.cm-wikilink') ?? null + const cancel = (): void => { tap = null } + const start = (event: TouchEvent): void => { + tap = null + const target = linkAt(event)?.dataset.target + if (event.touches.length === 1) touchingLink = Boolean(target) + else touchingLink ||= Boolean(target) + if (!target) return + suppressMouseUntil = now() + 800 + if (event.touches.length !== 1 || options.hasSelection()) return + const touch = event.touches[0] + tap = { target, id: touch.identifier, x: touch.clientX, y: touch.clientY, at: now() } + // Do not cancel touchstart: native scrolling and long-press selection + // must be able to take ownership. Save the target before CM reveals its + // Markdown source (and removes the link element). + } + const move = (event: TouchEvent): void => { + if (!tap) return + const touch = Array.from(event.touches).find((touch) => touch.identifier === tap?.id) + if (event.touches.length !== 1 || !touch || options.hasSelection() || + Math.hypot(touch.clientX - tap.x, touch.clientY - tap.y) > 10) cancel() + } + const end = (event: TouchEvent): void => { + if (touchingLink) suppressMouseUntil = now() + 800 + if (event.touches.length === 0) touchingLink = false + const candidate = tap + tap = null + if (!candidate || event.touches.length !== 0 || options.hasSelection()) return + const touch = Array.from(event.changedTouches).find((touch) => touch.identifier === candidate.id) + if (!touch || now() - candidate.at > 350 || + Math.hypot(touch.clientX - candidate.x, touch.clientY - candidate.y) > 10) return + event.preventDefault() + event.stopPropagation() + options.open(candidate.target) + } + const mouse = (event: MouseEvent): void => { + // The desktop extension follows mousedown. Never let a touch's delayed + // compatibility event follow a link that we rejected as a drag/selection. + if (now() >= suppressMouseUntil || !linkAt(event)) return + const capabilities = (event as MouseEvent & { sourceCapabilities?: { firesTouchEvents: boolean } }).sourceCapabilities + if (capabilities?.firesTouchEvents === false) return + event.preventDefault() + event.stopImmediatePropagation() + } + root.addEventListener('touchstart', start, { capture: true, passive: true }) + root.addEventListener('touchmove', move, { capture: true, passive: true }) + root.addEventListener('touchend', end, { capture: true, passive: false }) + root.addEventListener('touchcancel', cancel, { capture: true }) + root.addEventListener('scroll', cancel, { capture: true }) + root.addEventListener('mousedown', mouse, { capture: true }) + root.addEventListener('click', mouse, { capture: true }) + return () => { + root.removeEventListener('touchstart', start, { capture: true }) + root.removeEventListener('touchmove', move, { capture: true }) + root.removeEventListener('touchend', end, { capture: true }) + root.removeEventListener('touchcancel', cancel, { capture: true }) + root.removeEventListener('scroll', cancel, { capture: true }) + root.removeEventListener('mousedown', mouse, { capture: true }) + root.removeEventListener('click', mouse, { capture: true }) + } +} From 7c945b1ad717e961135557b39c71085f0464946e Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Fri, 11 Sep 2026 12:30:08 -0500 Subject: [PATCH 4/7] fix: unify Android launch splash handling (#55) Install the AndroidX compat splash before Capacitor switches theme, preserve a square icon canvas, and avoid a duplicate plugin splash. Native launch comparisons remain pending. --- .../main/java/md/zennotes/MainActivity.java | 7 +++++++ .../src/main/res/drawable/zn_splash_icon.xml | 15 +++++++++++++ android/app/src/main/res/values/styles.xml | 5 +++-- capacitor.config.ts | 3 ++- src/ui-mobile/splash-config.test.ts | 21 +++++++++++++++++++ 5 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 android/app/src/main/res/drawable/zn_splash_icon.xml create mode 100644 src/ui-mobile/splash-config.test.ts diff --git a/android/app/src/main/java/md/zennotes/MainActivity.java b/android/app/src/main/java/md/zennotes/MainActivity.java index 277d35e..1f805e1 100644 --- a/android/app/src/main/java/md/zennotes/MainActivity.java +++ b/android/app/src/main/java/md/zennotes/MainActivity.java @@ -4,11 +4,13 @@ import android.content.pm.PackageInfo; import android.os.Build; import android.os.Bundle; +import android.os.SystemClock; import android.view.WindowManager; import android.webkit.WebView; import androidx.core.view.WindowCompat; import androidx.core.view.WindowInsetsControllerCompat; +import androidx.core.splashscreen.SplashScreen; import android.webkit.WebResourceRequest; import android.webkit.WebResourceResponse; @@ -24,6 +26,11 @@ public class MainActivity extends BridgeActivity { @Override public void onCreate(Bundle savedInstanceState) { + // Install while the launch theme is still active, before Capacitor + // replaces it. One compat path for both pre-12 and modern Android. + long splashUntil = SystemClock.uptimeMillis() + 400; + SplashScreen splash = SplashScreen.installSplashScreen(this); + splash.setKeepOnScreenCondition(() -> SystemClock.uptimeMillis() < splashUntil); // App-local plugins must be registered before the bridge loads. registerPlugin(ShareInboxPlugin.class); registerPlugin(FolderPickerPlugin.class); diff --git a/android/app/src/main/res/drawable/zn_splash_icon.xml b/android/app/src/main/res/drawable/zn_splash_icon.xml new file mode 100644 index 0000000..d7885ad --- /dev/null +++ b/android/app/src/main/res/drawable/zn_splash_icon.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index af9a126..85ec9c8 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -22,8 +22,9 @@