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
2 changes: 1 addition & 1 deletion .zennotes-commit
Original file line number Diff line number Diff line change
@@ -1 +1 @@
431907dfb63a59192ff414839673446564ee737e
104416f560bb9e709620cabe9e1f225306f7a4ea
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 21
versionName "1.1.19"
versionCode 22
versionName "1.1.20"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
Expand Down
5 changes: 4 additions & 1 deletion capacitor.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ const config: CapacitorConfig = {
appName: 'ZenNotes',
webDir: 'dist',
android: {
backgroundColor: '#1d2021'
backgroundColor: '#1d2021',
// Native debug logging duplicates complete plugin payloads, including
// attachment base64 and credentials, and can exhaust Android's heap.
loggingBehavior: 'none'
},
plugins: {
Keyboard: {
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "zennotes-android",
"private": true,
"version": "1.1.19",
"version": "1.1.20",
"type": "module",
"description": "ZenNotes for Android — Capacitor shell over the ZenNotes app core",
"homepage": "https://zennotes.org",
Expand Down
93 changes: 93 additions & 0 deletions src/bridge/cloud-sync-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import assert from 'node:assert/strict'
import { it } from 'node:test'
import { loadMobileModule } from '../../tooling/load-mobile-module.ts'

it('downloads every manifest and change page without grouping large attachments in one native response', async () => {
const requests: URL[] = []
const items = [1, 2, 3].map((id) => ({
item_id: `asset-${id}`, path: `assets/${id}.jpg`, kind: 'binary', revision: 1,
sha256: `hash-${id}`, byte_length: 8_700_000, media_type: 'image/jpeg',
content: {
encoding: 'base64', data: `fixture-${id}`, sha256: `hash-${id}`,
byte_length: 8_700_000, media_type: 'image/jpeg'
}
}))
let feed: any[] = []
const { createCloudSyncClient, CloudSyncCoordinator } = await loadMobileModule([
'./src/bridge/cloud-sync-client.ts', '@zennotes/shared-domain/cloud-sync-coordinator'
], {
'@capacitor/core': {
registerPlugin: () => ({}),
CapacitorHttp: {
request: async ({ url }: { url: string }) => {
const request = new URL(url)
requests.push(request)
if (request.pathname.endsWith('/manifest')) {
const page = Number(request.searchParams.get('page') ?? 1)
const size = Number(request.searchParams.get('per_page') ?? 100)
const data = items.slice((page - 1) * size, page * size)
assert.ok(data.length <= 1, 'content responses must fit one attachment through the native bridge')
return { status: 200, data: { data, cursor: 3, next_page: page * size < items.length ? page + 1 : null } }
}
assert.ok(request.pathname.endsWith('/changes'))
const after = Number(request.searchParams.get('after'))
const size = Number(request.searchParams.get('limit'))
const remaining = feed.filter((change) => change.sequence > after)
const data = remaining.slice(0, size)
assert.ok(data.length <= 1, 'change responses must fit one attachment through the native bridge')
return { status: 200, data: { data, cursor: feed.at(-1)?.sequence ?? 3, has_more: remaining.length > size } }
}
}
}
})
const files = new Map<string, any>()
let state: any = null
const sync = new CloudSyncCoordinator('vault-1', createCloudSyncClient('https://example.test', 'test-only'), {
scan: async () => [...files.values()],
apply: async (change: any) => {
files.set(change.path, { path: change.path, kind: 'binary', content: change.content })
}
}, {
load: async () => state,
save: async (next: any) => { state = structuredClone(next) }
}, {
itemId: () => assert.fail('download must not invent an item'),
operationId: () => assert.fail('download must not upload unchanged files')
})

await sync.sync()
assert.equal(state.cursor, 3)
assert.equal(files.size, 3)
assert.deepEqual(requests.filter((url) => url.pathname.endsWith('/manifest')).map((url) => url.searchParams.get('page')), ['1', '2', '3'])

feed = items.map((item, index) => ({
sequence: 4 + index, item_id: item.item_id, path: item.path, previous_path: item.path,
type: 'upsert', revision: 2,
content: { ...item.content, data: `updated-${index}`, sha256: `updated-hash-${index}` }
}))
requests.length = 0
await sync.sync()
assert.equal(state.cursor, 6)
assert.deepEqual([...files.values()].map((item) => item.content.sha256), feed.map((change) => change.content.sha256))
assert.deepEqual(requests.slice(0, 3).map((url) => url.searchParams.get('after')), ['3', '4', '5'])
})

it('keeps metadata-only manifest pagination intact', async () => {
const requests: URL[] = []
const { createCloudSyncClient } = await loadMobileModule('./src/bridge/cloud-sync-client.ts', {
'@capacitor/core': {
registerPlugin: () => ({}),
CapacitorHttp: { request: async ({ url }: { url: string }) => {
requests.push(new URL(url))
return { status: 200, data: { data: [], cursor: 10, next_page: null } }
} }
}
})
const client = createCloudSyncClient('https://example.test', 'test-only')
await client.manifest('vault-1', { includeContent: false, page: 2, perPage: 250 })
await client.manifest('vault-1', { includeContent: false, perPage: 1 })
assert.equal(requests[0].searchParams.get('per_page'), '250')
assert.equal(requests[0].searchParams.get('page'), '2')
assert.equal(requests[1].searchParams.get('per_page'), '1')
assert.ok(requests.every((url) => url.searchParams.get('include_content') === 'false'))
})
13 changes: 13 additions & 0 deletions src/bridge/cloud-sync-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,19 @@ class MobileCloudSyncApiClient extends CloudSyncApiClient {
super(http)
}

// Capacitor copies JSON through Java and the WebView. A page containing
// several near-limit attachments can exhaust Android's native heap.
override manifest(
vaultId: string,
options: { includeContent?: boolean; page?: number; perPage?: number } = {}
) {
return super.manifest(vaultId, options.includeContent ? { ...options, perPage: 1 } : options)
}

override changes(vaultId: string, after: number, _limit = 100) {
return super.changes(vaultId, after, 1)
}

override async mutate(
vaultId: string,
body: CloudSyncMutationRequest
Expand Down
7 changes: 6 additions & 1 deletion src/bridge/mobile-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ import {
restoreMobileCloudBackup,
restoreMobileCloudBackupNote,
syncMobileCloudVault,
hasMobileCloudVaultChanges,
updateMobileCloudBackupSchedule,
unlinkMobileCloudVault,
deleteMobileCloudVault,
Expand All @@ -130,7 +131,7 @@ import {
import { folderForRelativePath, posixNormalize, sanitizeNoteTitle } from './vault-core'
import { isPhoneViewport } from '../viewport'

let appVersion = '1.1.19'
let appVersion = '1.1.20'

export async function loadNativeAppVersion(): Promise<string> {
try {
Expand Down Expand Up @@ -845,6 +846,10 @@ export const mobileBridge: ZenBridge = {
unlinkCloudVault: () => unlinkMobileCloudVault(activeMobileVault()),
deleteCloudVault: () => deleteMobileCloudVault(activeMobileVault()),
syncCloudVault: () => syncMobileCloudVault(activeMobileVault()),
hasCloudVaultChanges: () => {
const vault = activeVault()
return vault instanceof MobileVault ? hasMobileCloudVaultChanges(vault) : Promise.resolve(false)
},
getCloudBootstrapConflict: (conflict) =>
getMobileCloudBootstrapConflict(activeMobileVault(), conflict),
resolveCloudBootstrapConflict: (resolution) =>
Expand Down
86 changes: 86 additions & 0 deletions src/bridge/mobile-cloud-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict'
import { randomUUID } from 'node:crypto'
import { it } from 'node:test'
import { registerPlugin } from '@capacitor/core'
import { loadMobileModule } from '../../tooling/load-mobile-module.ts'

const account = {
base_url: 'https://zennotes.org', connected_at: '2026-09-14T12:00:00Z',
user: { name: 'Test', email: 'test@example.test' },
device: { id: 'test-device', name: 'Test phone', platform: 'android' }
}
const credential = JSON.stringify({ base_url: account.base_url, token: 'test-only', account })

async function coldLaunch(saved: Map<string, string>) {
// Exercise the real Capacitor lazy proxy: concurrent first calls can
// instantiate separate implementations, each with its own key prefix.
const secureStorage = registerPlugin(`TestCloudStorage${randomUUID()}`, {
web: async () => {
await Promise.resolve()
return new class {
prefix = 'capacitor-storage_'
async setKeyPrefix(prefix: string) { this.prefix = prefix }
async setSynchronize(_value: boolean) {}
async setDefaultKeychainAccess(_value: unknown) {}
async getItem(key: string) { return saved.get(this.prefix + key) ?? null }
async setItem(key: string, value: string) { saved.set(this.prefix + key, value) }
async removeItem(key: string) { saved.delete(this.prefix + key) }
}()
}
})
const api = await loadMobileModule('./src/bridge/mobile-cloud-auth.ts', {
'@capacitor/core': { Capacitor: { isNativePlatform: () => true }, CapacitorHttp: {} },
'@capacitor/app': { App: { addListener: async () => ({}), getLaunchUrl: async () => null } },
'@aparajita/capacitor-secure-storage': {
SecureStorage: secureStorage,
KeychainAccess: { whenUnlockedThisDeviceOnly: 'device-only' }
},
'./cloud-sync-client': { createCloudSyncClient: () => assert.fail('status must only read storage') }
})
await api.configureMobileCloudAuth('test-version')
return api
}

for (const prefix of ['zennotes.cloud.', 'capacitor-storage_']) {
it(`loads a saved account from ${prefix} on every cold launch`, async () => {
const saved = new Map([[prefix + 'credential', credential]])
for (let launch = 0; launch < 2; launch++) {
const api = await coldLaunch(saved)
const statuses = await Promise.all([api.getMobileCloudAccountStatus(), api.getMobileCloudAccountStatus()])
assert.deepEqual(statuses, [{ state: 'connected', account }, { state: 'connected', account }])
assert.deepEqual([...saved.keys()], ['zennotes.cloud.credential'])
}
})
}

it('preserves the canonical account and prevents a legacy credential from returning after logout', async () => {
const saved = new Map([
['zennotes.cloud.credential', credential],
['capacitor-storage_credential', JSON.stringify({ base_url: account.base_url, token: 'old-test-token', account })]
])
const api = await coldLaunch(saved)
assert.equal((await api.authenticatedCredential()).token, 'test-only')
await api.logoutMobileCloudAccount()
assert.equal(saved.size, 0)
assert.deepEqual(await (await coldLaunch(saved)).getMobileCloudAccountStatus(), { state: 'disconnected', account: null })
})

it('rejects invalid recovered credentials through the shared auth validator', async () => {
const invalid = JSON.stringify({ base_url: 'https://wrong.example.test', token: 'test-only', account })
const saved = new Map([['capacitor-storage_credential', invalid]])
const api = await coldLaunch(saved)
assert.deepEqual(await api.getMobileCloudAccountStatus(), { state: 'disconnected', account: null })
assert.equal(saved.size, 0)
})

it('recovers pending sign-in state across cold launches', async () => {
const pending = JSON.stringify({
base_url: account.base_url, state: 'test-state', code_verifier: 'a'.repeat(43), expires_at: '2099-01-01T00:00:00Z'
})
const saved = new Map([['capacitor-storage_pending-auth', pending]])
for (let launch = 0; launch < 2; launch++) {
const api = await coldLaunch(saved)
assert.deepEqual(await api.getMobileCloudAccountStatus(), { state: 'connecting', account: null })
assert.deepEqual([...saved.entries()], [['zennotes.cloud.pending-auth', pending]])
}
})
69 changes: 56 additions & 13 deletions src/bridge/mobile-cloud-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,33 +34,76 @@ const accountListeners = new Set<(status: CloudAccountStatus) => void>()
let authFlow: CloudAuthFlow | null = null
let callbackQueue = Promise.resolve()

const secureStorageReady = Capacitor.isNativePlatform()
? Promise.all([
SecureStorage.setKeyPrefix('zennotes.cloud.'),
SecureStorage.setSynchronize(false),
SecureStorage.setDefaultKeychainAccess(KeychainAccess.whenUnlockedThisDeviceOnly)
]).then(() => undefined)
: Promise.resolve()
// Lazy and retryable so a transient native storage failure does not poison
// every later account read for the session.
let secureStorageSetup: Promise<void> | null = null
function secureStorageReady(): Promise<void> {
if (!Capacitor.isNativePlatform()) return Promise.resolve()
if (!secureStorageSetup) {
secureStorageSetup = configureSecureStorage()
secureStorageSetup.catch(() => {
secureStorageSetup = null
})
}
return secureStorageSetup
}

async function configureSecureStorage(): Promise<void> {
// The Capacitor proxy loads its implementation lazily. Parallel first
// calls can initialize separate instances and lose the configured prefix.
await SecureStorage.setKeyPrefix('zennotes.cloud.')
await SecureStorage.setSynchronize(false)
await SecureStorage.setDefaultKeychainAccess(KeychainAccess.whenUnlockedThisDeviceOnly)
await migrateAuthStoragePrefix()
}

async function migrateAuthStoragePrefix(): Promise<void> {
const keys = [CREDENTIAL_KEY, PENDING_AUTH_KEY]
const canonical = new Map<string, string | null>()
for (const key of keys) canonical.set(key, await SecureStorage.getItem(key))

// Affected builds could persist auth under the plugin's default prefix.
// All storage callers await setup, so none can see this temporary prefix.
// CloudAuthFlow still validates every recovered record before using it.
try {
await SecureStorage.setKeyPrefix('capacitor-storage_')
const legacy = new Map<string, string>()
for (const key of keys) {
const value = await SecureStorage.getItem(key)
if (value !== null) legacy.set(key, value)
}
await SecureStorage.setKeyPrefix('zennotes.cloud.')
for (const [key, value] of legacy) {
if (canonical.get(key) === null) await SecureStorage.setItem(key, value)
}
// Remove superseded records as well, so logout cannot resurrect an older
// credential on the next launch. Copying must finish before removal.
await SecureStorage.setKeyPrefix('capacitor-storage_')
for (const key of legacy.keys()) await SecureStorage.removeItem(key)
} finally {
await SecureStorage.setKeyPrefix('zennotes.cloud.')
}
}

const storage: CloudAuthStorage = {
async loadPending(): Promise<unknown> {
if (!Capacitor.isNativePlatform()) return null
await secureStorageReady
await secureStorageReady()
return parseStoredJson(await SecureStorage.getItem(PENDING_AUTH_KEY))
},
async savePending(pending: CloudAuthPending): Promise<void> {
assertNativeCloudAuth()
await secureStorageReady
await secureStorageReady()
await SecureStorage.setItem(PENDING_AUTH_KEY, JSON.stringify(pending))
},
async deletePending(): Promise<void> {
if (!Capacitor.isNativePlatform()) return
await secureStorageReady
await secureStorageReady()
await SecureStorage.removeItem(PENDING_AUTH_KEY)
},
async loadCredential(): Promise<unknown> {
if (!Capacitor.isNativePlatform()) return null
await secureStorageReady
await secureStorageReady()
const credential = migrateLegacyCloudCredential(
parseStoredJson(await SecureStorage.getItem(CREDENTIAL_KEY))
)
Expand All @@ -71,13 +114,13 @@ const storage: CloudAuthStorage = {
},
async saveCredential(credential: CloudAuthCredential): Promise<void> {
assertNativeCloudAuth()
await secureStorageReady
await secureStorageReady()
const canonicalCredential = migrateLegacyCloudCredential(credential).value
await SecureStorage.setItem(CREDENTIAL_KEY, JSON.stringify(canonicalCredential))
},
async deleteCredential(): Promise<void> {
if (!Capacitor.isNativePlatform()) return
await secureStorageReady
await secureStorageReady()
await SecureStorage.removeItem(CREDENTIAL_KEY)
}
}
Expand Down
Loading