Skip to content

Commit 50c31dc

Browse files
authored
Merge pull request #64 from ZenNotes/fix/cloud-live-regressions
Release Android 1.1.20 with Cloud download, sign-in, and sync fixes
2 parents 1b4b65b + 04367dc commit 50c31dc

13 files changed

Lines changed: 322 additions & 26 deletions

.zennotes-commit

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
431907dfb63a59192ff414839673446564ee737e
1+
104416f560bb9e709620cabe9e1f225306f7a4ea

android/app/build.gradle

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ android {
1515
applicationId "md.zennotes"
1616
minSdkVersion rootProject.ext.minSdkVersion
1717
targetSdkVersion rootProject.ext.targetSdkVersion
18-
versionCode 21
19-
versionName "1.1.19"
18+
versionCode 22
19+
versionName "1.1.20"
2020
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
2121
aaptOptions {
2222
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

capacitor.config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ const config: CapacitorConfig = {
66
appName: 'ZenNotes',
77
webDir: 'dist',
88
android: {
9-
backgroundColor: '#1d2021'
9+
backgroundColor: '#1d2021',
10+
// Native debug logging duplicates complete plugin payloads, including
11+
// attachment base64 and credentials, and can exhaust Android's heap.
12+
loggingBehavior: 'none'
1013
},
1114
plugins: {
1215
Keyboard: {

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "zennotes-android",
33
"private": true,
4-
"version": "1.1.19",
4+
"version": "1.1.20",
55
"type": "module",
66
"description": "ZenNotes for Android — Capacitor shell over the ZenNotes app core",
77
"homepage": "https://zennotes.org",
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import assert from 'node:assert/strict'
2+
import { it } from 'node:test'
3+
import { loadMobileModule } from '../../tooling/load-mobile-module.ts'
4+
5+
it('downloads every manifest and change page without grouping large attachments in one native response', async () => {
6+
const requests: URL[] = []
7+
const items = [1, 2, 3].map((id) => ({
8+
item_id: `asset-${id}`, path: `assets/${id}.jpg`, kind: 'binary', revision: 1,
9+
sha256: `hash-${id}`, byte_length: 8_700_000, media_type: 'image/jpeg',
10+
content: {
11+
encoding: 'base64', data: `fixture-${id}`, sha256: `hash-${id}`,
12+
byte_length: 8_700_000, media_type: 'image/jpeg'
13+
}
14+
}))
15+
let feed: any[] = []
16+
const { createCloudSyncClient, CloudSyncCoordinator } = await loadMobileModule([
17+
'./src/bridge/cloud-sync-client.ts', '@zennotes/shared-domain/cloud-sync-coordinator'
18+
], {
19+
'@capacitor/core': {
20+
registerPlugin: () => ({}),
21+
CapacitorHttp: {
22+
request: async ({ url }: { url: string }) => {
23+
const request = new URL(url)
24+
requests.push(request)
25+
if (request.pathname.endsWith('/manifest')) {
26+
const page = Number(request.searchParams.get('page') ?? 1)
27+
const size = Number(request.searchParams.get('per_page') ?? 100)
28+
const data = items.slice((page - 1) * size, page * size)
29+
assert.ok(data.length <= 1, 'content responses must fit one attachment through the native bridge')
30+
return { status: 200, data: { data, cursor: 3, next_page: page * size < items.length ? page + 1 : null } }
31+
}
32+
assert.ok(request.pathname.endsWith('/changes'))
33+
const after = Number(request.searchParams.get('after'))
34+
const size = Number(request.searchParams.get('limit'))
35+
const remaining = feed.filter((change) => change.sequence > after)
36+
const data = remaining.slice(0, size)
37+
assert.ok(data.length <= 1, 'change responses must fit one attachment through the native bridge')
38+
return { status: 200, data: { data, cursor: feed.at(-1)?.sequence ?? 3, has_more: remaining.length > size } }
39+
}
40+
}
41+
}
42+
})
43+
const files = new Map<string, any>()
44+
let state: any = null
45+
const sync = new CloudSyncCoordinator('vault-1', createCloudSyncClient('https://example.test', 'test-only'), {
46+
scan: async () => [...files.values()],
47+
apply: async (change: any) => {
48+
files.set(change.path, { path: change.path, kind: 'binary', content: change.content })
49+
}
50+
}, {
51+
load: async () => state,
52+
save: async (next: any) => { state = structuredClone(next) }
53+
}, {
54+
itemId: () => assert.fail('download must not invent an item'),
55+
operationId: () => assert.fail('download must not upload unchanged files')
56+
})
57+
58+
await sync.sync()
59+
assert.equal(state.cursor, 3)
60+
assert.equal(files.size, 3)
61+
assert.deepEqual(requests.filter((url) => url.pathname.endsWith('/manifest')).map((url) => url.searchParams.get('page')), ['1', '2', '3'])
62+
63+
feed = items.map((item, index) => ({
64+
sequence: 4 + index, item_id: item.item_id, path: item.path, previous_path: item.path,
65+
type: 'upsert', revision: 2,
66+
content: { ...item.content, data: `updated-${index}`, sha256: `updated-hash-${index}` }
67+
}))
68+
requests.length = 0
69+
await sync.sync()
70+
assert.equal(state.cursor, 6)
71+
assert.deepEqual([...files.values()].map((item) => item.content.sha256), feed.map((change) => change.content.sha256))
72+
assert.deepEqual(requests.slice(0, 3).map((url) => url.searchParams.get('after')), ['3', '4', '5'])
73+
})
74+
75+
it('keeps metadata-only manifest pagination intact', async () => {
76+
const requests: URL[] = []
77+
const { createCloudSyncClient } = await loadMobileModule('./src/bridge/cloud-sync-client.ts', {
78+
'@capacitor/core': {
79+
registerPlugin: () => ({}),
80+
CapacitorHttp: { request: async ({ url }: { url: string }) => {
81+
requests.push(new URL(url))
82+
return { status: 200, data: { data: [], cursor: 10, next_page: null } }
83+
} }
84+
}
85+
})
86+
const client = createCloudSyncClient('https://example.test', 'test-only')
87+
await client.manifest('vault-1', { includeContent: false, page: 2, perPage: 250 })
88+
await client.manifest('vault-1', { includeContent: false, perPage: 1 })
89+
assert.equal(requests[0].searchParams.get('per_page'), '250')
90+
assert.equal(requests[0].searchParams.get('page'), '2')
91+
assert.equal(requests[1].searchParams.get('per_page'), '1')
92+
assert.ok(requests.every((url) => url.searchParams.get('include_content') === 'false'))
93+
})

src/bridge/cloud-sync-client.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,19 @@ class MobileCloudSyncApiClient extends CloudSyncApiClient {
102102
super(http)
103103
}
104104

105+
// Capacitor copies JSON through Java and the WebView. A page containing
106+
// several near-limit attachments can exhaust Android's native heap.
107+
override manifest(
108+
vaultId: string,
109+
options: { includeContent?: boolean; page?: number; perPage?: number } = {}
110+
) {
111+
return super.manifest(vaultId, options.includeContent ? { ...options, perPage: 1 } : options)
112+
}
113+
114+
override changes(vaultId: string, after: number, _limit = 100) {
115+
return super.changes(vaultId, after, 1)
116+
}
117+
105118
override async mutate(
106119
vaultId: string,
107120
body: CloudSyncMutationRequest

src/bridge/mobile-bridge.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ import {
104104
restoreMobileCloudBackup,
105105
restoreMobileCloudBackupNote,
106106
syncMobileCloudVault,
107+
hasMobileCloudVaultChanges,
107108
updateMobileCloudBackupSchedule,
108109
unlinkMobileCloudVault,
109110
deleteMobileCloudVault,
@@ -130,7 +131,7 @@ import {
130131
import { folderForRelativePath, posixNormalize, sanitizeNoteTitle } from './vault-core'
131132
import { isPhoneViewport } from '../viewport'
132133

133-
let appVersion = '1.1.19'
134+
let appVersion = '1.1.20'
134135

135136
export async function loadNativeAppVersion(): Promise<string> {
136137
try {
@@ -845,6 +846,10 @@ export const mobileBridge: ZenBridge = {
845846
unlinkCloudVault: () => unlinkMobileCloudVault(activeMobileVault()),
846847
deleteCloudVault: () => deleteMobileCloudVault(activeMobileVault()),
847848
syncCloudVault: () => syncMobileCloudVault(activeMobileVault()),
849+
hasCloudVaultChanges: () => {
850+
const vault = activeVault()
851+
return vault instanceof MobileVault ? hasMobileCloudVaultChanges(vault) : Promise.resolve(false)
852+
},
848853
getCloudBootstrapConflict: (conflict) =>
849854
getMobileCloudBootstrapConflict(activeMobileVault(), conflict),
850855
resolveCloudBootstrapConflict: (resolution) =>
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import assert from 'node:assert/strict'
2+
import { randomUUID } from 'node:crypto'
3+
import { it } from 'node:test'
4+
import { registerPlugin } from '@capacitor/core'
5+
import { loadMobileModule } from '../../tooling/load-mobile-module.ts'
6+
7+
const account = {
8+
base_url: 'https://zennotes.org', connected_at: '2026-09-14T12:00:00Z',
9+
user: { name: 'Test', email: 'test@example.test' },
10+
device: { id: 'test-device', name: 'Test phone', platform: 'android' }
11+
}
12+
const credential = JSON.stringify({ base_url: account.base_url, token: 'test-only', account })
13+
14+
async function coldLaunch(saved: Map<string, string>) {
15+
// Exercise the real Capacitor lazy proxy: concurrent first calls can
16+
// instantiate separate implementations, each with its own key prefix.
17+
const secureStorage = registerPlugin(`TestCloudStorage${randomUUID()}`, {
18+
web: async () => {
19+
await Promise.resolve()
20+
return new class {
21+
prefix = 'capacitor-storage_'
22+
async setKeyPrefix(prefix: string) { this.prefix = prefix }
23+
async setSynchronize(_value: boolean) {}
24+
async setDefaultKeychainAccess(_value: unknown) {}
25+
async getItem(key: string) { return saved.get(this.prefix + key) ?? null }
26+
async setItem(key: string, value: string) { saved.set(this.prefix + key, value) }
27+
async removeItem(key: string) { saved.delete(this.prefix + key) }
28+
}()
29+
}
30+
})
31+
const api = await loadMobileModule('./src/bridge/mobile-cloud-auth.ts', {
32+
'@capacitor/core': { Capacitor: { isNativePlatform: () => true }, CapacitorHttp: {} },
33+
'@capacitor/app': { App: { addListener: async () => ({}), getLaunchUrl: async () => null } },
34+
'@aparajita/capacitor-secure-storage': {
35+
SecureStorage: secureStorage,
36+
KeychainAccess: { whenUnlockedThisDeviceOnly: 'device-only' }
37+
},
38+
'./cloud-sync-client': { createCloudSyncClient: () => assert.fail('status must only read storage') }
39+
})
40+
await api.configureMobileCloudAuth('test-version')
41+
return api
42+
}
43+
44+
for (const prefix of ['zennotes.cloud.', 'capacitor-storage_']) {
45+
it(`loads a saved account from ${prefix} on every cold launch`, async () => {
46+
const saved = new Map([[prefix + 'credential', credential]])
47+
for (let launch = 0; launch < 2; launch++) {
48+
const api = await coldLaunch(saved)
49+
const statuses = await Promise.all([api.getMobileCloudAccountStatus(), api.getMobileCloudAccountStatus()])
50+
assert.deepEqual(statuses, [{ state: 'connected', account }, { state: 'connected', account }])
51+
assert.deepEqual([...saved.keys()], ['zennotes.cloud.credential'])
52+
}
53+
})
54+
}
55+
56+
it('preserves the canonical account and prevents a legacy credential from returning after logout', async () => {
57+
const saved = new Map([
58+
['zennotes.cloud.credential', credential],
59+
['capacitor-storage_credential', JSON.stringify({ base_url: account.base_url, token: 'old-test-token', account })]
60+
])
61+
const api = await coldLaunch(saved)
62+
assert.equal((await api.authenticatedCredential()).token, 'test-only')
63+
await api.logoutMobileCloudAccount()
64+
assert.equal(saved.size, 0)
65+
assert.deepEqual(await (await coldLaunch(saved)).getMobileCloudAccountStatus(), { state: 'disconnected', account: null })
66+
})
67+
68+
it('rejects invalid recovered credentials through the shared auth validator', async () => {
69+
const invalid = JSON.stringify({ base_url: 'https://wrong.example.test', token: 'test-only', account })
70+
const saved = new Map([['capacitor-storage_credential', invalid]])
71+
const api = await coldLaunch(saved)
72+
assert.deepEqual(await api.getMobileCloudAccountStatus(), { state: 'disconnected', account: null })
73+
assert.equal(saved.size, 0)
74+
})
75+
76+
it('recovers pending sign-in state across cold launches', async () => {
77+
const pending = JSON.stringify({
78+
base_url: account.base_url, state: 'test-state', code_verifier: 'a'.repeat(43), expires_at: '2099-01-01T00:00:00Z'
79+
})
80+
const saved = new Map([['capacitor-storage_pending-auth', pending]])
81+
for (let launch = 0; launch < 2; launch++) {
82+
const api = await coldLaunch(saved)
83+
assert.deepEqual(await api.getMobileCloudAccountStatus(), { state: 'connecting', account: null })
84+
assert.deepEqual([...saved.entries()], [['zennotes.cloud.pending-auth', pending]])
85+
}
86+
})

src/bridge/mobile-cloud-auth.ts

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,33 +34,76 @@ const accountListeners = new Set<(status: CloudAccountStatus) => void>()
3434
let authFlow: CloudAuthFlow | null = null
3535
let callbackQueue = Promise.resolve()
3636

37-
const secureStorageReady = Capacitor.isNativePlatform()
38-
? Promise.all([
39-
SecureStorage.setKeyPrefix('zennotes.cloud.'),
40-
SecureStorage.setSynchronize(false),
41-
SecureStorage.setDefaultKeychainAccess(KeychainAccess.whenUnlockedThisDeviceOnly)
42-
]).then(() => undefined)
43-
: Promise.resolve()
37+
// Lazy and retryable so a transient native storage failure does not poison
38+
// every later account read for the session.
39+
let secureStorageSetup: Promise<void> | null = null
40+
function secureStorageReady(): Promise<void> {
41+
if (!Capacitor.isNativePlatform()) return Promise.resolve()
42+
if (!secureStorageSetup) {
43+
secureStorageSetup = configureSecureStorage()
44+
secureStorageSetup.catch(() => {
45+
secureStorageSetup = null
46+
})
47+
}
48+
return secureStorageSetup
49+
}
50+
51+
async function configureSecureStorage(): Promise<void> {
52+
// The Capacitor proxy loads its implementation lazily. Parallel first
53+
// calls can initialize separate instances and lose the configured prefix.
54+
await SecureStorage.setKeyPrefix('zennotes.cloud.')
55+
await SecureStorage.setSynchronize(false)
56+
await SecureStorage.setDefaultKeychainAccess(KeychainAccess.whenUnlockedThisDeviceOnly)
57+
await migrateAuthStoragePrefix()
58+
}
59+
60+
async function migrateAuthStoragePrefix(): Promise<void> {
61+
const keys = [CREDENTIAL_KEY, PENDING_AUTH_KEY]
62+
const canonical = new Map<string, string | null>()
63+
for (const key of keys) canonical.set(key, await SecureStorage.getItem(key))
64+
65+
// Affected builds could persist auth under the plugin's default prefix.
66+
// All storage callers await setup, so none can see this temporary prefix.
67+
// CloudAuthFlow still validates every recovered record before using it.
68+
try {
69+
await SecureStorage.setKeyPrefix('capacitor-storage_')
70+
const legacy = new Map<string, string>()
71+
for (const key of keys) {
72+
const value = await SecureStorage.getItem(key)
73+
if (value !== null) legacy.set(key, value)
74+
}
75+
await SecureStorage.setKeyPrefix('zennotes.cloud.')
76+
for (const [key, value] of legacy) {
77+
if (canonical.get(key) === null) await SecureStorage.setItem(key, value)
78+
}
79+
// Remove superseded records as well, so logout cannot resurrect an older
80+
// credential on the next launch. Copying must finish before removal.
81+
await SecureStorage.setKeyPrefix('capacitor-storage_')
82+
for (const key of legacy.keys()) await SecureStorage.removeItem(key)
83+
} finally {
84+
await SecureStorage.setKeyPrefix('zennotes.cloud.')
85+
}
86+
}
4487

4588
const storage: CloudAuthStorage = {
4689
async loadPending(): Promise<unknown> {
4790
if (!Capacitor.isNativePlatform()) return null
48-
await secureStorageReady
91+
await secureStorageReady()
4992
return parseStoredJson(await SecureStorage.getItem(PENDING_AUTH_KEY))
5093
},
5194
async savePending(pending: CloudAuthPending): Promise<void> {
5295
assertNativeCloudAuth()
53-
await secureStorageReady
96+
await secureStorageReady()
5497
await SecureStorage.setItem(PENDING_AUTH_KEY, JSON.stringify(pending))
5598
},
5699
async deletePending(): Promise<void> {
57100
if (!Capacitor.isNativePlatform()) return
58-
await secureStorageReady
101+
await secureStorageReady()
59102
await SecureStorage.removeItem(PENDING_AUTH_KEY)
60103
},
61104
async loadCredential(): Promise<unknown> {
62105
if (!Capacitor.isNativePlatform()) return null
63-
await secureStorageReady
106+
await secureStorageReady()
64107
const credential = migrateLegacyCloudCredential(
65108
parseStoredJson(await SecureStorage.getItem(CREDENTIAL_KEY))
66109
)
@@ -71,13 +114,13 @@ const storage: CloudAuthStorage = {
71114
},
72115
async saveCredential(credential: CloudAuthCredential): Promise<void> {
73116
assertNativeCloudAuth()
74-
await secureStorageReady
117+
await secureStorageReady()
75118
const canonicalCredential = migrateLegacyCloudCredential(credential).value
76119
await SecureStorage.setItem(CREDENTIAL_KEY, JSON.stringify(canonicalCredential))
77120
},
78121
async deleteCredential(): Promise<void> {
79122
if (!Capacitor.isNativePlatform()) return
80-
await secureStorageReady
123+
await secureStorageReady()
81124
await SecureStorage.removeItem(CREDENTIAL_KEY)
82125
}
83126
}

0 commit comments

Comments
 (0)