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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ and `@zennotes/shared-domain` packages. The exact archives are vendored under
`vendor/zennotes/` with their source identity and checksums (`manifest.json`),
and `package-lock.json` pins the complete install. No source checkout is used.
The vendored set is the core release
[core-2.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.
Expand Down
4 changes: 2 additions & 2 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ android {
applicationId "md.zennotes"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 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.
Expand Down
19 changes: 14 additions & 5 deletions android/app/src/main/java/md/zennotes/DirectUploadPlugin.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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");
Expand All @@ -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);
Expand All @@ -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());
Expand All @@ -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();
}
}
Expand Down
72 changes: 72 additions & 0 deletions android/app/src/main/java/md/zennotes/UploadStallWatchdog.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
34 changes: 17 additions & 17 deletions package-lock.json

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

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "zennotes-android",
"private": true,
"version": "1.1.46",
"version": "1.1.47",
"type": "module",
"description": "ZenNotes for Android — Capacitor shell over the ZenNotes app core",
"homepage": "https://zennotes.org",
Expand Down Expand Up @@ -43,9 +43,9 @@
"@lezer/highlight": "^1.2.5",
"@replit/codemirror-vim": "^6.3.0",
"@xyflow/react": "^12.12.0",
"@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.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",
Expand Down
108 changes: 108 additions & 0 deletions src/bridge/cloud-sync-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
}
}
})
Loading
Loading