diff --git a/README.md b/README.md index e51fdce..5d40f7e 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ and `@zennotes/shared-domain` packages. The exact archives are vendored under `vendor/zennotes/` with their source identity and checksums (`manifest.json`), and `package-lock.json` pins the complete install. No source checkout is used. The vendored set is the core release -[core-2.76.0-core.h076cc71aa1ce0bbe](https://github.com/ZenNotes/zennotes/releases/tag/core-2.76.0-core.h076cc71aa1ce0bbe) -(desktop commit `61628218`, clean tree). Run `npm run +[core-2.77.0-core.hfe38f5ee2a70bd64](https://github.com/ZenNotes/zennotes/releases/tag/core-2.77.0-core.hfe38f5ee2a70bd64) +(desktop commit `3f0356de`, clean tree). Run `npm run boundaries:check` to verify archives, installed versions, singleton editor/React peers, and imports; it refuses an archive built from a dirty upstream tree unless `ZEN_ALLOW_DIRTY_CORE=1` is set for a local try-out. diff --git a/android/app/build.gradle b/android/app/build.gradle index 6ed5e8f..847b83a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -15,8 +15,8 @@ android { applicationId "md.zennotes" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 49 - versionName "1.1.46" + versionCode 50 + versionName "1.1.47" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/android/app/src/main/java/md/zennotes/DirectUploadPlugin.java b/android/app/src/main/java/md/zennotes/DirectUploadPlugin.java index 6e98da6..dc18e8e 100644 --- a/android/app/src/main/java/md/zennotes/DirectUploadPlugin.java +++ b/android/app/src/main/java/md/zennotes/DirectUploadPlugin.java @@ -37,8 +37,12 @@ */ @CapacitorPlugin(name = "ZenDirectUpload") public class DirectUploadPlugin extends Plugin { - private static final int CONNECT_TIMEOUT_MS = 30_000; - private static final int READ_TIMEOUT_MS = 300_000; + // The phones' stall bounds (shared-domain cloud-sync-stall.ts). The read + // timeout starts over with every byte that arrives, so it is how long a + // transfer may go silent, not how long a large one may take; JavaScript + // runs a transfer that went silent again. + private static final int CONNECT_TIMEOUT_MS = 15_000; + private static final int STALL_TIMEOUT_MS = 30_000; private static final int BUFFER_SIZE = 64 * 1024; private static final int HEADER_READ_LIMIT = 64 * 1024; @@ -117,7 +121,7 @@ public void download(PluginCall call) { connection.setRequestMethod("GET"); connection.setInstanceFollowRedirects(false); connection.setConnectTimeout(CONNECT_TIMEOUT_MS); - connection.setReadTimeout(READ_TIMEOUT_MS); + connection.setReadTimeout(STALL_TIMEOUT_MS); connection.setRequestProperty("Accept-Encoding", "identity"); applyHeaders(connection, headers); int status = connection.getResponseCode(); @@ -272,6 +276,7 @@ public void put(PluginCall call) { private void upload(PluginCall call) { HttpURLConnection connection = null; + UploadStallWatchdog watchdog = null; try { String value = call.getString("url"); String base64 = call.getString("base64"); @@ -291,15 +296,16 @@ private void upload(PluginCall call) { connection.setRequestMethod("PUT"); connection.setInstanceFollowRedirects(false); connection.setConnectTimeout(CONNECT_TIMEOUT_MS); - connection.setReadTimeout(READ_TIMEOUT_MS); + connection.setReadTimeout(STALL_TIMEOUT_MS); connection.setDoOutput(true); connection.setFixedLengthStreamingMode(expectedBytes); applyHeaders(connection, headers); + watchdog = new UploadStallWatchdog(connection, STALL_TIMEOUT_MS); try ( InputStream decoded = sourceUri != null ? openVaultFile(sourceUri) : new Base64InputStream( new ByteArrayInputStream(base64.getBytes(StandardCharsets.US_ASCII)), Base64.DEFAULT); - OutputStream output = connection.getOutputStream() + OutputStream output = watchdog.watch(connection.getOutputStream()) ) { if (expectedHash != null) { CloudFileStream.copyVerified(decoded, output, expectedBytes, expectedHash); @@ -315,6 +321,8 @@ private void upload(PluginCall call) { output.flush(); } } + // The body is out; the read timeout bounds the wait for the answer. + watchdog.close(); int status = connection.getResponseCode(); drain(status >= 400 ? connection.getErrorStream() : connection.getInputStream()); @@ -324,6 +332,7 @@ private void upload(PluginCall call) { } catch (Exception error) { call.reject("Cloud object upload failed.", "DIRECT_UPLOAD_FAILED", error); } finally { + if (watchdog != null) watchdog.close(); if (connection != null) connection.disconnect(); } } diff --git a/android/app/src/main/java/md/zennotes/UploadStallWatchdog.java b/android/app/src/main/java/md/zennotes/UploadStallWatchdog.java new file mode 100644 index 0000000..f9add64 --- /dev/null +++ b/android/app/src/main/java/md/zennotes/UploadStallWatchdog.java @@ -0,0 +1,72 @@ +package md.zennotes; + +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +/** + * Drops an upload whose bytes stopped leaving. + * + * Android's HttpURLConnection has a read timeout but no write timeout, so a + * PUT on a connection that went quiet (a filtering network can hold one open + * and pass nothing) blocks in write until the kernel gives up, many minutes + * later. This watches the bytes the upload hands to the connection and + * disconnects once none moved for the stall bound, which turns the blocked + * write into an IOException. Writes go out in small slices, so a slow link + * that still moves a few hundred bytes a second never looks stalled. + */ +final class UploadStallWatchdog implements AutoCloseable { + private static final int SLICE_BYTES = 8 * 1024; + private static final ScheduledExecutorService TIMER = Executors.newSingleThreadScheduledExecutor(runnable -> { + Thread thread = new Thread(runnable, "zen-upload-watchdog"); + thread.setDaemon(true); + return thread; + }); + + private final HttpURLConnection connection; + private final long stallNanos; + private volatile long lastProgress = System.nanoTime(); + private volatile ScheduledFuture check; + + UploadStallWatchdog(HttpURLConnection connection, int stallMs) { + this.connection = connection; + this.stallNanos = TimeUnit.MILLISECONDS.toNanos(stallMs); + long period = Math.max(250, stallMs / 10); + this.check = TIMER.scheduleWithFixedDelay(this::check, period, period, TimeUnit.MILLISECONDS); + } + + OutputStream watch(OutputStream output) { + return new FilterOutputStream(output) { + @Override + public void write(int value) throws IOException { + out.write(value); + lastProgress = System.nanoTime(); + } + + @Override + public void write(byte[] bytes, int offset, int length) throws IOException { + for (int end = offset + length; offset < end; offset += SLICE_BYTES) { + out.write(bytes, offset, Math.min(SLICE_BYTES, end - offset)); + lastProgress = System.nanoTime(); + } + } + }; + } + + private void check() { + if (System.nanoTime() - lastProgress < stallNanos) return; + close(); + connection.disconnect(); + } + + @Override + public void close() { + ScheduledFuture scheduled = check; + if (scheduled != null) scheduled.cancel(false); + } +} diff --git a/package-lock.json b/package-lock.json index 1b845e6..6c6214b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-android", - "version": "1.1.46", + "version": "1.1.47", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-android", - "version": "1.1.46", + "version": "1.1.47", "dependencies": { "@aparajita/capacitor-secure-storage": "^8.0.1", "@capacitor/android": "^8.5.3", @@ -32,9 +32,9 @@ "@lezer/highlight": "^1.2.5", "@replit/codemirror-vim": "^6.3.0", "@xyflow/react": "^12.12.0", - "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.76.0-core.h076cc71aa1ce0bbe.tgz", - "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.76.0-boundaries.he867787f4c1f2ebc.tgz", - "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.76.0-boundaries.he867787f4c1f2ebc.tgz", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.77.0-core.hfe38f5ee2a70bd64.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz", "codemirror": "^6.0.1", "dompurify": "^3.4.17", "function-plot": "^1.26.0", @@ -3668,9 +3668,9 @@ } }, "node_modules/@zennotes/app-core": { - "version": "2.76.0-core.h076cc71aa1ce0bbe", - "resolved": "file:vendor/zennotes/zennotes-app-core-2.76.0-core.h076cc71aa1ce0bbe.tgz", - "integrity": "sha512-z1j0s1yszCUjjnaw0zvVAF3tYUjj4WqDlbVbdswRABF8OmaSI15YjOpIwjnopi2YgsqQ+z7aq3DLg31MOYMMbQ==", + "version": "2.77.0-core.hfe38f5ee2a70bd64", + "resolved": "file:vendor/zennotes/zennotes-app-core-2.77.0-core.hfe38f5ee2a70bd64.tgz", + "integrity": "sha512-wZNZS/WkLLqi9DzWlKo5FYPl6Dpkk+C/viiiu7rhsryZARIrQodj5LDa9gTx3TUsIiDKFSg5XoP73ShmLuYsVA==", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -3700,8 +3700,8 @@ "@replit/codemirror-vim": "^6.3.0", "@terrastruct/d2": "0.1.33", "@xyflow/react": "^12.11.2", - "@zennotes/bridge-contract": "2.76.0-boundaries.he867787f4c1f2ebc", - "@zennotes/shared-domain": "2.76.0-boundaries.he867787f4c1f2ebc", + "@zennotes/bridge-contract": "2.77.0-boundaries.h3ebf6d85eb9ac49b", + "@zennotes/shared-domain": "2.77.0-boundaries.h3ebf6d85eb9ac49b", "dompurify": "^3.3.4", "function-plot": "^1.25.3", "gray-matter": "^4.0.3", @@ -3745,19 +3745,19 @@ } }, "node_modules/@zennotes/bridge-contract": { - "version": "2.76.0-boundaries.he867787f4c1f2ebc", - "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.76.0-boundaries.he867787f4c1f2ebc.tgz", - "integrity": "sha512-mPHMyDU5lKdbv1dZzcmSN6sqlxgiO+xcULirLg+vqjk4sd1LevLv6fy7mFuXNEP3PeE17wA9DAAElzOR2D05Lw==", + "version": "2.77.0-boundaries.h3ebf6d85eb9ac49b", + "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz", + "integrity": "sha512-wMp1ytIf4U1v4ngHlJ7CegQfQPC1OhyQQXoM4O+tPXmaTWYIUsPHVhgs+i7sptraWQJJZ636Ybn27jDQHTIQ6Q==", "license": "MIT" }, "node_modules/@zennotes/shared-domain": { - "version": "2.76.0-boundaries.he867787f4c1f2ebc", - "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.76.0-boundaries.he867787f4c1f2ebc.tgz", - "integrity": "sha512-ebanh7nYqtwwf+WFy8fzE8+IE+RI9biRW7ph7KJ8e57m/lz+yT7BrN1esI+Ei18INs/XB2zrZKJXXkLFtO2dHA==", + "version": "2.77.0-boundaries.h3ebf6d85eb9ac49b", + "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz", + "integrity": "sha512-x52yLX/DGx2tNN+3F5TBakphOnLq5TtXIhFM5prS1pp49i6IacXRo+ZZ9wmLr54zm4rqL6x2mfSbd4Dk3+fbJw==", "license": "MIT", "dependencies": { "@scure/bip39": "2.4.0", - "@zennotes/bridge-contract": "2.76.0-boundaries.he867787f4c1f2ebc", + "@zennotes/bridge-contract": "2.77.0-boundaries.h3ebf6d85eb9ac49b", "hash-wasm": "4.12.0", "lz-string": "^1.5.0" } diff --git a/package.json b/package.json index 07276dc..e75f1bc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-android", "private": true, - "version": "1.1.46", + "version": "1.1.47", "type": "module", "description": "ZenNotes for Android — Capacitor shell over the ZenNotes app core", "homepage": "https://zennotes.org", @@ -43,9 +43,9 @@ "@lezer/highlight": "^1.2.5", "@replit/codemirror-vim": "^6.3.0", "@xyflow/react": "^12.12.0", - "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.76.0-core.h076cc71aa1ce0bbe.tgz", - "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.76.0-boundaries.he867787f4c1f2ebc.tgz", - "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.76.0-boundaries.he867787f4c1f2ebc.tgz", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.77.0-core.hfe38f5ee2a70bd64.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz", "codemirror": "^6.0.1", "dompurify": "^3.4.17", "function-plot": "^1.26.0", diff --git a/src/bridge/cloud-sync-client.test.ts b/src/bridge/cloud-sync-client.test.ts index e9e2f44..50aa3a0 100644 --- a/src/bridge/cloud-sync-client.test.ts +++ b/src/bridge/cloud-sync-client.test.ts @@ -242,3 +242,111 @@ it('sends an encrypted vault\'s file direct once its ciphertext passes the inlin assert.equal(puts[0].sha256, createHash('sha256').update(sealed).digest('hex')) assert.equal(sealed.subarray(0, 4).toString('latin1'), 'ZNE1') }) + +it('lets a request go 30 s without a byte and a connection 15 s to open; only a publication waits longer', async () => { + const options: Array<{ connectTimeout?: number; readTimeout?: number }> = [] + const { createCloudSyncClient } = await loadMobileModule('./src/bridge/cloud-sync-client.ts', { + '@capacitor/core': { + registerPlugin: () => ({}), + CapacitorHttp: { request: async (request: { connectTimeout?: number; readTimeout?: number }) => { + options.push(request) + return { status: 200, data: { data: [], id: 1, slug: 'note', url: 'https://example.test/s/note' } } + } } + } + }) + const client = createCloudSyncClient('https://example.test', 'test-only', { accountId: 'account' }) + await client.listVaults() + await client.publishNote({ note_path: 'Note.md', title: 'Note', markdown: 'Text' }) + // Android's read timeout starts over with every byte, so 30 s is how long a + // request may go silent; a big page on a slow link takes as long as it needs. + assert.deepEqual(options.map(({ connectTimeout, readTimeout }) => ({ connectTimeout, readTimeout })), [ + { connectTimeout: 15_000, readTimeout: 30_000 }, + { connectTimeout: 15_000, readTimeout: 300_000 } + ]) +}) + +it('sends a read that went silent again instead of failing the sync', async (t) => { + t.mock.method(Math, 'random', () => 0) + const paths: string[] = [] + const { createCloudSyncClient } = await loadMobileModule('./src/bridge/cloud-sync-client.ts', { + '@capacitor/core': { + registerPlugin: () => ({}), + CapacitorHttp: { request: async ({ url }: { url: string }) => { + paths.push(new URL(url).pathname) + if (paths.length === 1) throw Object.assign(new Error('timeout'), { code: 'SocketTimeoutException' }) + return { status: 200, data: { data: [] } } + } } + } + }) + const client = createCloudSyncClient('https://example.test', 'test-only', { accountId: 'stalled-read' }) + assert.deepEqual(await client.listVaults(), { data: [] }) + assert.deepEqual(paths, ['/api/v1/vaults', '/api/v1/vaults']) +}) + +it('never sends a request again that the service might apply twice, and says the connection stalled', async () => { + let calls = 0 + const { createCloudSyncClient } = await loadMobileModule('./src/bridge/cloud-sync-client.ts', { + '@capacitor/core': { + registerPlugin: () => ({}), + CapacitorHttp: { request: async () => { + calls++ + throw Object.assign(new Error('timeout'), { code: 'SocketTimeoutException' }) + } } + } + }) + const { describeCloudError, CLOUD_TIMEOUT_MESSAGE } = await import('@zennotes/shared-domain/cloud-error-copy') + const client = createCloudSyncClient('https://example.test', 'test-only', { accountId: 'stalled-write' }) + const error = await client.createVault('New vault').catch((failure: unknown) => failure) + assert.equal(calls, 1) + assert.equal((error as Error).name, 'CloudSyncTransportError') + assert.equal((error as { stalled?: boolean }).stalled, true) + assert.equal(describeCloudError(error).message, CLOUD_TIMEOUT_MESSAGE) +}) + +it('puts an object again when the store never answered, never when it refused', async (t) => { + t.mock.method(Math, 'random', () => 0) + for (const outcome of ['no answer', 'refused'] as const) { + const puts: Array<{ base64?: string }> = [] + const requests: string[] = [] + const { createCloudSyncClient } = await loadMobileModule('./src/bridge/cloud-sync-client.ts', { + '@capacitor/core': { + registerPlugin: () => ({ put: async (request: { base64?: string }) => { + puts.push(request) + if (puts.length > 1) return { status: 200 } + if (outcome === 'refused') return { status: 403 } + throw Object.assign(new Error('Cloud object upload failed.'), { code: 'DIRECT_UPLOAD_FAILED' }) + } }), + CapacitorHttp: { request: async ({ method, url, data }: any) => { + const path = new URL(url).pathname + requests.push(`${method} ${path}`) + if (path.endsWith('/uploads')) { + return { status: 201, data: { data: { + id: 'upload-1', operation_id: data.operation_id, status: 'uploading', expected_bytes: data.content.byte_length, + expires_at: '2099-01-01T00:00:00Z', upload: { method: 'PUT', url: 'https://objects.example.test/put', headers: {} } + } } } + } + if (method === 'DELETE') return { status: 204, data: '' } + return { status: 200, data: { data: { id: 'upload-1', operation_id: 'operation-1', status: 'completed', + result: { acknowledged: [{ operation_id: 'operation-1', item_id: 'item-1', revision: 1, sequence: 1 }], conflicts: [], cursor: 1 } } } } + } } + } + }) + const client = createCloudSyncClient('https://example.test', 'test-only', { accountId: `put-${outcome}` }) + const bytes = Buffer.alloc(5 * 1024 * 1024 + 1, 7) + const mutation = client.mutate('vault-1', { mutations: [{ + type: 'upsert', operation_id: 'operation-1', item_id: 'item-1', base_revision: null, path: 'Scans/receipt.pdf', kind: 'binary', + content: { encoding: 'base64', data: bytes.toString('base64'), sha256: createHash('sha256').update(bytes).digest('hex'), + byte_length: bytes.length, media_type: 'application/pdf' } + }] }) + if (outcome === 'no answer') { + assert.equal((await mutation).acknowledged.length, 1) + assert.equal(puts.length, 2) + assert.equal(puts[1]!.base64, puts[0]!.base64) + assert.deepEqual(requests, ['POST /api/v1/vaults/vault-1/uploads', 'POST /api/v1/vaults/vault-1/uploads/upload-1/complete']) + } else { + await assert.rejects(mutation, /403/) + assert.equal(puts.length, 1) + assert.deepEqual(requests, ['POST /api/v1/vaults/vault-1/uploads', 'DELETE /api/v1/vaults/vault-1/uploads/upload-1']) + } + } +}) diff --git a/src/bridge/cloud-sync-client.ts b/src/bridge/cloud-sync-client.ts index 12c7a2c..36bcbde 100644 --- a/src/bridge/cloud-sync-client.ts +++ b/src/bridge/cloud-sync-client.ts @@ -13,6 +13,13 @@ import type { } from '@zennotes/bridge-contract/cloud-sync' import type { CloudE2eeKeyring } from '@zennotes/shared-domain/cloud-e2ee-account' import { ENCRYPTED_VAULT_PLACEHOLDER_NAME } from '@zennotes/shared-domain/cloud-e2ee-vault' +import { + CLOUD_CONNECT_TIMEOUT_MS, + CLOUD_STALL_TIMEOUT_MS, + CLOUD_TRANSPORT_RETRY_DELAYS_MS, + CloudSyncTransportError, + retryCloudTransfer +} from '@zennotes/shared-domain/cloud-sync-stall' import { MobileDirectUploadError, mutateWithMobileDirectUploads, @@ -88,11 +95,13 @@ export function createCloudSyncClient(baseUrl: string, token: string, options: M ? await serializeFormData(request.body) : request.body, ...(multipart ? { dataType: 'formData' as const } : {}), - connectTimeout: 30_000, - // Attachment-heavy first syncs legitimately take longer on mobile. - // A short timeout repeatedly retries the same batch without progress. - readTimeout: request.timeoutMs ?? 300_000 - }) + connectTimeout: CLOUD_CONNECT_TIMEOUT_MS, + // Android's read timeout starts over with every byte that arrives, so + // this is how long a request may go silent, not how long it may take: + // a big page on a slow link keeps going as long as bytes move. A + // request that waits on the service's own work says so in timeoutMs. + readTimeout: request.timeoutMs ?? CLOUD_STALL_TIMEOUT_MS + }).catch((error: unknown) => { throw unanswered(error) }) if (response.status < 200 || response.status >= 300) { const error = response.data?.error @@ -128,10 +137,12 @@ export function createCloudSyncClient(baseUrl: string, token: string, options: M const loopback = /^http:\/\/(localhost|127\.\d+\.\d+\.\d+|\[::1\])(:\d+)?$/.test(normalizedBaseUrl) return new MobileCloudSyncApiClient(cloudSyncRateLimits.wrap(transport, { - baseUrl: normalizedBaseUrl, accountId: options.accountId, signal: lifetime + baseUrl: normalizedBaseUrl, accountId: options.accountId, signal: lifetime, + transportRetryDelaysMs: CLOUD_TRANSPORT_RETRY_DELAYS_MS }), async (request) => { if (lifetime.aborted) throw new DOMException('Cloud request cancelled.', 'AbortError') - await uploadObject(request) + // The same bytes to the same object: a PUT that got no answer is safe to send again. + await retryCloudTransfer(() => uploadObject(request), { signal: lifetime, isTransportFailure: unansweredUpload }) if (lifetime.aborted) throw new DOMException('Cloud request cancelled.', 'AbortError') }, { // Streaming host: large revisions arrive as references and are downloaded @@ -233,6 +244,26 @@ export const uploadObject: MobileObjectUpload = async (request) => { } } +/** + * CapacitorHttp resolves any HTTP answer, error statuses included, so a + * rejection means none came: the request stalled past its read timeout, the + * connection dropped, or it never opened. Android names the Java exception + * in `code`. + */ +function unanswered(error: unknown): CloudSyncTransportError { + const { message, code } = (error ?? {}) as { message?: unknown; code?: unknown } + const name = typeof code === 'string' && code ? code : null + return new CloudSyncTransportError( + typeof message === 'string' && message ? message : 'ZenNotes Cloud did not answer.', + { code: name, stalled: name === 'SocketTimeoutException', cause: error } + ) +} + +/** The object store never answered the PUT (a status means it did). */ +function unansweredUpload(error: unknown): boolean { + return error instanceof MobileDirectUploadError && error.status === 0 && error.code === 'DIRECT_UPLOAD_FAILED' +} + function firstValidationMessage(errors: unknown): string | null { if (!errors || typeof errors !== 'object') return null diff --git a/src/bridge/cloud-sync-staging.test.ts b/src/bridge/cloud-sync-staging.test.ts index 48e4926..2cfa593 100644 --- a/src/bridge/cloud-sync-staging.test.ts +++ b/src/bridge/cloud-sync-staging.test.ts @@ -487,3 +487,46 @@ describe('native Cloud staging for end-to-end encrypted vaults', () => { assert.deepEqual(h.leftovers(), []) }) }) + +describe('a download that goes quiet', () => { + it('downloads again into a clean scratch file and stages the bytes', async (t) => { + t.mock.method(Math, 'random', () => 0) + const h = harness() + const bytes = Buffer.from('the whole note, as the PC saved it') + const ref = h.reference('item', 1, bytes) + const url = 'https://objects.example.test/item/1' + h.objects.set(url, bytes) + const download = h.native.download + const leftBehind: boolean[] = [] + let stalls = 1 + h.native.download = async (options) => { + leftBehind.push(h.files.has(options.to)) + if (stalls-- > 0) { + // Half the body arrived, then the connection went silent until the + // native read timeout gave up: no HTTP status to report. + h.files.set(options.to, bytes.subarray(0, 10)) + h.log.push(`stalled ${options.url}`) + throw Object.assign(new Error('Cloud object download failed.'), { code: 'DIRECT_DOWNLOAD_FAILED' }) + } + await download(options) + } + const file = await h.staging.stage(h.source(ref, url)) + assert.deepEqual(h.log, [`stalled ${url}`, `download ${url}`]) + assert.deepEqual(leftBehind, [false, false]) + assert.deepEqual(h.files.get(cloudSyncStagedHandle(file).path), bytes) + }) + + it('does not download again when the storage host answered with an error', async () => { + const h = harness() + const bytes = Buffer.from('cloud') + const url = 'https://objects.example.test/item/1' + let calls = 0 + h.native.download = async () => { + calls++ + throw Object.assign(new Error('Cloud object download failed (404).'), { code: 'DIRECT_DOWNLOAD_FAILED', data: { status: 404 } }) + } + await assert.rejects(h.staging.stage(h.source(h.reference('item', 1, bytes), url)), /404/) + assert.equal(calls, 1) + assert.deepEqual([...h.files.keys()], []) + }) +}) diff --git a/src/bridge/cloud-sync-staging.ts b/src/bridge/cloud-sync-staging.ts index 2d8bf8f..72c3425 100644 --- a/src/bridge/cloud-sync-staging.ts +++ b/src/bridge/cloud-sync-staging.ts @@ -13,6 +13,7 @@ import { type CloudSyncStagedFile } from '@zennotes/shared-domain/cloud-sync-content' import type { CloudSyncRepositoryConflict } from '@zennotes/shared-domain/cloud-sync-coordinator' +import { retryCloudTransfer } from '@zennotes/shared-domain/cloud-sync-stall' import type { CloudSyncLocalItem, CloudSyncTrackedItem } from '@zennotes/shared-domain/cloud-sync-engine' import type { CloudFileFingerprint } from './native-fs' import { randomUUID } from './uuid' @@ -77,10 +78,19 @@ export class NativeCloudStaging { } const expected = reference.encrypted ?? reference try { - await this.native.download({ - url: instruction.download.url, headers: instruction.download.headers, to: download, - byteLength: expected.byte_length, sha256: expected.sha256 - }) + // A download that stalled or dropped is a GET into a scratch file: + // run it again from the start instead of failing the whole sync. + await retryCloudTransfer(async () => { + try { + await this.native.download({ + url: instruction.download.url, headers: instruction.download.headers, to: download, + byteLength: expected.byte_length, sha256: expected.sha256 + }) + } catch (error) { + await discardDownload() + throw error + } + }, { signal: source.signal, isTransportFailure: isUnansweredDownload }) } catch (error) { if (attempt === 0 && isExpiredUrlError(error)) { await discardDownload() @@ -332,6 +342,17 @@ function hasNativeDecryption(native: CloudStagingNative): native is CloudStaging return typeof native.readHeader === 'function' && typeof native.decryptFile === 'function' } +/** + * The native download got no HTTP answer, or the bytes stopped short of what + * the reference promised (a dropped connection can end a body early). An HTTP + * status means the storage host answered, which a retry would not change. + */ +function isUnansweredDownload(error: unknown): boolean { + const candidate = error as { code?: unknown; status?: unknown; data?: { status?: unknown } } | null + return candidate?.code === 'DIRECT_DOWNLOAD_FAILED' && candidate.status === undefined && + candidate.data?.status === undefined +} + function isExpiredUrlError(error: unknown): boolean { // Capacitor plugin rejections carry the HTTP status under `data`. const candidate = error as { status?: unknown; data?: { status?: unknown } } diff --git a/src/bridge/mobile-bridge.ts b/src/bridge/mobile-bridge.ts index 3206790..15df644 100644 --- a/src/bridge/mobile-bridge.ts +++ b/src/bridge/mobile-bridge.ts @@ -155,7 +155,7 @@ import { import { folderForRelativePath, posixNormalize, sanitizeNoteTitle } from './vault-core' import { isPhoneViewport } from '../viewport' -let appVersion = '1.1.46' +let appVersion = '1.1.47' export async function loadNativeAppVersion(): Promise { try { diff --git a/src/ui-mobile/MobileShell.tsx b/src/ui-mobile/MobileShell.tsx index a4c1891..23210ba 100644 --- a/src/ui-mobile/MobileShell.tsx +++ b/src/ui-mobile/MobileShell.tsx @@ -6,7 +6,7 @@ * full-screen flow. Rendered into its own React root so app-core stays * untouched; state is driven through the shared Zustand store. */ -import React, { useEffect, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import ReactDOM from 'react-dom/client' import { App as CapApp } from '@capacitor/app' import { Haptics, ImpactStyle } from '@capacitor/haptics' @@ -17,7 +17,9 @@ import { getBrowseSnapshot, requestCreateBrowseFolder, requestRenameBrowseDataba requestMoveBrowseDirectory, requestDeleteBrowseDirectory, type BrowseActionHost } from '@zennotes/app-core/browse' import { getWorkspaceSnapshot, useWorkspaceSnapshot, subscribeWorkspace, readPersistedHomeState, configureWorkspacePresentation, pickLocalVault, openLocalVault, connectRemoteProfile, refreshRemoteProfiles } from '@zennotes/app-core/workspace' -import { getSettingsSnapshot, useSettingsSnapshot, subscribeSettings, setSettingsVisible, setEditorFontSize } from '@zennotes/app-core/settings' +import { getSettingsSnapshot, useSettingsSnapshot, subscribeSettings, setSettingsVisible, setEditorFontSize, + showSettingsPage } from '@zennotes/app-core/settings' +import { DateCalendar } from '@zennotes/app-core/calendar' import { useEditorPresentation, setEditorMode, hasEditorSelection, runEditorCommand } from '@zennotes/app-core/editor' import { getTasksSnapshot, moveTaskToColumn, type KanbanGroupBy } from '@zennotes/app-core/tasks' import { runAppCommand, showCommandPalette, showSearch, showTemplates, showOutline } from '@zennotes/app-core/commands' @@ -269,10 +271,7 @@ function ActionSheet({ onClose }: { onClose: () => void }): React.JSX.Element { className="zn-mobile-sheet-row" onClick={() => { onClose() - window.setTimeout( - () => window.dispatchEvent(new Event('zen:toggle-calendar')), - 30 - ) + window.setTimeout(showCalendar, 30) }} > @@ -436,6 +435,72 @@ function CreateSheet({ onClose }: { onClose: () => void }): React.JSX.Element { ) } +const SHOW_CALENDAR_EVENT = 'zn:show-calendar' + +/** + * Open the Calendar. A phone has no room beside a note, and Home has no note + * to sit beside, so there it is a sheet of its own (CalendarSheet). A tablet + * keeps the desktop's panel beside the open note, which is the only place + * its ••• row offers it. + */ +function showCalendar(): void { + if (isPhoneWidth()) window.dispatchEvent(new Event(SHOW_CALENDAR_EVENT)) + else window.dispatchEvent(new Event('zen:toggle-calendar')) +} + +/** + * The Calendar (Discord, 2026-10-26: "I don't see anywhere to bring the + * calendar up"). The only way in used to be a note's ••• sheet, and only + * with daily or weekly notes on, so nobody on Home ever met it. This is + * app-core's date calendar (month grid with writing dots and task marks, + * the selected day's week of tasks below) as a sheet that needs no open + * note, opened from the circle's Calendar row and from •••. A tap selects a + * day and a tap on the selected day opens its daily note or offers to + * create it (core); once a day or a task opens, the sheet closes so the note + * is what shows. Done and system Back reach it as a right-hand panel. + * + * With daily and weekly notes both off there is nothing for it to open, so + * the row says how to turn daily notes on instead of disappearing. + */ +function CalendarSheet({ onClose }: { onClose: () => void }): React.JSX.Element { + const { calendarAvailable } = useSettingsSnapshot() + useEffect(() => { + window.addEventListener('zen:close-right-panel', onClose) + return () => window.removeEventListener('zen:close-right-panel', onClose) + }, [onClose]) + + if (calendarAvailable) { + return ( +
+ +
+ ) + } + const turnOn = (): void => { + onClose() + window.setTimeout(() => showSettingsPage('periodic-notes'), 30) + } + return ( + <> +
+
+ +
Calendar
+

+ The calendar opens your daily notes: tap a day for its note, with that week's tasks + below. Daily notes are off in this vault. +

+
+ +
+
+ + ) +} + function MobileNav(): React.JSX.Element | null { const shell = useShellSnapshot() const vault = shell.vault @@ -452,6 +517,8 @@ function MobileNav(): React.JSX.Element | null { const [sheetOpen, setSheetOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false) const [fabOpen, setFabOpen] = useState(false) + const [calendarOpen, setCalendarOpen] = useState(false) + const closeCalendar = useCallback(() => setCalendarOpen(false), []) // First-run coach mark: the unlabeled ensō reads as a loading spinner to // new users (real-user tested). Set by onboarding, cleared on first tap. const [fabHint, setFabHint] = useState( @@ -465,19 +532,28 @@ function MobileNav(): React.JSX.Element | null { if (createOpen) setCreateOpen(false) else if (sheetOpen) setSheetOpen(false) else if (fabOpen) setFabOpen(false) + // The calendar itself closes at the right-panel step, after a confirm + // or menu it raised; only its "daily notes are off" sheet closes here. + else if (calendarOpen && !getSettingsSnapshot().calendarAvailable) setCalendarOpen(false) else return e.preventDefault() } window.addEventListener('zn:android-back', onBack) return () => window.removeEventListener('zn:android-back', onBack) - }, [createOpen, sheetOpen, fabOpen]) + }, [createOpen, sheetOpen, fabOpen, calendarOpen]) // The pull-down quick action (usePullDownAction, #24) can be set to "New - // note"; the sheet is this component's, so it is summoned by event. + // note"; the sheet is this component's, so it is summoned by event. So is + // the Calendar, which the ••• sheet opens too. useEffect(() => { const onCreate = (): void => setCreateOpen(true) + const onCalendar = (): void => setCalendarOpen(true) window.addEventListener('zn:quick-create', onCreate) - return () => window.removeEventListener('zn:quick-create', onCreate) + window.addEventListener(SHOW_CALENDAR_EVENT, onCalendar) + return () => { + window.removeEventListener('zn:quick-create', onCreate) + window.removeEventListener(SHOW_CALENDAR_EVENT, onCalendar) + } }, []) if (!vault) return null @@ -518,6 +594,8 @@ function MobileNav(): React.JSX.Element | null { ] : []), { label: 'More', icon: ICONS.more, run: () => setSheetOpen(true) }, + // Always listed, daily notes on or off: off, it says how to turn them on. + { label: 'Calendar', icon: ICONS.calendar, run: showCalendar }, { label: 'Browse', icon: ICONS.sidebar, run: () => setDrawerOpen(true) }, { label: 'Search', icon: ICONS.search, run: showSearch } ] @@ -596,6 +674,7 @@ function MobileNav(): React.JSX.Element | null { {sheetOpen && setSheetOpen(false)} />} {createOpen && setCreateOpen(false)} />} + {calendarOpen && } ) } @@ -1404,9 +1483,11 @@ function useCalendarWeekMode(): void { localStorage.getItem(CAL_SCOPE_KEY) === 'month' ? 'month' : 'week' const apply = (): void => { + // The date calendar (a note's panel, or the Calendar sheet over Tasks) + // marks its days the same way; its grid keeps the whole month. const cells = Array.from( document.querySelectorAll('button[data-cal-day]') - ) + ).filter((cell) => !cell.closest('[data-calendar-panel]')) const todayBtn = document.querySelector("button[title='Today (gt)']") if (cells.length === 0 || !todayBtn) { control?.remove() diff --git a/src/ui-mobile/mobile-layout.test.ts b/src/ui-mobile/mobile-layout.test.ts index 4766577..342de3f 100644 --- a/src/ui-mobile/mobile-layout.test.ts +++ b/src/ui-mobile/mobile-layout.test.ts @@ -114,3 +114,10 @@ test('the date calendar hides its key help on phones and keeps Today on the righ assert.equal(declaration(hint, 'display'), 'none') assert.equal(declaration(`${hint} + button`, 'margin-left'), 'auto') }) + +test("the Calendar sheet is the date calendar's full-screen phone sheet, with finger-sized days", () => { + const panel = '.zn-mobile.zn-phone .zn-calendar-sheet > [data-calendar-panel]' + assert.equal(groupedDeclaration(panel, 'position'), 'fixed') + assert.ok(groupedDeclaration(panel, 'width')?.includes('100vw')) + assert.equal(groupedDeclaration('.zn-mobile.zn-phone .zn-calendar-sheet button[data-cal-day]', 'min-height'), '42px') +}) diff --git a/src/ui-mobile/mobile.css b/src/ui-mobile/mobile.css index bb7e172..918b6bd 100644 --- a/src/ui-mobile/mobile.css +++ b/src/ui-mobile/mobile.css @@ -1239,12 +1239,15 @@ /* Right-hand side panels (calendar/outline/connections/comments/reference) would split a 390pt screen in half — they become full-screen sheets, with a shell-injected close button (zen:close-right-panel). Panel roots vary: -
,
(calendar), and