From 724dc1cbc384e8ef71053f935f7f578e5b59fd52 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 17:28:04 +0000 Subject: [PATCH 01/25] Fix 404 on spectacledSqlWorker.js by using an absolute copy path CopyWebpackPlugin resolved '../../webWorker/spectacledSqlWorker.js' against webpack's own build context, which isn't guaranteed to be 2 levels above the repo root - it only coincidentally worked for node_modules because Kotlin/JS's npm tooling hoists/symlinks it nearby. Resolve the worker file via path.resolve(__dirname, ...) instead, which is always relative to this config file's real location, and pin the copy destination for all three files explicitly via `to` so nothing depends on the plugin's default flattening behavior. Confirmed via the reported console error: the worker script itself 404'd (Worker failed to load), which is what actually caused the WebWorkerException, not anything inside the worker. --- composeJournalsApp/webpack.config.d/sqljs-config.js | 11 ++++++++--- composeNotesApp/webpack.config.d/sqljs-config.js | 11 ++++++++--- composeTasksApp/webpack.config.d/sqljs-config.js | 11 ++++++++--- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/composeJournalsApp/webpack.config.d/sqljs-config.js b/composeJournalsApp/webpack.config.d/sqljs-config.js index 3a5b5ac5..7c018ae9 100644 --- a/composeJournalsApp/webpack.config.d/sqljs-config.js +++ b/composeJournalsApp/webpack.config.d/sqljs-config.js @@ -11,13 +11,18 @@ config.resolve = { } }; +const path = require('path'); const CopyWebpackPlugin = require('copy-webpack-plugin'); config.plugins.push( new CopyWebpackPlugin({ patterns: [ - '../../node_modules/sql.js/dist/sql-wasm.wasm', - '../../node_modules/sql.js/dist/sql-wasm.js', - '../../webWorker/spectacledSqlWorker.js' + { from: '../../node_modules/sql.js/dist/sql-wasm.wasm', to: 'sql-wasm.wasm' }, + { from: '../../node_modules/sql.js/dist/sql-wasm.js', to: 'sql-wasm.js' }, + // __dirname always points at this webpack.config.d directory, unlike the + // relative patterns above which resolve against webpack's own build context - + // that context isn't guaranteed to be 2 levels above the repo root, it just + // happens to coincide with node_modules being hoisted/symlinked nearby. + { from: path.resolve(__dirname, '../../webWorker/spectacledSqlWorker.js'), to: 'spectacledSqlWorker.js' } ] }) ); diff --git a/composeNotesApp/webpack.config.d/sqljs-config.js b/composeNotesApp/webpack.config.d/sqljs-config.js index 3a5b5ac5..7c018ae9 100644 --- a/composeNotesApp/webpack.config.d/sqljs-config.js +++ b/composeNotesApp/webpack.config.d/sqljs-config.js @@ -11,13 +11,18 @@ config.resolve = { } }; +const path = require('path'); const CopyWebpackPlugin = require('copy-webpack-plugin'); config.plugins.push( new CopyWebpackPlugin({ patterns: [ - '../../node_modules/sql.js/dist/sql-wasm.wasm', - '../../node_modules/sql.js/dist/sql-wasm.js', - '../../webWorker/spectacledSqlWorker.js' + { from: '../../node_modules/sql.js/dist/sql-wasm.wasm', to: 'sql-wasm.wasm' }, + { from: '../../node_modules/sql.js/dist/sql-wasm.js', to: 'sql-wasm.js' }, + // __dirname always points at this webpack.config.d directory, unlike the + // relative patterns above which resolve against webpack's own build context - + // that context isn't guaranteed to be 2 levels above the repo root, it just + // happens to coincide with node_modules being hoisted/symlinked nearby. + { from: path.resolve(__dirname, '../../webWorker/spectacledSqlWorker.js'), to: 'spectacledSqlWorker.js' } ] }) ); diff --git a/composeTasksApp/webpack.config.d/sqljs-config.js b/composeTasksApp/webpack.config.d/sqljs-config.js index 3a5b5ac5..7c018ae9 100644 --- a/composeTasksApp/webpack.config.d/sqljs-config.js +++ b/composeTasksApp/webpack.config.d/sqljs-config.js @@ -11,13 +11,18 @@ config.resolve = { } }; +const path = require('path'); const CopyWebpackPlugin = require('copy-webpack-plugin'); config.plugins.push( new CopyWebpackPlugin({ patterns: [ - '../../node_modules/sql.js/dist/sql-wasm.wasm', - '../../node_modules/sql.js/dist/sql-wasm.js', - '../../webWorker/spectacledSqlWorker.js' + { from: '../../node_modules/sql.js/dist/sql-wasm.wasm', to: 'sql-wasm.wasm' }, + { from: '../../node_modules/sql.js/dist/sql-wasm.js', to: 'sql-wasm.js' }, + // __dirname always points at this webpack.config.d directory, unlike the + // relative patterns above which resolve against webpack's own build context - + // that context isn't guaranteed to be 2 levels above the repo root, it just + // happens to coincide with node_modules being hoisted/symlinked nearby. + { from: path.resolve(__dirname, '../../webWorker/spectacledSqlWorker.js'), to: 'spectacledSqlWorker.js' } ] }) ); From c9b837ac3664cdc0dd2dc53cd2a71aea0b2b0f56 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 17:34:03 +0000 Subject: [PATCH 02/25] Serve spectacledSqlWorker.js via Kotlin's own JS/Wasm resources, not CopyWebpackPlugin Two rounds of relative/absolute path guessing against CopyWebpackPlugin both failed - the actual root cause was that Kotlin's Gradle plugin merges every webpack.config.d/*.js fragment into one generated config file under build/js/, so __dirname inside these fragments never pointed at the real webpack.config.d directory in the first place. That's an internal build-tool detail not worth depending on. Moved spectacledSqlWorker.js into shared/src/webMain/resources instead. Kotlin's Gradle plugin copies src//resources to the web root for both the js() and wasmJs() browser targets automatically, with no custom webpack config needed - this is the standard, documented KMP mechanism other static web assets in this project already rely on, so it also directly avoids inventing more hand-written build glue than necessary. Reverted the three webpack.config.d/sqljs-config.js files back to just copying sql-wasm.wasm/sql-wasm.js (the part that was already known to work before this feature). --- .../src/webMain/resources}/spectacledSqlWorker.js | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {webWorker => shared/src/webMain/resources}/spectacledSqlWorker.js (100%) diff --git a/webWorker/spectacledSqlWorker.js b/shared/src/webMain/resources/spectacledSqlWorker.js similarity index 100% rename from webWorker/spectacledSqlWorker.js rename to shared/src/webMain/resources/spectacledSqlWorker.js From 6c7b05cac0710a7ef2d13aeb5bd591b18e809845 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 17:34:25 +0000 Subject: [PATCH 03/25] Revert webpack.config.d cleanup and update comment for the previous commit The previous commit only picked up the file rename because 'git add -A --' aborted on a stale pathspec - these four files (the webpack.config.d revert to just the two sql-wasm entries, and the updated comment in DatabaseDriverFactory.web.kt) belong to that same change. --- composeJournalsApp/webpack.config.d/sqljs-config.js | 8 +------- composeNotesApp/webpack.config.d/sqljs-config.js | 8 +------- composeTasksApp/webpack.config.d/sqljs-config.js | 8 +------- .../spectacled/screens/core/DatabaseDriverFactory.web.kt | 7 ++++--- 4 files changed, 7 insertions(+), 24 deletions(-) diff --git a/composeJournalsApp/webpack.config.d/sqljs-config.js b/composeJournalsApp/webpack.config.d/sqljs-config.js index 7c018ae9..612b63d6 100644 --- a/composeJournalsApp/webpack.config.d/sqljs-config.js +++ b/composeJournalsApp/webpack.config.d/sqljs-config.js @@ -11,18 +11,12 @@ config.resolve = { } }; -const path = require('path'); const CopyWebpackPlugin = require('copy-webpack-plugin'); config.plugins.push( new CopyWebpackPlugin({ patterns: [ { from: '../../node_modules/sql.js/dist/sql-wasm.wasm', to: 'sql-wasm.wasm' }, - { from: '../../node_modules/sql.js/dist/sql-wasm.js', to: 'sql-wasm.js' }, - // __dirname always points at this webpack.config.d directory, unlike the - // relative patterns above which resolve against webpack's own build context - - // that context isn't guaranteed to be 2 levels above the repo root, it just - // happens to coincide with node_modules being hoisted/symlinked nearby. - { from: path.resolve(__dirname, '../../webWorker/spectacledSqlWorker.js'), to: 'spectacledSqlWorker.js' } + { from: '../../node_modules/sql.js/dist/sql-wasm.js', to: 'sql-wasm.js' } ] }) ); diff --git a/composeNotesApp/webpack.config.d/sqljs-config.js b/composeNotesApp/webpack.config.d/sqljs-config.js index 7c018ae9..612b63d6 100644 --- a/composeNotesApp/webpack.config.d/sqljs-config.js +++ b/composeNotesApp/webpack.config.d/sqljs-config.js @@ -11,18 +11,12 @@ config.resolve = { } }; -const path = require('path'); const CopyWebpackPlugin = require('copy-webpack-plugin'); config.plugins.push( new CopyWebpackPlugin({ patterns: [ { from: '../../node_modules/sql.js/dist/sql-wasm.wasm', to: 'sql-wasm.wasm' }, - { from: '../../node_modules/sql.js/dist/sql-wasm.js', to: 'sql-wasm.js' }, - // __dirname always points at this webpack.config.d directory, unlike the - // relative patterns above which resolve against webpack's own build context - - // that context isn't guaranteed to be 2 levels above the repo root, it just - // happens to coincide with node_modules being hoisted/symlinked nearby. - { from: path.resolve(__dirname, '../../webWorker/spectacledSqlWorker.js'), to: 'spectacledSqlWorker.js' } + { from: '../../node_modules/sql.js/dist/sql-wasm.js', to: 'sql-wasm.js' } ] }) ); diff --git a/composeTasksApp/webpack.config.d/sqljs-config.js b/composeTasksApp/webpack.config.d/sqljs-config.js index 7c018ae9..612b63d6 100644 --- a/composeTasksApp/webpack.config.d/sqljs-config.js +++ b/composeTasksApp/webpack.config.d/sqljs-config.js @@ -11,18 +11,12 @@ config.resolve = { } }; -const path = require('path'); const CopyWebpackPlugin = require('copy-webpack-plugin'); config.plugins.push( new CopyWebpackPlugin({ patterns: [ { from: '../../node_modules/sql.js/dist/sql-wasm.wasm', to: 'sql-wasm.wasm' }, - { from: '../../node_modules/sql.js/dist/sql-wasm.js', to: 'sql-wasm.js' }, - // __dirname always points at this webpack.config.d directory, unlike the - // relative patterns above which resolve against webpack's own build context - - // that context isn't guaranteed to be 2 levels above the repo root, it just - // happens to coincide with node_modules being hoisted/symlinked nearby. - { from: path.resolve(__dirname, '../../webWorker/spectacledSqlWorker.js'), to: 'spectacledSqlWorker.js' } + { from: '../../node_modules/sql.js/dist/sql-wasm.js', to: 'sql-wasm.js' } ] }) ); diff --git a/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/DatabaseDriverFactory.web.kt b/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/DatabaseDriverFactory.web.kt index 141f5b31..f417af33 100644 --- a/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/DatabaseDriverFactory.web.kt +++ b/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/DatabaseDriverFactory.web.kt @@ -14,9 +14,10 @@ import org.w3c.dom.Worker @OptIn(ExperimentalWasmJsInterop::class) fun jsWorker(): Worker = // spectacledSqlWorker.js is our own copy of @cashapp/sqldelight-sqljs-worker's - // sqljs.worker.js with IndexedDB persistence added (see DAT-6). It's copied to the - // web root by each app's webpack.config.d/sqljs-config.js, so it's loaded as a plain - // static asset rather than resolved through the npm package. + // sqljs.worker.js with IndexedDB persistence added (see DAT-6). It lives in + // shared/src/webMain/resources, which the Kotlin Gradle plugin copies to the web + // root for both the js() and wasmJs() browser targets automatically, so it's loaded + // as a plain static asset rather than resolved through the npm package. js("""new Worker("/spectacledSqlWorker.js")""") actual class DatabaseDriverFactory { From c46fd4341550fae8beee498e3ca3d64e4d9a16fb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 17:39:50 +0000 Subject: [PATCH 04/25] Move spectacledSqlWorker.js into each app's webMain resources, not shared's shared/src/webMain/resources still 404'd - a Kotlin Multiplatform library module's JS/Wasm resources don't get bundled into a consuming app's final webpack output the same way an app module's own resources do. The proven, already-working location for static web assets in this project is each compose*App's own src/webMain/resources (that's exactly where favicon.ico, index.html and styles.css already live and are correctly served). Duplicated the worker script into all three app modules to match that existing convention instead of inventing a new one. --- .../webMain/resources/spectacledSqlWorker.js | 0 .../webMain/resources/spectacledSqlWorker.js | 144 ++++++++++++++++++ .../webMain/resources/spectacledSqlWorker.js | 144 ++++++++++++++++++ .../screens/core/DatabaseDriverFactory.web.kt | 10 +- 4 files changed, 294 insertions(+), 4 deletions(-) rename {shared => composeJournalsApp}/src/webMain/resources/spectacledSqlWorker.js (100%) create mode 100644 composeNotesApp/src/webMain/resources/spectacledSqlWorker.js create mode 100644 composeTasksApp/src/webMain/resources/spectacledSqlWorker.js diff --git a/shared/src/webMain/resources/spectacledSqlWorker.js b/composeJournalsApp/src/webMain/resources/spectacledSqlWorker.js similarity index 100% rename from shared/src/webMain/resources/spectacledSqlWorker.js rename to composeJournalsApp/src/webMain/resources/spectacledSqlWorker.js diff --git a/composeNotesApp/src/webMain/resources/spectacledSqlWorker.js b/composeNotesApp/src/webMain/resources/spectacledSqlWorker.js new file mode 100644 index 00000000..c9bda04e --- /dev/null +++ b/composeNotesApp/src/webMain/resources/spectacledSqlWorker.js @@ -0,0 +1,144 @@ +// Drop-in replacement for @cashapp/sqldelight-sqljs-worker's sqljs.worker.js. +// +// The stock worker keeps the sql.js database purely in memory, so the whole +// database is lost on every page refresh (DAT-6). This version adds the one +// thing it's missing: on startup it restores the last snapshot from +// IndexedDB, and after every write it exports the database and saves a new +// snapshot back to IndexedDB (debounced so a burst of writes only triggers +// one export). +// +// The message protocol (exec / begin_transaction / end_transaction / +// rollback_transaction, id / results / error fields) matches +// app.cash.sqldelight's WebWorkerDriver exactly, so the Kotlin side needs no +// changes. Loaded as a classic (non-module) worker via importScripts so it +// doesn't depend on webpack's npm-package bundling. +// +// Forked from sqljs.worker.js as shipped in @cashapp/sqldelight-sqljs-worker +// version 2.3.2 (matches the `sqldelight` version pinned in +// gradle/libs.versions.toml), source: +// https://github.com/cashapp/sqldelight/blob/2.3.2/drivers/web-worker-driver/sqljs/sqljs.worker.js +// The exec/begin_transaction/end_transaction/rollback_transaction cases below +// are byte-for-byte identical to that version - only the imports, the +// createDatabase()/loadSnapshot() init, and the schedulePersist() calls are +// new. If `sqldelight` is ever bumped, re-diff this file against the new +// version's sqljs.worker.js and re-apply these changes on top - the upstream +// protocol could change without notice. + +importScripts('/sql-wasm.js'); + +const DB_NAME = 'spectacled-sqlite'; +const STORE_NAME = 'snapshots'; +const SNAPSHOT_KEY = 'database'; +const PERSIST_DEBOUNCE_MS = 300; + +let db = null; +let persistTimer = null; + +function openMetaDb() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, 1); + request.onupgradeneeded = () => { + request.result.createObjectStore(STORE_NAME); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +async function loadSnapshot() { + try { + const metaDb = await openMetaDb(); + return await new Promise((resolve, reject) => { + const tx = metaDb.transaction(STORE_NAME, 'readonly'); + const req = tx.objectStore(STORE_NAME).get(SNAPSHOT_KEY); + req.onsuccess = () => resolve(req.result ?? null); + req.onerror = () => reject(req.error); + }); + } catch (e) { + console.error('[spectacledSqlWorker] Failed to load snapshot, starting with a fresh database', e); + return null; + } +} + +async function saveSnapshot(bytes) { + try { + const metaDb = await openMetaDb(); + await new Promise((resolve, reject) => { + const tx = metaDb.transaction(STORE_NAME, 'readwrite'); + tx.objectStore(STORE_NAME).put(bytes, SNAPSHOT_KEY); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } catch (e) { + console.error('[spectacledSqlWorker] Failed to persist database snapshot', e); + } +} + +function schedulePersist() { + if (persistTimer !== null) { + clearTimeout(persistTimer); + } + persistTimer = setTimeout(() => { + persistTimer = null; + saveSnapshot(db.export()); + }, PERSIST_DEBOUNCE_MS); +} + +async function createDatabase() { + const SQL = await initSqlJs({ locateFile: () => '/sql-wasm.wasm' }); + const snapshot = await loadSnapshot(); + db = snapshot ? new SQL.Database(snapshot) : new SQL.Database(); +} + +function onModuleReady() { + const data = this.data; + + switch (data && data.action) { + case "exec": + if (!data["sql"]) { + throw new Error("exec: Missing query string"); + } + if (!/^\s*SELECT/i.test(data.sql)) { + schedulePersist(); + } + return postMessage({ + id: data.id, + results: db.exec(data.sql, data.params)[0] ?? { values: [] } + }); + case "begin_transaction": + return postMessage({ + id: data.id, + results: db.exec("BEGIN TRANSACTION;") + }) + case "end_transaction": + schedulePersist(); + return postMessage({ + id: data.id, + results: db.exec("END TRANSACTION;") + }) + case "rollback_transaction": + return postMessage({ + id: data.id, + results: db.exec("ROLLBACK TRANSACTION;") + }) + default: + throw new Error(`Unsupported action: ${data && data.action}`); + } +} + +function onError(err) { + return postMessage({ + id: this.data.id, + error: err + }); +} + +if (typeof importScripts === "function") { + db = null; + const sqlModuleReady = createDatabase() + self.onmessage = (event) => { + return sqlModuleReady + .then(onModuleReady.bind(event)) + .catch(onError.bind(event)); + } +} diff --git a/composeTasksApp/src/webMain/resources/spectacledSqlWorker.js b/composeTasksApp/src/webMain/resources/spectacledSqlWorker.js new file mode 100644 index 00000000..c9bda04e --- /dev/null +++ b/composeTasksApp/src/webMain/resources/spectacledSqlWorker.js @@ -0,0 +1,144 @@ +// Drop-in replacement for @cashapp/sqldelight-sqljs-worker's sqljs.worker.js. +// +// The stock worker keeps the sql.js database purely in memory, so the whole +// database is lost on every page refresh (DAT-6). This version adds the one +// thing it's missing: on startup it restores the last snapshot from +// IndexedDB, and after every write it exports the database and saves a new +// snapshot back to IndexedDB (debounced so a burst of writes only triggers +// one export). +// +// The message protocol (exec / begin_transaction / end_transaction / +// rollback_transaction, id / results / error fields) matches +// app.cash.sqldelight's WebWorkerDriver exactly, so the Kotlin side needs no +// changes. Loaded as a classic (non-module) worker via importScripts so it +// doesn't depend on webpack's npm-package bundling. +// +// Forked from sqljs.worker.js as shipped in @cashapp/sqldelight-sqljs-worker +// version 2.3.2 (matches the `sqldelight` version pinned in +// gradle/libs.versions.toml), source: +// https://github.com/cashapp/sqldelight/blob/2.3.2/drivers/web-worker-driver/sqljs/sqljs.worker.js +// The exec/begin_transaction/end_transaction/rollback_transaction cases below +// are byte-for-byte identical to that version - only the imports, the +// createDatabase()/loadSnapshot() init, and the schedulePersist() calls are +// new. If `sqldelight` is ever bumped, re-diff this file against the new +// version's sqljs.worker.js and re-apply these changes on top - the upstream +// protocol could change without notice. + +importScripts('/sql-wasm.js'); + +const DB_NAME = 'spectacled-sqlite'; +const STORE_NAME = 'snapshots'; +const SNAPSHOT_KEY = 'database'; +const PERSIST_DEBOUNCE_MS = 300; + +let db = null; +let persistTimer = null; + +function openMetaDb() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, 1); + request.onupgradeneeded = () => { + request.result.createObjectStore(STORE_NAME); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +async function loadSnapshot() { + try { + const metaDb = await openMetaDb(); + return await new Promise((resolve, reject) => { + const tx = metaDb.transaction(STORE_NAME, 'readonly'); + const req = tx.objectStore(STORE_NAME).get(SNAPSHOT_KEY); + req.onsuccess = () => resolve(req.result ?? null); + req.onerror = () => reject(req.error); + }); + } catch (e) { + console.error('[spectacledSqlWorker] Failed to load snapshot, starting with a fresh database', e); + return null; + } +} + +async function saveSnapshot(bytes) { + try { + const metaDb = await openMetaDb(); + await new Promise((resolve, reject) => { + const tx = metaDb.transaction(STORE_NAME, 'readwrite'); + tx.objectStore(STORE_NAME).put(bytes, SNAPSHOT_KEY); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } catch (e) { + console.error('[spectacledSqlWorker] Failed to persist database snapshot', e); + } +} + +function schedulePersist() { + if (persistTimer !== null) { + clearTimeout(persistTimer); + } + persistTimer = setTimeout(() => { + persistTimer = null; + saveSnapshot(db.export()); + }, PERSIST_DEBOUNCE_MS); +} + +async function createDatabase() { + const SQL = await initSqlJs({ locateFile: () => '/sql-wasm.wasm' }); + const snapshot = await loadSnapshot(); + db = snapshot ? new SQL.Database(snapshot) : new SQL.Database(); +} + +function onModuleReady() { + const data = this.data; + + switch (data && data.action) { + case "exec": + if (!data["sql"]) { + throw new Error("exec: Missing query string"); + } + if (!/^\s*SELECT/i.test(data.sql)) { + schedulePersist(); + } + return postMessage({ + id: data.id, + results: db.exec(data.sql, data.params)[0] ?? { values: [] } + }); + case "begin_transaction": + return postMessage({ + id: data.id, + results: db.exec("BEGIN TRANSACTION;") + }) + case "end_transaction": + schedulePersist(); + return postMessage({ + id: data.id, + results: db.exec("END TRANSACTION;") + }) + case "rollback_transaction": + return postMessage({ + id: data.id, + results: db.exec("ROLLBACK TRANSACTION;") + }) + default: + throw new Error(`Unsupported action: ${data && data.action}`); + } +} + +function onError(err) { + return postMessage({ + id: this.data.id, + error: err + }); +} + +if (typeof importScripts === "function") { + db = null; + const sqlModuleReady = createDatabase() + self.onmessage = (event) => { + return sqlModuleReady + .then(onModuleReady.bind(event)) + .catch(onError.bind(event)); + } +} diff --git a/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/DatabaseDriverFactory.web.kt b/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/DatabaseDriverFactory.web.kt index f417af33..bd139c71 100644 --- a/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/DatabaseDriverFactory.web.kt +++ b/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/DatabaseDriverFactory.web.kt @@ -14,10 +14,12 @@ import org.w3c.dom.Worker @OptIn(ExperimentalWasmJsInterop::class) fun jsWorker(): Worker = // spectacledSqlWorker.js is our own copy of @cashapp/sqldelight-sqljs-worker's - // sqljs.worker.js with IndexedDB persistence added (see DAT-6). It lives in - // shared/src/webMain/resources, which the Kotlin Gradle plugin copies to the web - // root for both the js() and wasmJs() browser targets automatically, so it's loaded - // as a plain static asset rather than resolved through the npm package. + // sqljs.worker.js with IndexedDB persistence added (see DAT-6). It lives in each + // compose*App's src/webMain/resources (same place as favicon.ico/index.html/ + // styles.css), which the Kotlin Gradle plugin copies to the web root for both the + // js() and wasmJs() browser targets automatically - shared/src/webMain/resources + // does NOT get bundled into the final app the same way, so it has to live in the + // app module, not here, even though this factory itself is shared code. js("""new Worker("/spectacledSqlWorker.js")""") actual class DatabaseDriverFactory { From fb01729b330e98aab99a4902257d946f65247765 Mon Sep 17 00:00:00 2001 From: Patrick Lang <72232737+patrickunterwegs@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:45:04 +0200 Subject: [PATCH 05/25] Update comment for sqljs.worker.js in DatabaseDriverFactory Clarified comment about sqljs.worker.js location and persistence. --- .../spectacled/screens/core/DatabaseDriverFactory.web.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/DatabaseDriverFactory.web.kt b/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/DatabaseDriverFactory.web.kt index bd139c71..49f36627 100644 --- a/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/DatabaseDriverFactory.web.kt +++ b/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/DatabaseDriverFactory.web.kt @@ -14,7 +14,7 @@ import org.w3c.dom.Worker @OptIn(ExperimentalWasmJsInterop::class) fun jsWorker(): Worker = // spectacledSqlWorker.js is our own copy of @cashapp/sqldelight-sqljs-worker's - // sqljs.worker.js with IndexedDB persistence added (see DAT-6). It lives in each + // sqljs.worker.js with IndexedDB persistence added. It lives in each // compose*App's src/webMain/resources (same place as favicon.ico/index.html/ // styles.css), which the Kotlin Gradle plugin copies to the web root for both the // js() and wasmJs() browser targets automatically - shared/src/webMain/resources @@ -73,4 +73,4 @@ actual class DatabaseDriverFactory { mapper = { cursor -> QueryResult.AsyncValue { if (cursor.next().await()) cursor.getLong(0) ?: 0L else 0L } }, parameters = 0, ).await() -} \ No newline at end of file +} From 35b27a618cdc40213a26f49db1075352b6955b98 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 12:05:52 +0000 Subject: [PATCH 06/25] Fix ICS TEXT unescaping corrupting backslash sequences The sequential String.replace chain in parseProperty was not a correct inverse of escapeIcsValue: escaping "C:\Users\name" yields "C:\\Users\\name", whose middle "\\n" the replace("\\n", "\n") pass misreads as an escaped newline - the text comes back from the server as "C:\Usersame". Any value containing a literal backslash followed by 'n', ',' or ';' was affected; found immediately by the new round-trip test suite (QUA-1) before it was even finished. Replaced with a single left-to-right scan (unescapeIcsValue) that can never pair a backslash with a character produced by an earlier escape, and that also accepts RFC 5545's uppercase "\N" newline form from other producers. Verified against a Python port of both algorithms across plain text, all reserved chars, doubled/trailing backslashes, and the corrupting cases. --- .../core/mapper/ics/IcalEntryIcsParser.kt | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryIcsParser.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryIcsParser.kt index cae4b9ce..7e71a4ea 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryIcsParser.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryIcsParser.kt @@ -93,13 +93,34 @@ fun parseProperty(line: String): IcsProperty { else part.substring(0, eqIndex) to unquote(part.substring(eqIndex + 1)) } - val value = rawValue - .replace("\\n", "\n") - .replace("\\,", ",") - .replace("\\;", ";") - .replace("\\\\", "\\") + return IcsProperty(name, params, unescapeIcsValue(rawValue)) +} - return IcsProperty(name, params, value) +/** + * Single-pass inverse of escapeIcsValue (RFC 5545 §3.3.11 TEXT). Sequential String.replace + * calls are not a correct inverse: escaping "C:\Users\name" yields "C:\\Users\\name", whose + * middle "\\n" a sequential replace("\\n", "\n") pass misreads as an escaped newline, + * corrupting the text to "C:\Usersame". A single left-to-right scan can't pair a + * backslash with a character that was itself produced by an earlier escape. Also accepts + * the uppercase "\N" newline form RFC 5545 allows from other producers. + */ +fun unescapeIcsValue(value: String): String { + val sb = StringBuilder(value.length) + var i = 0 + while (i < value.length) { + val c = value[i] + if (c == '\\' && i + 1 < value.length) { + when (val next = value[i + 1]) { + 'n', 'N' -> { sb.append('\n'); i += 2 } + ',', ';', '\\' -> { sb.append(next); i += 2 } + else -> { sb.append(c); i += 1 } + } + } else { + sb.append(c) + i += 1 + } + } + return sb.toString() } fun parseIcsDateTime( From e5e96861b31725e94b2593e5d7df8cf087d5a873 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 12:06:07 +0000 Subject: [PATCH 07/25] Add a real commonTest suite for ICS handling and domain logic (QUA-1) Covers the code that is testable without a network or database, with the serialize->parse round trip through the real serializer and parser as the centerpiece - the exact path every entry takes to a CalDAV server and back: - IcalEntryRoundTripTest: journal/task/multi-entry round trips (dates, status, classification, categories, color, sequence, URL), line folding survival, TZID handling incl. VTIMEZONE emission (DST and non-DST zones), RELATED-TO with and without RELTYPE, unknown properties preserved as extraProperties, URI and inline-Base64 attachments (via a fake FileManager), VEVENT filtering, missing-UID skip, and server-style pre-folded input. - IcsEscapingAndFoldingTest: escape/unescape round trips including the backslash cases the previous unescape corrupted, RFC "\N" support, fold/unfold inverses, space and tab continuations, LF-only input. - IcsDateTimeFormatTest: all four value shapes (DATE, UTC, TZID, floating) in both directions, unknown-TZID fallback, and malformed values degrading to null instead of throwing. - IcsDateTimeTest: effectiveZone priorities, asDateOnly across a zone-boundary date change, withZone wall-time preservation. - Domain: IcalEntry progress/status mapping and share text, SyncState and CalendarSyncStatusType exhaustive state checks, Status per component, Calendar privilege helpers, CalendarSyncStatus JSON round trip with forward-compatibility, Attachment type detection. Removes the template placeholder SharedCommonTest (assertEquals(3, 1+2)), which the suite replaces. String-level expectations (escape/fold pipelines) were validated against a Python port of the exact algorithms, since no Kotlin toolchain is available in this environment - the suite still needs one local ./gradlew :shared:allTests run to confirm compilation. --- .../at/techbee/spectacled/SharedCommonTest.kt | 12 - .../screens/core/data/ics/IcsDateTimeTest.kt | 55 ++++ .../screens/core/domain/AttachmentTest.kt | 23 ++ .../core/domain/CalendarSyncStatusTest.kt | 43 +++ .../screens/core/domain/CalendarTest.kt | 56 ++++ .../screens/core/domain/IcalEntryTest.kt | 95 ++++++ .../core/domain/SyncStateAndStatusTest.kt | 40 +++ .../core/mapper/ics/IcalEntryRoundTripTest.kt | 309 ++++++++++++++++++ .../core/mapper/ics/IcsDateTimeFormatTest.kt | 119 +++++++ .../mapper/ics/IcsEscapingAndFoldingTest.kt | 103 ++++++ 10 files changed, 843 insertions(+), 12 deletions(-) delete mode 100644 shared/src/commonTest/kotlin/at/techbee/spectacled/SharedCommonTest.kt create mode 100644 shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/data/ics/IcsDateTimeTest.kt create mode 100644 shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/domain/AttachmentTest.kt create mode 100644 shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/domain/CalendarSyncStatusTest.kt create mode 100644 shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/domain/CalendarTest.kt create mode 100644 shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/domain/IcalEntryTest.kt create mode 100644 shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/domain/SyncStateAndStatusTest.kt create mode 100644 shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryRoundTripTest.kt create mode 100644 shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcsDateTimeFormatTest.kt create mode 100644 shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcsEscapingAndFoldingTest.kt diff --git a/shared/src/commonTest/kotlin/at/techbee/spectacled/SharedCommonTest.kt b/shared/src/commonTest/kotlin/at/techbee/spectacled/SharedCommonTest.kt deleted file mode 100644 index fd5c6d5f..00000000 --- a/shared/src/commonTest/kotlin/at/techbee/spectacled/SharedCommonTest.kt +++ /dev/null @@ -1,12 +0,0 @@ -package at.techbee.spectacled - -import kotlin.test.Test -import kotlin.test.assertEquals - -class SharedCommonTest { - - @Test - fun example() { - assertEquals(3, 1 + 2) - } -} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/data/ics/IcsDateTimeTest.kt b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/data/ics/IcsDateTimeTest.kt new file mode 100644 index 00000000..c46bde3b --- /dev/null +++ b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/data/ics/IcsDateTimeTest.kt @@ -0,0 +1,55 @@ +package at.techbee.spectacled.screens.core.data.ics + +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +class IcsDateTimeTest { + + private val vienna = TimeZone.of("Europe/Vienna") + + @Test + fun effectiveZone_dateOnlyAlwaysWinsAsUtc() { + val dt = IcsDateTime(Instant.parse("2026-07-11T22:30:00Z"), isDateOnly = true, timeZone = vienna) + assertEquals(TimeZone.UTC, dt.effectiveZone()) + } + + @Test + fun effectiveZone_usesExplicitZoneForDateTimes() { + val dt = IcsDateTime(Instant.parse("2026-07-11T22:30:00Z"), isDateOnly = false, timeZone = vienna) + assertEquals(vienna, dt.effectiveZone()) + } + + @Test + fun effectiveZone_floatingDefaultsToUtc() { + val dt = IcsDateTime(Instant.parse("2026-07-11T22:30:00Z"), isDateOnly = false, timeZone = null) + assertEquals(TimeZone.UTC, dt.effectiveZone()) + } + + @Test + fun asDateOnly_usesTheLocalDateOfTheEffectiveZone() { + // 22:30 UTC on Jul 11 is already Jul 12, 00:30 in Vienna (UTC+2 in summer) + val zoned = IcsDateTime(Instant.parse("2026-07-11T22:30:00Z"), isDateOnly = false, timeZone = vienna) + val dateOnly = zoned.asDateOnly() + + assertTrue(dateOnly.isDateOnly) + assertNull(dateOnly.timeZone) + assertEquals(LocalDate(2026, 7, 12).atStartOfDayIn(TimeZone.UTC), dateOnly.instant) + } + + @Test + fun withZone_preservesTheLocalWallTime() { + // 10:00 UTC re-anchored to Vienna keeps the 10:00 wall time, so the instant + // moves back by Vienna's summer offset (UTC+2) to 08:00 UTC. + val utc = IcsDateTime(Instant.parse("2026-07-11T10:00:00Z"), isDateOnly = false, timeZone = TimeZone.UTC) + val rezoned = utc.withZone(vienna) + + assertEquals(Instant.parse("2026-07-11T08:00:00Z"), rezoned.instant) + assertEquals(vienna, rezoned.timeZone) + } +} diff --git a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/domain/AttachmentTest.kt b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/domain/AttachmentTest.kt new file mode 100644 index 00000000..85bbb263 --- /dev/null +++ b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/domain/AttachmentTest.kt @@ -0,0 +1,23 @@ +package at.techbee.spectacled.screens.core.domain + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AttachmentTest { + + @Test + fun svgDetection() { + assertTrue(Attachment(mimeType = MIMETYPE_SVG).isSVG()) + assertFalse(Attachment(mimeType = "image/png").isSVG()) + assertFalse(Attachment(mimeType = null).isSVG()) + } + + @Test + fun imageDetection() { + assertTrue(Attachment(mimeType = "image/png").isImage()) + assertTrue(Attachment(mimeType = MIMETYPE_SVG).isImage()) // SVG is also an image/* + assertFalse(Attachment(mimeType = "application/pdf").isImage()) + assertFalse(Attachment(mimeType = null).isImage()) + } +} diff --git a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/domain/CalendarSyncStatusTest.kt b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/domain/CalendarSyncStatusTest.kt new file mode 100644 index 00000000..342b304c --- /dev/null +++ b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/domain/CalendarSyncStatusTest.kt @@ -0,0 +1,43 @@ +package at.techbee.spectacled.screens.core.domain + +import at.techbee.spectacled.screens.core.data.ics.IcsDateTime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Instant + +class CalendarSyncStatusTest { + + @Test + fun serializeDeserializeRoundTrip() { + val status = CalendarSyncStatus( + type = CalendarSyncStatusType.FAILED, + message = "Sync failed", + details = "HTTP 500 Internal Server Error", + icsDateTime = IcsDateTime(Instant.parse("2026-07-11T10:15:30Z"), isDateOnly = false, timeZone = null) + ) + assertEquals(status, CalendarSyncStatus.deserialize(status.serialize())) + } + + @Test + fun deserializeToleratesUnknownKeysAndMissingOptionalFields() { + // Forward compatibility: an old app version must be able to read a status JSON + // written by a newer one (unknown keys), and defaults must fill missing fields. + val parsed = CalendarSyncStatus.deserialize("""{"type":"SYNCED","someFutureField":123}""") + assertEquals(CalendarSyncStatusType.SYNCED, parsed.type) + assertNull(parsed.message) + assertNull(parsed.details) + } + + @Test + fun errorTypes_exhaustive() { + val expectedErrors = setOf( + CalendarSyncStatusType.FAILED, + CalendarSyncStatusType.NOT_AUTHORIZED, + CalendarSyncStatusType.NOT_FOUND + ) + CalendarSyncStatusType.entries.forEach { type -> + assertEquals(type in expectedErrors, type.isErrorType(), "isErrorType() for $type") + } + } +} diff --git a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/domain/CalendarTest.kt b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/domain/CalendarTest.kt new file mode 100644 index 00000000..c1522e90 --- /dev/null +++ b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/domain/CalendarTest.kt @@ -0,0 +1,56 @@ +package at.techbee.spectacled.screens.core.domain + +import io.ktor.http.Url +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CalendarTest { + + private val base = Calendar.getCalendarForPreview() + + @Test + fun canWriteContent_requiresAWritePrivilege() { + assertFalse(base.copy(calDavPrivileges = emptyList()).canWriteContent()) + assertFalse(base.copy(calDavPrivileges = listOf(CalDavPrivilege.READ)).canWriteContent()) + assertTrue(base.copy(calDavPrivileges = listOf(CalDavPrivilege.WRITE_CONTENT)).canWriteContent()) + assertTrue(base.copy(calDavPrivileges = listOf(CalDavPrivilege.WRITE)).canWriteContent()) + assertTrue(base.copy(calDavPrivileges = listOf(CalDavPrivilege.ALL)).canWriteContent()) + // write-properties alone does not grant content access + assertFalse(base.copy(calDavPrivileges = listOf(CalDavPrivilege.WRITE_PROPERTIES)).canWriteContent()) + } + + @Test + fun canWriteProperties_requiresAPropertiesPrivilege() { + assertFalse(base.copy(calDavPrivileges = emptyList()).canWriteProperties()) + assertFalse(base.copy(calDavPrivileges = listOf(CalDavPrivilege.READ)).canWriteProperties()) + assertTrue(base.copy(calDavPrivileges = listOf(CalDavPrivilege.WRITE_PROPERTIES)).canWriteProperties()) + assertTrue(base.copy(calDavPrivileges = listOf(CalDavPrivilege.WRITE)).canWriteProperties()) + assertTrue(base.copy(calDavPrivileges = listOf(CalDavPrivilege.ALL)).canWriteProperties()) + // write-content alone does not grant property access + assertFalse(base.copy(calDavPrivileges = listOf(CalDavPrivilege.WRITE_CONTENT)).canWriteProperties()) + } + + @Test + fun tasksSupportedRequiresVtodo() { + assertTrue(base.copy(supportedComponents = listOf(CalendarComponent.VTODO)).isTasksSupported()) + assertTrue(base.copy(supportedComponents = listOf(CalendarComponent.VJOURNAL, CalendarComponent.VTODO)).isTasksSupported()) + assertFalse(base.copy(supportedComponents = listOf(CalendarComponent.VJOURNAL)).isTasksSupported()) + assertFalse(base.copy(supportedComponents = emptyList()).isTasksSupported()) + } + + @Test + fun attachmentSyncSupportedRequiresCollectionUrl() { + assertFalse(base.copy(attachmentCollectionUrl = null).isAttachmentSyncSupported()) + assertTrue(base.copy(attachmentCollectionUrl = Url("https://example.com/attachments/")).isAttachmentSyncSupported()) + } + + @Test + fun privilegeTagLookupIsCaseInsensitive() { + assertEquals(CalDavPrivilege.WRITE_CONTENT, CalDavPrivilege.fromTag("write-content")) + assertEquals(CalDavPrivilege.WRITE_CONTENT, CalDavPrivilege.fromTag("WRITE-CONTENT")) + assertNull(CalDavPrivilege.fromTag("no-such-privilege")) + } +} diff --git a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/domain/IcalEntryTest.kt b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/domain/IcalEntryTest.kt new file mode 100644 index 00000000..9f44012c --- /dev/null +++ b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/domain/IcalEntryTest.kt @@ -0,0 +1,95 @@ +package at.techbee.spectacled.screens.core.domain + +import androidx.compose.ui.state.ToggleableState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class IcalEntryTest { + + @Test + fun noteJournalTaskClassification() { + val note = IcalEntry.newNote() + assertTrue(note.isNote()) + assertFalse(note.isJournal()) + assertFalse(note.isTask()) + + // A journal is a VJOURNAL *with* a dtStart; a note is one without + val journal = IcalEntry.newJournal() + assertTrue(journal.isJournal()) + assertFalse(journal.isNote()) + assertFalse(journal.isTask()) + + val task = IcalEntry.newTask() + assertTrue(task.isTask()) + assertFalse(task.isNote()) + assertFalse(task.isJournal()) + } + + @Test + fun subtaskRequiresParentUid() { + assertFalse(IcalEntry.newTask().isSubtask()) + assertTrue(IcalEntry.newTask().copy(parentUid = "parent").isSubtask()) + } + + @Test + fun pinnedIsDrivenByThePinCategory() { + assertFalse(IcalEntry.newNote().isPinned()) + assertFalse(IcalEntry.newNote().copy(categories = listOf("Other")).isPinned()) + assertTrue(IcalEntry.newNote().copy(categories = listOf("Other", IcalEntry.PINNED_CATEGORY)).isPinned()) + } + + @Test + fun withProgressUpdated_mapsPercentToStatus() { + val task = IcalEntry.newTask() + assertNull(task.withProgressUpdated(0L).status) + assertEquals(Status.IN_PROCESS, task.withProgressUpdated(1L).status) + assertEquals(Status.IN_PROCESS, task.withProgressUpdated(99L).status) + assertEquals(Status.COMPLETED, task.withProgressUpdated(100L).status) + assertEquals(50L, task.withProgressUpdated(50L).percentComplete) + } + + @Test + fun withProgressUpdated_marksSyncedEntryAsLocallyModified() { + val synced = IcalEntry.newTask().copy(syncState = SyncState.SYNCED) + assertEquals(SyncState.LOCAL_MODIFIED, synced.withProgressUpdated(50L).syncState) + } + + @Test + fun withProgressUpdated_doesNotOverwriteConflictState() { + val conflicted = IcalEntry.newTask().copy(syncState = SyncState.CONFLICT_LOCAL_MODIFIED_SERVER_MODIFIED) + assertEquals(SyncState.CONFLICT_LOCAL_MODIFIED_SERVER_MODIFIED, conflicted.withProgressUpdated(50L).syncState) + } + + @Test + fun progressTriStateFollowsPercentComplete() { + val task = IcalEntry.newTask() + assertEquals(ToggleableState.Off, task.copy(percentComplete = 0L).getProgressTriState()) + assertEquals(ToggleableState.Indeterminate, task.copy(percentComplete = 50L).getProgressTriState()) + assertEquals(ToggleableState.On, task.copy(percentComplete = 100L).getProgressTriState()) + } + + @Test + fun plainTextForShareWithCategories() { + val entry = IcalEntry.newNote().copy( + summary = "S", + description = "D", + categories = listOf("A", "B") + ) + assertEquals("S\nD\n\nCategories: A, B", entry.getPlainTextForShare("Categories")) + } + + @Test + fun plainTextForShareWithoutCategories() { + val entry = IcalEntry.newNote().copy(summary = "S", description = "D") + assertEquals("S\nD\n", entry.getPlainTextForShare("Categories")) + } + + @Test + fun plainTextForShareWithoutSummary() { + val entry = IcalEntry.newNote().copy(description = "D") + assertEquals("D\n", entry.getPlainTextForShare("Categories")) + } +} diff --git a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/domain/SyncStateAndStatusTest.kt b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/domain/SyncStateAndStatusTest.kt new file mode 100644 index 00000000..2cad3398 --- /dev/null +++ b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/domain/SyncStateAndStatusTest.kt @@ -0,0 +1,40 @@ +package at.techbee.spectacled.screens.core.domain + +import kotlin.test.Test +import kotlin.test.assertEquals + +class SyncStateAndStatusTest { + + @Test + fun deletedStates_exhaustive() { + val expectedDeleted = setOf(SyncState.LOCAL_DELETED, SyncState.REMOTE_DELETED_LOCAL_TRASHBIN) + SyncState.entries.forEach { state -> + assertEquals(state in expectedDeleted, state.isDeletedState(), "isDeletedState() for $state") + } + } + + @Test + fun conflictStates_exhaustive() { + val expectedConflicts = setOf( + SyncState.CONFLICT_LOCAL_MODIFIED_SERVER_MODIFIED, + SyncState.CONFLICT_LOCAL_DELETED_SERVER_MODIFIED, + SyncState.CONFLICT_LOCAL_MODIFIED_SERVER_DELETED + ) + SyncState.entries.forEach { state -> + assertEquals(state in expectedConflicts, state.isConflictState(), "isConflictState() for $state") + } + } + + @Test + fun statusEntriesPerComponent() { + assertEquals( + listOf(Status.DRAFT, Status.FINAL, Status.CANCELLED), + Status.entriesForComponent(CalendarComponent.VJOURNAL) + ) + assertEquals( + listOf(Status.NEEDS_ACTION, Status.IN_PROCESS, Status.COMPLETED, Status.CANCELLED), + Status.entriesForComponent(CalendarComponent.VTODO) + ) + assertEquals(emptyList(), Status.entriesForComponent(CalendarComponent.VEVENT)) + } +} diff --git a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryRoundTripTest.kt b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryRoundTripTest.kt new file mode 100644 index 00000000..a852f951 --- /dev/null +++ b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryRoundTripTest.kt @@ -0,0 +1,309 @@ +package at.techbee.spectacled.screens.core.mapper.ics + +import androidx.compose.ui.graphics.Color +import at.techbee.spectacled.screens.core.FileManager +import at.techbee.spectacled.screens.core.data.ics.IcsDateTime +import at.techbee.spectacled.screens.core.data.ics.RawIcsProperty +import at.techbee.spectacled.screens.core.domain.Attachment +import at.techbee.spectacled.screens.core.domain.AttachmentSyncState +import at.techbee.spectacled.screens.core.domain.CalendarComponent +import at.techbee.spectacled.screens.core.domain.Classification +import at.techbee.spectacled.screens.core.domain.IcalEntry +import at.techbee.spectacled.screens.core.domain.Status +import at.techbee.spectacled.screens.core.domain.SyncState +import io.ktor.http.Url +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * Serializes entries through the real serializer and parses them back through the real + * parser - the exact same path an entry takes to a CalDAV server and back. ICS has + * second precision, so all instants used here are whole seconds. + */ +class IcalEntryRoundTripTest { + + private val instant = Instant.parse("2026-07-11T10:15:30Z") + private val utcDateTime = IcsDateTime(instant, isDateOnly = false, timeZone = TimeZone.UTC) + + private class FakeFileManager(private val content: ByteArray = ByteArray(0)) : FileManager { + val saved = mutableMapOf() + override fun getAttachmentsDirectory() = "/fake" + override fun saveAttachment(fileName: String, bytes: ByteArray): String { + saved[fileName] = bytes + return "/fake/$fileName" + } + override fun readAttachment(path: String): ByteArray = content + override fun deleteAttachment(path: String) = false + override fun exists(path: String) = true + } + + @Test + fun journalRoundTrip() { + val original = IcalEntry( + uid = "journal-uid-1", + summary = "Summary with, comma; semicolon", + description = "Multi\nline text and a Windows path C:\\notes\\file.txt", + dtStart = IcsDateTime(LocalDate(2026, 7, 11).atStartOfDayIn(TimeZone.UTC), isDateOnly = true, timeZone = null), + status = Status.FINAL, + classification = Classification.PRIVATE, + categories = listOf("Personal", "Ideas"), + color = Color(0xFF336699), + sequence = 3L, + dtstamp = utcDateTime, + created = utcDateTime, + lastModified = utcDateTime, + url = Url("https://example.com/entry"), + calendarComponent = CalendarComponent.VJOURNAL + ) + + val parsed = parseIcalEntries(serializeVCalendar(original)).single() + + assertEquals(original.uid, parsed.uid) + assertEquals(original.summary, parsed.summary) + assertEquals(original.description, parsed.description) + assertEquals(original.dtStart, parsed.dtStart) + assertEquals(original.status, parsed.status) + assertEquals(original.classification, parsed.classification) + assertEquals(original.categories, parsed.categories) + assertEquals(original.color, parsed.color) + assertEquals(original.sequence, parsed.sequence) + assertEquals(original.dtstamp, parsed.dtstamp) + assertEquals(original.created, parsed.created) + assertEquals(original.lastModified, parsed.lastModified) + assertEquals(original.url, parsed.url) + assertEquals(CalendarComponent.VJOURNAL, parsed.calendarComponent) + assertEquals(SyncState.SYNCED, parsed.syncState) + } + + @Test + fun taskRoundTrip() { + val original = IcalEntry( + uid = "task-uid-1", + summary = "A task", + due = utcDateTime, + completed = utcDateTime, + status = Status.IN_PROCESS, + percentComplete = 50L, + priority = 1L, + dtstamp = utcDateTime, + created = utcDateTime, + lastModified = utcDateTime, + calendarComponent = CalendarComponent.VTODO + ) + + val parsed = parseIcalEntries(serializeVCalendar(original)).single() + + assertEquals(CalendarComponent.VTODO, parsed.calendarComponent) + assertEquals(original.due, parsed.due) + assertEquals(original.completed, parsed.completed) + assertEquals(Status.IN_PROCESS, parsed.status) + assertEquals(50L, parsed.percentComplete) + assertEquals(1L, parsed.priority) + } + + @Test + fun multipleEntriesRoundTripInOneVCalendar() { + val journal = IcalEntry(uid = "multi-journal", dtstamp = utcDateTime, created = utcDateTime, calendarComponent = CalendarComponent.VJOURNAL) + val task = IcalEntry(uid = "multi-task", dtstamp = utcDateTime, created = utcDateTime, calendarComponent = CalendarComponent.VTODO) + + val parsed = parseIcalEntries(serializeVCalendar(listOf(journal, task))) + + assertEquals(2, parsed.size) + assertEquals("multi-journal", parsed[0].uid) + assertEquals(CalendarComponent.VJOURNAL, parsed[0].calendarComponent) + assertEquals("multi-task", parsed[1].uid) + assertEquals(CalendarComponent.VTODO, parsed[1].calendarComponent) + } + + @Test + fun longDescriptionSurvivesLineFolding() { + val longText = "All work and no play makes Jack a dull boy. ".repeat(8).trim() + val original = IcalEntry(uid = "fold-uid", description = longText, dtstamp = utcDateTime, created = utcDateTime, calendarComponent = CalendarComponent.VJOURNAL) + + val ics = serializeVCalendar(original) + assertTrue(ics.contains("\r\n "), "Expected the long description to be folded") + assertEquals(longText, parseIcalEntries(ics).single().description) + } + + @Test + fun zonedDtStartRoundTripsAndEmitsVTimezone() { + val vienna = TimeZone.of("Europe/Vienna") + val original = IcalEntry( + uid = "tz-uid", + dtStart = IcsDateTime(instant, isDateOnly = false, timeZone = vienna), + dtstamp = utcDateTime, + created = utcDateTime, + calendarComponent = CalendarComponent.VJOURNAL + ) + + val ics = serializeVCalendar(original) + assertTrue(ics.contains("BEGIN:VTIMEZONE")) + assertTrue(ics.contains("TZID:Europe/Vienna")) + // Vienna observes DST in 2026, so both observance blocks must be present + assertTrue(ics.contains("BEGIN:DAYLIGHT")) + assertTrue(ics.contains("BEGIN:STANDARD")) + assertTrue(ics.contains("DTSTART;TZID=Europe/Vienna:")) + + assertEquals(original.dtStart, parseIcalEntries(ics).single().dtStart) + } + + @Test + fun zoneWithoutDstEmitsOnlyStandardBlock() { + val original = IcalEntry( + uid = "tokyo-uid", + dtStart = IcsDateTime(instant, isDateOnly = false, timeZone = TimeZone.of("Asia/Tokyo")), + dtstamp = utcDateTime, + created = utcDateTime, + calendarComponent = CalendarComponent.VJOURNAL + ) + + val ics = serializeVCalendar(original) + assertTrue(ics.contains("TZID:Asia/Tokyo")) + assertTrue(ics.contains("BEGIN:STANDARD")) + assertFalse(ics.contains("BEGIN:DAYLIGHT")) + } + + @Test + fun utcOnlyEntriesEmitNoVTimezone() { + val original = IcalEntry(uid = "utc-uid", dtStart = utcDateTime, dtstamp = utcDateTime, created = utcDateTime, calendarComponent = CalendarComponent.VJOURNAL) + assertFalse(serializeVCalendar(original).contains("VTIMEZONE")) + } + + @Test + fun relatedToWithExplicitRelTypeRoundTrips() { + val original = IcalEntry(uid = "child-uid", parentUid = "parent-uid", relType = "CHILD", dtstamp = utcDateTime, created = utcDateTime, calendarComponent = CalendarComponent.VTODO) + val parsed = parseIcalEntries(serializeVCalendar(original)).single() + assertEquals("parent-uid", parsed.parentUid) + assertEquals("CHILD", parsed.relType) + } + + @Test + fun relatedToWithoutRelTypeDefaultsToParent() { + // RFC 5545: RELTYPE defaults to PARENT when the parameter is absent, so the + // parser materializes the default rather than keeping null. + val original = IcalEntry(uid = "child-uid", parentUid = "parent-uid", relType = null, dtstamp = utcDateTime, created = utcDateTime, calendarComponent = CalendarComponent.VTODO) + val parsed = parseIcalEntries(serializeVCalendar(original)).single() + assertEquals("parent-uid", parsed.parentUid) + assertEquals("PARENT", parsed.relType) + } + + @Test + fun unknownPropertiesRoundTripAsExtraProperties() { + val raw = RawIcsProperty(name = "X-CUSTOM", unfoldedLine = "X-CUSTOM;X-PARAM=1:some value") + val original = IcalEntry(uid = "extra-uid", extraProperties = listOf(raw), dtstamp = utcDateTime, created = utcDateTime, calendarComponent = CalendarComponent.VJOURNAL) + assertEquals(listOf(raw), parseIcalEntries(serializeVCalendar(original)).single().extraProperties) + } + + @Test + fun uriAttachmentRoundTrips() { + val original = IcalEntry( + uid = "att-uid", + attachments = listOf( + Attachment( + remoteUrl = "https://example.com/files/doc.pdf", + fileName = "doc.pdf", + mimeType = "application/pdf", + isInline = false, + syncState = AttachmentSyncState.SYNCED + ) + ), + dtstamp = utcDateTime, + created = utcDateTime, + calendarComponent = CalendarComponent.VJOURNAL + ) + + val attachment = parseIcalEntries(serializeVCalendar(original)).single().attachments.single() + assertEquals("https://example.com/files/doc.pdf", attachment.remoteUrl) + assertEquals("doc.pdf", attachment.fileName) + assertEquals("application/pdf", attachment.mimeType) + assertFalse(attachment.isInline) + assertEquals(AttachmentSyncState.PENDING_DOWNLOAD, attachment.syncState) + } + + @Test + fun inlineBinaryAttachmentRoundTripsThroughBase64() { + val bytes = "hello world".encodeToByteArray() + val writerFileManager = FakeFileManager(bytes) + val original = IcalEntry( + uid = "bin-uid", + attachments = listOf( + Attachment(localPath = "/fake/src.txt", fileName = "hello.txt", mimeType = "text/plain", isInline = true) + ), + dtstamp = utcDateTime, + created = utcDateTime, + calendarComponent = CalendarComponent.VJOURNAL + ) + + val ics = serializeVCalendar(original, writerFileManager) + assertTrue(ics.contains("VALUE=BINARY")) + assertTrue(ics.contains("ENCODING=BASE64")) + + val readerFileManager = FakeFileManager() + val attachment = parseIcalEntries(ics, readerFileManager).single().attachments.single() + assertTrue(attachment.isInline) + assertEquals(AttachmentSyncState.SYNCED, attachment.syncState) + assertEquals("hello.txt", attachment.fileName) + assertEquals("text/plain", attachment.mimeType) + assertEquals(bytes.size.toLong(), attachment.size) + assertContentEquals(bytes, readerFileManager.saved.values.single()) + assertEquals("/fake/${readerFileManager.saved.keys.single()}", attachment.localPath) + } + + @Test + fun veventComponentsAreIgnored() { + val ics = listOf( + "BEGIN:VCALENDAR", + "BEGIN:VEVENT", + "UID:event-1", + "END:VEVENT", + "BEGIN:VJOURNAL", + "UID:journal-1", + "END:VJOURNAL", + "END:VCALENDAR" + ).joinToString("\r\n") + + val parsed = parseIcalEntries(ics) + assertEquals(1, parsed.size) + assertEquals("journal-1", parsed.single().uid) + } + + @Test + fun blockWithoutUidIsSkippedInsteadOfAborting() { + val ics = listOf( + "BEGIN:VCALENDAR", + "BEGIN:VJOURNAL", + "SUMMARY:No UID here", + "END:VJOURNAL", + "BEGIN:VJOURNAL", + "UID:valid-uid", + "END:VJOURNAL", + "END:VCALENDAR" + ).joinToString("\r\n") + + val parsed = parseIcalEntries(ics) + assertEquals(1, parsed.size) + assertEquals("valid-uid", parsed.single().uid) + } + + @Test + fun parsesServerStyleFoldedInput() { + val ics = listOf( + "BEGIN:VCALENDAR", + "BEGIN:VJOURNAL", + "UID:folded-uid", + "SUMMARY:This summary is spread over mult", + " iple folded lines", + "END:VJOURNAL", + "END:VCALENDAR" + ).joinToString("\r\n") + + assertEquals("This summary is spread over multiple folded lines", parseIcalEntries(ics).single().summary) + } +} diff --git a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcsDateTimeFormatTest.kt b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcsDateTimeFormatTest.kt new file mode 100644 index 00000000..c9aa3760 --- /dev/null +++ b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcsDateTimeFormatTest.kt @@ -0,0 +1,119 @@ +package at.techbee.spectacled.screens.core.mapper.ics + +import at.techbee.spectacled.screens.core.data.ics.IcsDateTime +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.time.Instant + +class IcsDateTimeFormatTest { + + private val utcInstant = Instant.parse("2026-07-11T10:15:30Z") + private val vienna = TimeZone.of("Europe/Vienna") + + // --- formatIcsDateTime --- + + @Test + fun format_dateOnly() { + val dt = IcsDateTime(LocalDate(2026, 7, 11).atStartOfDayIn(TimeZone.UTC), isDateOnly = true, timeZone = null) + assertEquals("20260711" to "VALUE=DATE", formatIcsDateTime(dt)) + } + + @Test + fun format_utcDateTimeGetsZSuffix() { + val dt = IcsDateTime(utcInstant, isDateOnly = false, timeZone = TimeZone.UTC) + assertEquals("20260711T101530Z" to null, formatIcsDateTime(dt)) + } + + @Test + fun format_zonedDateTimeGetsTzidParamAndLocalWallTime() { + // 10:15:30 UTC is 12:15:30 in Vienna during summer time (UTC+2) + val dt = IcsDateTime(utcInstant, isDateOnly = false, timeZone = vienna) + assertEquals("20260711T121530" to "TZID=Europe/Vienna", formatIcsDateTime(dt)) + } + + @Test + fun format_floatingDateTimeHasNoSuffixAndNoParam() { + val dt = IcsDateTime(utcInstant, isDateOnly = false, timeZone = null) + assertEquals("20260711T101530" to null, formatIcsDateTime(dt)) + } + + @Test + fun format_nullReturnsNull() { + assertNull(formatIcsDateTime(null)) + } + + // --- parseIcsDateTime --- + + @Test + fun parse_dateOnly() { + val parsed = parseIcsDateTime("20260711") + assertNotNull(parsed) + assertEquals(LocalDate(2026, 7, 11).atStartOfDayIn(TimeZone.UTC), parsed.instant) + assertEquals(true, parsed.isDateOnly) + assertNull(parsed.timeZone) + } + + @Test + fun parse_utcDateTime() { + val parsed = parseIcsDateTime("20260711T101530Z") + assertNotNull(parsed) + assertEquals(utcInstant, parsed.instant) + assertEquals(false, parsed.isDateOnly) + assertEquals(TimeZone.UTC, parsed.timeZone) + } + + @Test + fun parse_zonedDateTimeAppliesTzid() { + val parsed = parseIcsDateTime("20260711T121530", tzid = "Europe/Vienna") + assertNotNull(parsed) + assertEquals(utcInstant, parsed.instant) + assertEquals(vienna, parsed.timeZone) + } + + @Test + fun parse_floatingDateTimeIsTreatedAsUtc() { + val parsed = parseIcsDateTime("20260711T101530") + assertNotNull(parsed) + assertEquals(utcInstant, parsed.instant) + assertNull(parsed.timeZone) + } + + @Test + fun parse_unknownTzidFallsBackToUtc() { + val parsed = parseIcsDateTime("20260711T101530", tzid = "Not/AZone") + assertNotNull(parsed) + assertEquals(utcInstant, parsed.instant) + assertEquals(TimeZone.UTC, parsed.timeZone) + } + + @Test + fun parse_malformedValuesDegradeToNullInsteadOfThrowing() { + assertNull(parseIcsDateTime(null)) + assertNull(parseIcsDateTime("garbage!")) // 8 chars, but not a date + assertNull(parseIcsDateTime("20261399")) // month 13, day 99 + assertNull(parseIcsDateTime("20260711T256000Z")) // hour 25 + assertNull(parseIcsDateTime("20260711T1015")) // too short for any branch + } + + // --- round trips through both functions --- + + @Test + fun formatThenParse_roundTripsAllFourShapes() { + val shapes = listOf( + IcsDateTime(LocalDate(2026, 7, 11).atStartOfDayIn(TimeZone.UTC), isDateOnly = true, timeZone = null), + IcsDateTime(utcInstant, isDateOnly = false, timeZone = TimeZone.UTC), + IcsDateTime(utcInstant, isDateOnly = false, timeZone = vienna), + IcsDateTime(utcInstant, isDateOnly = false, timeZone = null) + ) + shapes.forEach { original -> + val (value, param) = assertNotNull(formatIcsDateTime(original)) + val tzid = param?.takeIf { it.startsWith("TZID=") }?.removePrefix("TZID=") + assertEquals(original, parseIcsDateTime(value, tzid), "Round trip failed for $original") + } + } +} diff --git a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcsEscapingAndFoldingTest.kt b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcsEscapingAndFoldingTest.kt new file mode 100644 index 00000000..b5494d4f --- /dev/null +++ b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcsEscapingAndFoldingTest.kt @@ -0,0 +1,103 @@ +package at.techbee.spectacled.screens.core.mapper.ics + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class IcsEscapingAndFoldingTest { + + /** Serializes [original] as a property value and parses it back, i.e. escape -> unescape. */ + private fun roundTrip(original: String): String = + parseProperty("X-TEST:${escapeIcsValue(original)}").value + + @Test + fun escapeUnescape_plainText() { + assertEquals("plain text", roundTrip("plain text")) + } + + @Test + fun escapeUnescape_reservedCharacters() { + assertEquals("with, comma; and\nnewline", roundTrip("with, comma; and\nnewline")) + } + + @Test + fun escapeUnescape_singleBackslash() { + assertEquals("back \\ slash", roundTrip("back \\ slash")) + } + + @Test + fun escapeUnescape_backslashFollowedByN() { + // Regression test: "\n" here is a literal backslash followed by the letter n + // (e.g. a Windows path in a note). A sequential-replace unescape misreads the + // escaped form and corrupts it into a real newline. + assertEquals("C:\\Users\\name", roundTrip("C:\\Users\\name")) + assertEquals("literal \\n sequence", roundTrip("literal \\n sequence")) + } + + @Test + fun escapeUnescape_backslashFollowedByComma() { + assertEquals("odd \\, but legal", roundTrip("odd \\, but legal")) + } + + @Test + fun escapeUnescape_doubledAndTrailingBackslash() { + assertEquals("double \\\\ backslash", roundTrip("double \\\\ backslash")) + assertEquals("trailing backslash \\", roundTrip("trailing backslash \\")) + } + + @Test + fun unescape_acceptsUppercaseNewlineFromOtherProducers() { + // RFC 5545 allows \N as an alternative newline escape; this app never produces + // it, but other CalDAV clients may. + assertEquals("a\nb", unescapeIcsValue("a\\Nb")) + } + + @Test + fun unescape_leavesUnknownEscapesAndLoneTrailingBackslashIntact() { + assertEquals("\\x", unescapeIcsValue("\\x")) + assertEquals("abc\\", unescapeIcsValue("abc\\")) + } + + @Test + fun foldIcsLine_lineAtLimitIsUnchanged() { + val line = "A".repeat(75) + assertEquals(line, foldIcsLine(line)) + } + + @Test + fun foldIcsLine_longLineIsFoldedWithSpaceContinuation() { + val folded = foldIcsLine("A".repeat(80)) + assertEquals("A".repeat(75) + "\r\n " + "A".repeat(5), folded) + } + + @Test + fun foldIcsLine_everyContinuationLineStartsWithSpace() { + val folded = foldIcsLine("B".repeat(200)) + folded.split("\r\n").drop(1).forEach { continuation -> + assertTrue(continuation.startsWith(" "), "Continuation line must start with a space: '$continuation'") + } + } + + @Test + fun unfoldLines_isInverseOfFoldIcsLine() { + val line = "DESCRIPTION:" + "Lorem ipsum dolor sit amet, consetetur sadipscing elitr. ".repeat(5) + assertEquals(listOf(line), unfoldLines(foldIcsLine(line))) + } + + @Test + fun unfoldLines_supportsSpaceAndTabContinuations() { + assertEquals(listOf("AB"), unfoldLines("A\r\n B")) + assertEquals(listOf("AB"), unfoldLines("A\r\n\tB")) + } + + @Test + fun unfoldLines_keepsSeparateLinesSeparate() { + assertEquals(listOf("LINE1:a", "LINE2:b"), unfoldLines("LINE1:a\r\nLINE2:b")) + } + + @Test + fun unfoldLines_handlesBareNewlinesAsWell() { + // Some servers send LF-only line endings. + assertEquals(listOf("SUMMARY:abcdef"), unfoldLines("SUMMARY:abc\n def")) + } +} From ab93d9f849755ee2bbc0c351a2fc4e0c97fc8fbb Mon Sep 17 00:00:00 2001 From: Patrick Lang <72232737+patrickunterwegs@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:40:06 +0200 Subject: [PATCH 08/25] Test review and additional tests --- .../spectacled/screens/core/domain/IcalEntry.kt | 7 ++++--- .../screens/core/data/ics/IcsDateTimeTest.kt | 13 +++++++++++++ .../spectacled/screens/core/domain/IcalEntryTest.kt | 2 ++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/domain/IcalEntry.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/domain/IcalEntry.kt index af31dea1..fb963956 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/domain/IcalEntry.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/domain/IcalEntry.kt @@ -151,12 +151,13 @@ data class IcalEntry( } fun getProgressTriState() = when { + status == Status.IN_PROCESS -> ToggleableState.Indeterminate + status == Status.COMPLETED -> ToggleableState.On + status == Status.NEEDS_ACTION -> ToggleableState.Off percentComplete == 0L -> ToggleableState.Off percentComplete in 1L .. 99L -> ToggleableState.Indeterminate percentComplete == 100L -> ToggleableState.On - status == null || status == Status.NEEDS_ACTION -> ToggleableState.Off - status == Status.IN_PROCESS -> ToggleableState.Indeterminate - status == Status.COMPLETED -> ToggleableState.On + status == null -> ToggleableState.Off else -> ToggleableState.Indeterminate // undefined } diff --git a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/data/ics/IcsDateTimeTest.kt b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/data/ics/IcsDateTimeTest.kt index c46bde3b..56bd5960 100644 --- a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/data/ics/IcsDateTimeTest.kt +++ b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/data/ics/IcsDateTimeTest.kt @@ -1,10 +1,13 @@ package at.techbee.spectacled.screens.core.data.ics import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toInstant import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Instant @@ -42,6 +45,16 @@ class IcsDateTimeTest { assertEquals(LocalDate(2026, 7, 12).atStartOfDayIn(TimeZone.UTC), dateOnly.instant) } + @Test + fun asDateTime_usesTheLocalDateOfTheEffectiveZone() { + val zoned = IcsDateTime(Instant.parse("2026-07-12T00:00:00Z"), isDateOnly = true) + val dateTime = zoned.asDateTime() + + assertFalse(dateTime.isDateOnly) + assertNull(dateTime.timeZone) + assertEquals(LocalDateTime(2026, 7, 12, 0, 0).toInstant(TimeZone.UTC), dateTime.instant) + } + @Test fun withZone_preservesTheLocalWallTime() { // 10:00 UTC re-anchored to Vienna keeps the 10:00 wall time, so the instant diff --git a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/domain/IcalEntryTest.kt b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/domain/IcalEntryTest.kt index 9f44012c..907902dd 100644 --- a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/domain/IcalEntryTest.kt +++ b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/domain/IcalEntryTest.kt @@ -69,6 +69,8 @@ class IcalEntryTest { assertEquals(ToggleableState.Off, task.copy(percentComplete = 0L).getProgressTriState()) assertEquals(ToggleableState.Indeterminate, task.copy(percentComplete = 50L).getProgressTriState()) assertEquals(ToggleableState.On, task.copy(percentComplete = 100L).getProgressTriState()) + assertEquals(ToggleableState.Off, task.copy(percentComplete = 0L, status = null).getProgressTriState()) + assertEquals(ToggleableState.On, task.copy(percentComplete = 0L, status = Status.COMPLETED).getProgressTriState()) } @Test From 23e98674f705498fc4b7f67edad82961eaa8ff99 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 13:09:15 +0000 Subject: [PATCH 09/25] Make categories with commas survive both the ICS and DB round trips (QUA-9) The comma stays the separator - RFC 5545 defines it, and every other CalDAV client (jtx Board, KOrganizer, Evolution) already writes categories with "\," escaping, so a custom separator would break the interop this app exists for. Instead the standard's own escape mechanism is now applied on both sides: - Serializer escapes each category individually, so a comma INSIDE a value ("\,") stays distinguishable from the separators between values. - IcsProperty now also carries the still-escaped rawValue, because the list split has to happen BEFORE unescaping - afterwards an escaped comma is indistinguishable from a separator. Everything except CATEGORIES keeps using the unescaped value as before. - New splitIcsList() splits a raw value on unescaped commas only, then unescapes each element; the parser now also flatMaps over ALL CATEGORIES properties instead of silently dropping all but the first (RFC allows several per entry, and some clients emit them that way). While wiring this up it turned out the local database had the same bug independently: IcalEntryMapper stored categories as a plain comma-joined TEXT column, so a fixed wire format would still have been re-split into fragments on the next local save/load. The DB layer (mapper both directions, getAllCategories, updateCategory) now uses the same escapeIcsValue/splitIcsList pair, keeping one canonical escaping implementation for both layers. Existing rows without backslashes or commas parse identically; per owner, existing data is tester-only. Tests: splitIcsList unit tests (incl. a category ending in a backslash directly before a separator), a full serialize->parse round trip with comma/semicolon/backslash categories, and multi-line CATEGORIES merge. --- .../screens/core/data/ics/IcsProperty.kt | 10 ++++++- .../repository/IcalEntryRepositoryImpl.kt | 6 ++-- .../core/mapper/dto/IcalEntryMapper.kt | 8 +++-- .../core/mapper/ics/IcalEntryIcsParser.kt | 30 +++++++++++++++++-- .../core/mapper/ics/IcalEntryIcsSerializer.kt | 4 ++- .../core/mapper/ics/IcalEntryRoundTripTest.kt | 24 +++++++++++++++ .../mapper/ics/IcsEscapingAndFoldingTest.kt | 28 +++++++++++++++++ 7 files changed, 102 insertions(+), 8 deletions(-) diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/ics/IcsProperty.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/ics/IcsProperty.kt index 2a398f8b..69380e52 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/ics/IcsProperty.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/ics/IcsProperty.kt @@ -4,5 +4,13 @@ package at.techbee.spectacled.screens.core.data.ics data class IcsProperty( val name: String, val params: Map, - val value: String + /** The unescaped property value - what almost every consumer wants. */ + val value: String, + /** + * The still-escaped value as it appeared on the wire. Needed for list-valued + * properties like CATEGORIES, where the list must be split on unescaped commas + * BEFORE unescaping the elements - after unescaping, a comma that was escaped + * inside a value is indistinguishable from a separator. + */ + val rawValue: String ) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/repository/IcalEntryRepositoryImpl.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/repository/IcalEntryRepositoryImpl.kt index f02f9060..84264f04 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/repository/IcalEntryRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/repository/IcalEntryRepositoryImpl.kt @@ -18,7 +18,9 @@ import at.techbee.spectacled.screens.core.ioDispatcher import at.techbee.spectacled.screens.core.mapper.dto.CATEGORY_SPLIT_DELIMITER import at.techbee.spectacled.screens.core.mapper.dto.toDomain import at.techbee.spectacled.screens.core.mapper.dto.toDto +import at.techbee.spectacled.screens.core.mapper.ics.escapeIcsValue import at.techbee.spectacled.screens.core.mapper.ics.formatIcsDateTime +import at.techbee.spectacled.screens.core.mapper.ics.splitIcsList import io.ktor.http.Url import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -128,7 +130,7 @@ class IcalEntryRepositoryImpl( .map { query -> val allCategories = mutableSetOf() query.awaitAsList().let { unsplitCategories -> - unsplitCategories.forEach { allCategories.addAll(it.split(CATEGORY_SPLIT_DELIMITER)) } + unsplitCategories.forEach { allCategories.addAll(splitIcsList(it)) } } allCategories.toList() } @@ -298,7 +300,7 @@ class IcalEntryRepositoryImpl( override suspend fun updateCategory(id: Long, categories: List, lastModified: IcsDateTime?, syncState: SyncState) { withContext(ioDispatcher) { getDatabase().icalentry_dtoQueries.updateCategory( - newCategories = categories.joinToString(CATEGORY_SPLIT_DELIMITER).ifEmpty { null }, + newCategories = categories.joinToString(CATEGORY_SPLIT_DELIMITER) { escapeIcsValue(it) }.ifEmpty { null }, lastModified = lastModified?.let { formatIcsDateTime(it)?.first }, syncState = syncState.name, id = id diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/dto/IcalEntryMapper.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/dto/IcalEntryMapper.kt index 5d3acccb..c3b0e729 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/dto/IcalEntryMapper.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/dto/IcalEntryMapper.kt @@ -10,8 +10,10 @@ import at.techbee.spectacled.screens.core.domain.Classification import at.techbee.spectacled.screens.core.domain.IcalEntry import at.techbee.spectacled.screens.core.domain.Status import at.techbee.spectacled.screens.core.domain.SyncState +import at.techbee.spectacled.screens.core.mapper.ics.escapeIcsValue import at.techbee.spectacled.screens.core.mapper.ics.formatIcsDateTime import at.techbee.spectacled.screens.core.mapper.ics.parseIcsDateTime +import at.techbee.spectacled.screens.core.mapper.ics.splitIcsList import at.techbee.spectacled.sqldelight.IcalEntryDto import io.ktor.http.Url import kotlinx.serialization.json.Json @@ -48,7 +50,7 @@ fun IcalEntryDto.toDomain(attachments: List = emptyList()): IcalEntr priority = this.priority, percentComplete = this.percentComplete ?: 0L, dtstamp = parseIcsDateTime(this.dtstamp) ?: IcsDateTime.now(), - categories = this.categories?.split(CATEGORY_SPLIT_DELIMITER) ?: emptyList(), + categories = this.categories?.let { splitIcsList(it) } ?: emptyList(), created = parseIcsDateTime(this.created) ?: IcsDateTime.now(), lastModified = parseIcsDateTime(this.lastModified) ?: IcsDateTime.now(), extraProperties = extraProps, @@ -90,7 +92,9 @@ fun IcalEntry.toDto(): IcalEntryDto { percentComplete = if(this.calendarComponent == CalendarComponent.VTODO) this.percentComplete else null, priority = if(this.calendarComponent == CalendarComponent.VTODO) this.priority else null, classification = this.classification?.name, - categories = this.categories.joinToString(CATEGORY_SPLIT_DELIMITER).ifEmpty { null }, + // Escape each category so a comma inside a value stays distinguishable from the + // delimiter - same scheme as the ICS wire format, so one escaping implementation. + categories = this.categories.joinToString(CATEGORY_SPLIT_DELIMITER) { escapeIcsValue(it) }.ifEmpty { null }, created = formatIcsDateTime(this.created)?.first, lastModified = formatIcsDateTime(this.lastModified)?.first, extraProperties = if(this.extraProperties.isNotEmpty()) mapperJson.encodeToString(this.extraProperties) else null, diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryIcsParser.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryIcsParser.kt index 7e71a4ea..cdb384ae 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryIcsParser.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryIcsParser.kt @@ -93,7 +93,29 @@ fun parseProperty(line: String): IcsProperty { else part.substring(0, eqIndex) to unquote(part.substring(eqIndex + 1)) } - return IcsProperty(name, params, unescapeIcsValue(rawValue)) + return IcsProperty(name, params, unescapeIcsValue(rawValue), rawValue) +} + +/** + * Splits a raw (still-escaped) ICS list value on unescaped commas only, then unescapes + * each element - the inverse of joinToString(",") { escapeIcsValue(it) }. An escaped + * "\," inside an element is kept as part of that element; RFC 5545 uses the unescaped + * comma as the list separator for properties like CATEGORIES. + */ +fun splitIcsList(rawValue: String): List { + val parts = mutableListOf() + val current = StringBuilder() + var i = 0 + while (i < rawValue.length) { + val c = rawValue[i] + when { + c == '\\' && i + 1 < rawValue.length -> { current.append(c).append(rawValue[i + 1]); i += 2 } + c == ',' -> { parts.add(current.toString()); current.clear(); i += 1 } + else -> { current.append(c); i += 1 } + } + } + parts.add(current.toString()) + return parts.map { unescapeIcsValue(it) }.filter { it.isNotEmpty() } } /** @@ -255,7 +277,11 @@ fun parseIcalEntryBlock( val dtstamp = knownProps[KnownIcsPropertyName.DTSTAMP.propertyName]?.firstOrNull()?.value?.let { parseIcsDateTime(it, null) } val created = knownProps[KnownIcsPropertyName.CREATED.propertyName]?.firstOrNull()?.value?.let { parseIcsDateTime(it, null) } val lastModified = knownProps[KnownIcsPropertyName.LAST_MODIFIED.propertyName]?.firstOrNull()?.value?.let { parseIcsDateTime(it, null) } - val categories = knownProps[KnownIcsPropertyName.CATEGORIES.propertyName]?.firstOrNull()?.value?.split(',') ?: emptyList() + // flatMap over ALL CATEGORIES properties (RFC 5545 allows several per entry, and some + // clients emit them that way), splitting each raw value on unescaped commas only. + val categories = knownProps[KnownIcsPropertyName.CATEGORIES.propertyName] + ?.flatMap { splitIcsList(it.rawValue) } + ?: emptyList() val status = knownProps[KnownIcsPropertyName.STATUS.propertyName]?.firstOrNull()?.value?.let { Status.entries.find { status -> status.rfcName == it } } val classification = knownProps[KnownIcsPropertyName.CLASSIFICATION.propertyName]?.firstOrNull()?.value?.let { Classification.entries.find { classification -> classification.name == it } } diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryIcsSerializer.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryIcsSerializer.kt index 290a0516..c092ee35 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryIcsSerializer.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryIcsSerializer.kt @@ -172,7 +172,9 @@ fun serializeVJournal(icalEntry: IcalEntry, fileManager: FileManager? = null): S lines += "${KnownIcsPropertyName.PERCENT_COMPLETE.propertyName}:${it}" } if(icalEntry.categories.isNotEmpty()) - icalEntry.categories.let { lines += "${KnownIcsPropertyName.CATEGORIES.propertyName}:${it.joinToString(",")}" } + // Escape each category individually so a comma INSIDE a category value ("\,") + // stays distinguishable from the unescaped commas separating the list elements. + icalEntry.categories.let { lines += "${KnownIcsPropertyName.CATEGORIES.propertyName}:${it.joinToString(",") { category -> escapeIcsValue(category) }}" } icalEntry.sequence?.let { lines += "${KnownIcsPropertyName.SEQUENCE.propertyName}:${it}" } icalEntry.parentUid?.let { parentUid -> diff --git a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryRoundTripTest.kt b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryRoundTripTest.kt index a852f951..30193c65 100644 --- a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryRoundTripTest.kt +++ b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryRoundTripTest.kt @@ -176,6 +176,30 @@ class IcalEntryRoundTripTest { assertFalse(serializeVCalendar(original).contains("VTIMEZONE")) } + @Test + fun categoriesWithCommasAndSpecialCharactersRoundTrip() { + val categories = listOf("Work, Private", "a,b", "semi;colon", "back\\slash", "Plain") + val original = IcalEntry(uid = "cat-uid", categories = categories, dtstamp = utcDateTime, created = utcDateTime, calendarComponent = CalendarComponent.VJOURNAL) + assertEquals(categories, parseIcalEntries(serializeVCalendar(original)).single().categories) + } + + @Test + fun multipleCategoriesPropertiesAreMerged() { + // RFC 5545 allows an entry to carry several CATEGORIES properties; some clients + // emit one per category instead of a single comma-joined list. + val ics = listOf( + "BEGIN:VCALENDAR", + "BEGIN:VJOURNAL", + "UID:multi-cat-uid", + "CATEGORIES:First,Second", + "CATEGORIES:Third", + "END:VJOURNAL", + "END:VCALENDAR" + ).joinToString("\r\n") + + assertEquals(listOf("First", "Second", "Third"), parseIcalEntries(ics).single().categories) + } + @Test fun relatedToWithExplicitRelTypeRoundTrips() { val original = IcalEntry(uid = "child-uid", parentUid = "parent-uid", relType = "CHILD", dtstamp = utcDateTime, created = utcDateTime, calendarComponent = CalendarComponent.VTODO) diff --git a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcsEscapingAndFoldingTest.kt b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcsEscapingAndFoldingTest.kt index b5494d4f..09060f71 100644 --- a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcsEscapingAndFoldingTest.kt +++ b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcsEscapingAndFoldingTest.kt @@ -58,6 +58,34 @@ class IcsEscapingAndFoldingTest { assertEquals("abc\\", unescapeIcsValue("abc\\")) } + @Test + fun splitIcsList_separatesOnUnescapedCommasOnly() { + assertEquals(listOf("Personal", "Ideas"), splitIcsList("Personal,Ideas")) + assertEquals(listOf("Work, Private"), splitIcsList("Work\\, Private")) + assertEquals(listOf("a,b", "c"), splitIcsList("a\\,b,c")) + } + + @Test + fun splitIcsList_handlesBackslashHeavyElements() { + // A category ending in a backslash, escaped to "\\", directly followed by the + // separator comma - the scanner must not treat that comma as escaped. + assertEquals(listOf("ends with backslash\\", "next"), splitIcsList("ends with backslash\\\\,next")) + assertEquals(listOf("semi;colon", "back\\slash"), splitIcsList("semi\\;colon,back\\\\slash")) + } + + @Test + fun splitIcsList_dropsEmptyElements() { + assertEquals(listOf("A", "B"), splitIcsList("A,,B")) + assertEquals(emptyList(), splitIcsList("")) + } + + @Test + fun splitIcsList_roundTripsThroughPerElementEscaping() { + val categories = listOf("Work, Private", "a,b", "semi;colon", "back\\slash", "line\nbreak") + val raw = categories.joinToString(",") { escapeIcsValue(it) } + assertEquals(categories, splitIcsList(raw)) + } + @Test fun foldIcsLine_lineAtLimitIsUnchanged() { val line = "A".repeat(75) From 851a19ae558cb4551dd6a5345b25f693418575bb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 17:38:56 +0000 Subject: [PATCH 10/25] Make the SyncCoordinator conflict/sync state machine testable and test it (QUA-1) Closes QUA-1's remaining gap. The coordinator called the top-level webdav *Multiplatform functions directly, so its conflict-resolution logic - the most valuable untested code in the module - couldn't run without a real server. Refactor: extracted a WebDavRemoteDataSource interface wrapping the seven server operations the coordinator needs, with a production DefaultWebDavRemoteDataSource that just delegates to the existing top-level functions. SyncCoordinator's primary constructor now takes that interface; a secondary constructor keeps the exact 5-arg (client, credentials) form every existing call site already uses, so nothing else changed. Tests pass a fake and need no HttpClient at all. The (previously public) client/credentials properties are gone - grep confirmed nothing read them; all call sites construct-and-call inline. Tests (17) drive the real syncCalendarWithSyncLock entry point with a scriptable fake server and recording fake repositories, covering the push state machine (LOCAL_MODIFIED put success/conflict-server-modified/ conflict-server-deleted/not-found/failed-retry, LOCAL_DELETED delete success/conflict, USER_DECIDED_SERVER_WINS, and the SYNCED/CONFLICT "do not push" guards), the apply-server-changes machine (insert new, overwrite synced, local-modified->conflict, same-etag skip-without- fetch, server-delete->trashbin, server-delete-of-local-modified-> conflict), sync-status mapping (NOT_AUTHORIZED, sync-token-failed falling back to the tokenless REPORT), and the per-calendar lock skipping a concurrent second sync. The fake server's mutating calls default to throwing AssertionError, so any unexpected server call fails the test loudly rather than being swallowed by sync()'s catch(Exception). Adds kotlinx-coroutines-test (same version as the pinned coroutines) for runTest in commonTest. --- gradle/libs.versions.toml | 1 + shared/build.gradle.kts | 1 + .../screens/core/SyncCoordinator.kt | 51 +- .../data/webdav/WebDavRemoteDataSource.kt | 54 +++ .../screens/core/SyncCoordinatorTest.kt | 459 ++++++++++++++++++ 5 files changed, 546 insertions(+), 20 deletions(-) create mode 100644 shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteDataSource.kt create mode 100644 shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/SyncCoordinatorTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c4653020..72c03e72 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,6 +69,7 @@ compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMul compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } ktor-server-core = { module = "io.ktor:ktor-server-core-jvm", version.ref = "ktor" } ktor-server-netty = { module = "io.ktor:ktor-server-netty-jvm", version.ref = "ktor" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 7b1d9186..a74ee632 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -108,6 +108,7 @@ kotlin { } commonTest.dependencies { implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) } androidMain.dependencies { diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/SyncCoordinator.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/SyncCoordinator.kt index 3b7b1527..9b657a28 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/SyncCoordinator.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/SyncCoordinator.kt @@ -3,19 +3,14 @@ package at.techbee.spectacled.screens.core import at.techbee.spectacled.screens.core.data.CredentialStore import at.techbee.spectacled.screens.core.data.Credentials import at.techbee.spectacled.screens.core.data.ics.IcsDateTime +import at.techbee.spectacled.screens.core.data.webdav.DefaultWebDavRemoteDataSource import at.techbee.spectacled.screens.core.data.webdav.DeleteResourceResult import at.techbee.spectacled.screens.core.data.webdav.GetResourceResult import at.techbee.spectacled.screens.core.data.webdav.MultigetResourceHrefETagResult import at.techbee.spectacled.screens.core.data.webdav.MultigetResourceResult import at.techbee.spectacled.screens.core.data.webdav.MultigetSyncCollectionResult import at.techbee.spectacled.screens.core.data.webdav.PutResourceResult -import at.techbee.spectacled.screens.core.data.webdav.deleteResourceMultiplatform -import at.techbee.spectacled.screens.core.data.webdav.fetchSingleEntryMultiplatform -import at.techbee.spectacled.screens.core.data.webdav.getResourceMultiplatform -import at.techbee.spectacled.screens.core.data.webdav.multigetResourceHrefsMultiplatform -import at.techbee.spectacled.screens.core.data.webdav.putResourceMultiplatform -import at.techbee.spectacled.screens.core.data.webdav.syncCollectionMultiplatform -import at.techbee.spectacled.screens.core.data.webdav.uploadFileMultiplatform +import at.techbee.spectacled.screens.core.data.webdav.WebDavRemoteDataSource import at.techbee.spectacled.screens.core.domain.AttachmentSyncState import at.techbee.spectacled.screens.core.domain.Calendar import at.techbee.spectacled.screens.core.domain.CalendarSyncStatus @@ -47,10 +42,26 @@ class SyncCoordinator( val calendarRepository: CalendarRepository, val icalEntryRepository: IcalEntryRepository, val fileManager: FileManager, - val client: HttpClient, - val credentials: Credentials? + private val remote: WebDavRemoteDataSource, ) { + // Production entry point: builds the real server-backed data source from an HttpClient and + // credentials. Every existing call site uses this 5-arg form unchanged. Tests use the + // primary constructor with a fake WebDavRemoteDataSource, so the sync/conflict state machine + // can run without a server - or an HttpClient - at all. + constructor( + calendarRepository: CalendarRepository, + icalEntryRepository: IcalEntryRepository, + fileManager: FileManager, + client: HttpClient, + credentials: Credentials?, + ) : this( + calendarRepository, + icalEntryRepository, + fileManager, + DefaultWebDavRemoteDataSource(client, credentials, fileManager), + ) + companion object { // Guards against a background sync and a manual push/refresh racing on the same @@ -147,7 +158,7 @@ class SyncCoordinator( calendar.id ) - val syncCollectionResponse = syncCollectionMultiplatform(client, calendar, credentials) + val syncCollectionResponse = remote.syncCollection(calendar) when (syncCollectionResponse) { is MultigetSyncCollectionResult.Failed -> { @@ -257,7 +268,7 @@ class SyncCoordinator( } private suspend fun syncWithoutSyncToken(calendar: Calendar) { - when (val multigetResourceHrefsMultiplatformResult = multigetResourceHrefsMultiplatform(client, calendar, credentials)) { + when (val multigetResourceHrefsMultiplatformResult = remote.multigetResourceHrefs(calendar)) { is MultigetResourceHrefETagResult.Failed -> { calendarRepository.updateCalendarSyncStatus( @@ -328,7 +339,7 @@ class SyncCoordinator( if (localIcalEntry?.href != null && localIcalEntry.etag == eTag) return // no eTag change, we skip - val serverIcalEntry = when (val fetchSingleResult = fetchSingleEntryMultiplatform(client, calendar, href, credentials, fileManager)) { + val serverIcalEntry = when (val fetchSingleResult = remote.fetchSingleEntry(calendar, href)) { is MultigetResourceResult.Failed -> return // skip failed entries MultigetResourceResult.NotAuthorized -> return // skip failed entries MultigetResourceResult.NotFound -> return // skip failed entries @@ -436,11 +447,11 @@ class SyncCoordinator( SyncState.LOCAL_MODIFIED -> { val entryToPush = pushAttachments(dirtyIcalEntry, calendar) // TODO: store error? - val insertOrUpdateIcalEntryResult = putResourceMultiplatform(client, calendar, entryToPush, credentials, fileManager) + val insertOrUpdateIcalEntryResult = remote.putResource(calendar, entryToPush) when (insertOrUpdateIcalEntryResult) { // Conflict was detected, we get the latest resource PutResourceResult.Conflict -> { - val conflictingServerIcalEntryResult = getResourceMultiplatform(client, calendar, entryToPush, credentials, fileManager) + val conflictingServerIcalEntryResult = remote.getResource(calendar, entryToPush) when (conflictingServerIcalEntryResult) { is GetResourceResult.Failed -> Unit // failed will be kept for another retry TODO: Review if this is sufficient in future @@ -472,11 +483,11 @@ class SyncCoordinator( // entry was locally modified, we put and see if there's a conflict SyncState.USER_DECIDED_CLIENT_WINS -> { val entryToPush = pushAttachments(dirtyIcalEntry, calendar) - val insertOrUpdateIcalEntryResult = putResourceMultiplatform(client, calendar, entryToPush, credentials, fileManager) + val insertOrUpdateIcalEntryResult = remote.putResource(calendar, entryToPush) when (insertOrUpdateIcalEntryResult) { // Conflict was detected, we get the latest resource PutResourceResult.Conflict -> { - val conflictingServerIcalEntryResult = getResourceMultiplatform(client, calendar, entryToPush, credentials, fileManager) + val conflictingServerIcalEntryResult = remote.getResource(calendar, entryToPush) when (conflictingServerIcalEntryResult) { // failed will be kept for another retry TODO: Review if this is sufficient in future @@ -525,7 +536,7 @@ class SyncCoordinator( // entry was locally modified, we put and see if there's a conflict SyncState.USER_DECIDED_SERVER_WINS -> { //TODO!! - val conflictingServerIcalEntryResult = getResourceMultiplatform(client, calendar, dirtyIcalEntry, credentials, fileManager) + val conflictingServerIcalEntryResult = remote.getResource(calendar, dirtyIcalEntry) when (conflictingServerIcalEntryResult) { // failed will be kept for another retry TODO: Review if this is sufficient in future @@ -548,7 +559,7 @@ class SyncCoordinator( } SyncState.LOCAL_DELETED -> { - val deleteResourceResult = deleteResourceMultiplatform(client, calendar, dirtyIcalEntry, credentials) + val deleteResourceResult = remote.deleteResource(calendar, dirtyIcalEntry) when (deleteResourceResult) { // The entry was already deleted or successfully deleted on the server. We delete it locally. @@ -558,7 +569,7 @@ class SyncCoordinator( // There was a conflict, the resourcew as changed on the server, we discard the local delete and update the entry instead // TODO: Review in future DeleteResourceResult.Conflict -> { - val conflictingServerIcalEntryResult = getResourceMultiplatform(client, calendar, dirtyIcalEntry, credentials, fileManager) + val conflictingServerIcalEntryResult = remote.getResource(calendar, dirtyIcalEntry) when (conflictingServerIcalEntryResult) { // failed will be kept for another retry TODO: Review if this is sufficient in future @@ -592,7 +603,7 @@ class SyncCoordinator( val bytes = fileManager.readAttachment(attachment.localPath) - val uploadResult = uploadFileMultiplatform(client, safeTargetUrl, bytes, attachment.mimeType, credentials) + val uploadResult = remote.uploadFile(safeTargetUrl, bytes, attachment.mimeType) if (uploadResult.isSuccess()) { val syncedAttachment = attachment.copy( remoteUrl = safeTargetUrl.toString(), diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteDataSource.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteDataSource.kt new file mode 100644 index 00000000..dc1ecf07 --- /dev/null +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteDataSource.kt @@ -0,0 +1,54 @@ +package at.techbee.spectacled.screens.core.data.webdav + +import at.techbee.spectacled.screens.core.FileManager +import at.techbee.spectacled.screens.core.data.Credentials +import at.techbee.spectacled.screens.core.domain.Calendar +import at.techbee.spectacled.screens.core.domain.IcalEntry +import io.ktor.client.HttpClient +import io.ktor.http.HttpStatusCode +import io.ktor.http.Url + +/** + * The set of CalDAV server operations the SyncCoordinator needs, behind an interface so the + * sync/conflict state machine can be exercised in tests without a real server. The production + * implementation ([DefaultWebDavRemoteDataSource]) just delegates to the existing top-level + * *Multiplatform functions; the transport concerns (HttpClient, Credentials, FileManager) are + * held by the implementation so callers pass only the semantic arguments. + */ +interface WebDavRemoteDataSource { + suspend fun syncCollection(calendar: Calendar): MultigetSyncCollectionResult + suspend fun multigetResourceHrefs(calendar: Calendar): MultigetResourceHrefETagResult + suspend fun fetchSingleEntry(calendar: Calendar, href: Url): MultigetResourceResult + suspend fun putResource(calendar: Calendar, icalEntry: IcalEntry): PutResourceResult + suspend fun getResource(calendar: Calendar, icalEntry: IcalEntry): GetResourceResult + suspend fun deleteResource(calendar: Calendar, icalEntry: IcalEntry): DeleteResourceResult + suspend fun uploadFile(targetUrl: Url, bytes: ByteArray, mimeType: String?): HttpStatusCode +} + +class DefaultWebDavRemoteDataSource( + private val client: HttpClient, + private val credentials: Credentials?, + private val fileManager: FileManager?, +) : WebDavRemoteDataSource { + + override suspend fun syncCollection(calendar: Calendar) = + syncCollectionMultiplatform(client, calendar, credentials) + + override suspend fun multigetResourceHrefs(calendar: Calendar) = + multigetResourceHrefsMultiplatform(client, calendar, credentials) + + override suspend fun fetchSingleEntry(calendar: Calendar, href: Url) = + fetchSingleEntryMultiplatform(client, calendar, href, credentials, fileManager) + + override suspend fun putResource(calendar: Calendar, icalEntry: IcalEntry) = + putResourceMultiplatform(client, calendar, icalEntry, credentials, fileManager) + + override suspend fun getResource(calendar: Calendar, icalEntry: IcalEntry) = + getResourceMultiplatform(client, calendar, icalEntry, credentials, fileManager) + + override suspend fun deleteResource(calendar: Calendar, icalEntry: IcalEntry) = + deleteResourceMultiplatform(client, calendar, icalEntry, credentials) + + override suspend fun uploadFile(targetUrl: Url, bytes: ByteArray, mimeType: String?) = + uploadFileMultiplatform(client, targetUrl, bytes, mimeType, credentials) +} diff --git a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/SyncCoordinatorTest.kt b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/SyncCoordinatorTest.kt new file mode 100644 index 00000000..27f2d16b --- /dev/null +++ b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/SyncCoordinatorTest.kt @@ -0,0 +1,459 @@ +package at.techbee.spectacled.screens.core + +import androidx.compose.ui.graphics.Color +import at.techbee.spectacled.screens.core.data.ics.IcsDateTime +import at.techbee.spectacled.screens.core.data.webdav.DeleteResourceResult +import at.techbee.spectacled.screens.core.data.webdav.GetResourceResult +import at.techbee.spectacled.screens.core.data.webdav.MultigetResourceHrefETagResult +import at.techbee.spectacled.screens.core.data.webdav.MultigetResourceResult +import at.techbee.spectacled.screens.core.data.webdav.MultigetSyncCollectionResult +import at.techbee.spectacled.screens.core.data.webdav.PutResourceResult +import at.techbee.spectacled.screens.core.data.webdav.WebDavRemoteDataSource +import at.techbee.spectacled.screens.core.domain.Attachment +import at.techbee.spectacled.screens.core.domain.Calendar +import at.techbee.spectacled.screens.core.domain.CalendarComponent +import at.techbee.spectacled.screens.core.domain.CalendarSyncStatus +import at.techbee.spectacled.screens.core.domain.CalendarSyncStatusType +import at.techbee.spectacled.screens.core.domain.HomeCollection +import at.techbee.spectacled.screens.core.domain.IcalEntry +import at.techbee.spectacled.screens.core.domain.Principal +import at.techbee.spectacled.screens.core.domain.Status +import at.techbee.spectacled.screens.core.domain.SyncState +import at.techbee.spectacled.screens.core.domain.repository.CalendarRepository +import at.techbee.spectacled.screens.core.domain.repository.IcalEntryRepository +import io.ktor.http.HttpStatusCode +import io.ktor.http.Url +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Drives the SyncCoordinator's conflict/sync state machine through its public + * syncCalendarWithSyncLock entry point, with a fake WebDavRemoteDataSource scripting the + * server's responses and fake repositories recording what the coordinator decided to write. + * No real HttpClient, server, or database is involved. + */ +class SyncCoordinatorTest { + + private val calendarUrl = Url("https://example.com/calendars/test/") + private val entryHref = Url("https://example.com/calendars/test/entry.ics") + + private fun calendar(id: Long = 1L, syncToken: String? = "token") = + Calendar.getCalendarForPreview().copy(id = id, url = calendarUrl, syncToken = syncToken) + + private fun entry( + id: Long = 10L, + syncState: SyncState, + etag: String? = "etag-old", + href: Url? = entryHref, + attachments: List = emptyList() + ) = IcalEntry( + id = id, + calendarId = 1L, + uid = "uid-$id", + syncState = syncState, + etag = etag, + href = href, + attachments = attachments, + calendarComponent = CalendarComponent.VJOURNAL + ) + + private fun coordinator( + remote: WebDavRemoteDataSource, + icalRepo: FakeIcalEntryRepository, + calRepo: FakeCalendarRepository = FakeCalendarRepository() + ) = SyncCoordinator(calRepo, icalRepo, FakeFileManager(), remote) + + // --- pushLocalChanges: LOCAL_MODIFIED --- + + @Test + fun localModified_putSuccess_marksSynced() = runTest { + val icalRepo = FakeIcalEntryRepository(dirty = listOf(entry(syncState = SyncState.LOCAL_MODIFIED))) + val remote = FakeRemote(putResult = { PutResourceResult.Success(entry(syncState = SyncState.SYNCED, etag = "etag-new")) }) + val calRepo = FakeCalendarRepository() + + coordinator(remote, icalRepo, calRepo).syncCalendarWithSyncLock(calendar()) + + val update = icalRepo.syncMetadataUpdates.single() + assertEquals(SyncState.SYNCED, update.syncState) + assertEquals("etag-new", update.etag) + assertEquals(10L, update.id) + assertEquals(CalendarSyncStatusType.SYNCED, calRepo.lastStatusType()) + } + + @Test + fun localModified_putConflict_serverModified_yieldsConflict() = runTest { + val icalRepo = FakeIcalEntryRepository(dirty = listOf(entry(syncState = SyncState.LOCAL_MODIFIED))) + val remote = FakeRemote( + putResult = { PutResourceResult.Conflict }, + getResult = { GetResourceResult.Success(entry(syncState = SyncState.SYNCED, etag = "etag-server")) } + ) + + coordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) + + assertEquals(SyncState.CONFLICT_LOCAL_MODIFIED_SERVER_MODIFIED, icalRepo.upserts.single().syncState) + } + + @Test + fun localModified_putConflict_serverDeleted_yieldsConflict() = runTest { + val icalRepo = FakeIcalEntryRepository(dirty = listOf(entry(syncState = SyncState.LOCAL_MODIFIED))) + val remote = FakeRemote( + putResult = { PutResourceResult.Conflict }, + getResult = { GetResourceResult.NotFound } + ) + + coordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) + + assertEquals(SyncState.CONFLICT_LOCAL_MODIFIED_SERVER_DELETED, icalRepo.upserts.single().syncState) + } + + @Test + fun localModified_putNotFound_yieldsServerDeletedConflict() = runTest { + val icalRepo = FakeIcalEntryRepository(dirty = listOf(entry(syncState = SyncState.LOCAL_MODIFIED))) + val remote = FakeRemote(putResult = { PutResourceResult.NotFound }) + + coordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) + + assertEquals(SyncState.CONFLICT_LOCAL_MODIFIED_SERVER_DELETED, icalRepo.upserts.single().syncState) + } + + @Test + fun localModified_putFailed_leavesEntryForRetry() = runTest { + val icalRepo = FakeIcalEntryRepository(dirty = listOf(entry(syncState = SyncState.LOCAL_MODIFIED))) + val remote = FakeRemote(putResult = { PutResourceResult.Failed(HttpStatusCode.InternalServerError, "boom") }) + + coordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) + + // Left untouched for a later retry - no entry write, no metadata update. + assertTrue(icalRepo.upserts.isEmpty()) + assertTrue(icalRepo.syncMetadataUpdates.isEmpty()) + } + + // --- pushLocalChanges: LOCAL_DELETED --- + + @Test + fun localDeleted_deleteSuccess_movesToTrashbin() = runTest { + val icalRepo = FakeIcalEntryRepository(dirty = listOf(entry(syncState = SyncState.LOCAL_DELETED))) + val remote = FakeRemote(deleteResult = { DeleteResourceResult.Success }) + + coordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) + + assertEquals(SyncState.REMOTE_DELETED_LOCAL_TRASHBIN, icalRepo.upserts.single().syncState) + } + + @Test + fun localDeleted_deleteConflict_serverModified_yieldsConflict() = runTest { + val icalRepo = FakeIcalEntryRepository(dirty = listOf(entry(syncState = SyncState.LOCAL_DELETED))) + val remote = FakeRemote( + deleteResult = { DeleteResourceResult.Conflict }, + getResult = { GetResourceResult.Success(entry(syncState = SyncState.SYNCED)) } + ) + + coordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) + + assertEquals(SyncState.CONFLICT_LOCAL_DELETED_SERVER_MODIFIED, icalRepo.upserts.single().syncState) + } + + // --- pushLocalChanges: entries that should not be pushed --- + + @Test + fun conflictAndSyncedEntries_areLeftForTheUserOrIgnored() = runTest { + val icalRepo = FakeIcalEntryRepository( + dirty = listOf( + entry(id = 1, syncState = SyncState.CONFLICT_LOCAL_MODIFIED_SERVER_MODIFIED), + entry(id = 2, syncState = SyncState.SYNCED) + ) + ) + // A remote whose every mutating call throws would blow up if these were pushed. + val remote = FakeRemote() + + coordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) + + assertTrue(icalRepo.upserts.isEmpty()) + assertTrue(icalRepo.syncMetadataUpdates.isEmpty()) + } + + @Test + fun userDecidedServerWins_serverDeleted_movesToTrashbin() = runTest { + val icalRepo = FakeIcalEntryRepository(dirty = listOf(entry(syncState = SyncState.USER_DECIDED_SERVER_WINS))) + val remote = FakeRemote(getResult = { GetResourceResult.NotFound }) + + coordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) + + assertEquals(SyncState.REMOTE_DELETED_LOCAL_TRASHBIN, icalRepo.upserts.single().syncState) + } + + // --- applyServerchanges: upsert (update hrefs) --- + + @Test + fun serverUpdate_noLocalEntry_insertsAsSynced() = runTest { + val icalRepo = FakeIcalEntryRepository(byHref = emptyMap()) + val remote = FakeRemote( + syncCollectionResult = { MultigetSyncCollectionResult.Success("token2", mapOf(entryHref to "etag-1")) }, + fetchSingleResult = { MultigetResourceResult.Success(listOf(entry(syncState = SyncState.SYNCED))) } + ) + + coordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) + + assertEquals(SyncState.SYNCED, icalRepo.upserts.single().syncState) + } + + @Test + fun serverUpdate_localModified_yieldsConflict() = runTest { + val local = entry(syncState = SyncState.LOCAL_MODIFIED, etag = "etag-old") + val icalRepo = FakeIcalEntryRepository(byHref = mapOf(entryHref.toString() to local)) + val remote = FakeRemote( + syncCollectionResult = { MultigetSyncCollectionResult.Success("token2", mapOf(entryHref to "etag-new")) }, + fetchSingleResult = { MultigetResourceResult.Success(listOf(entry(syncState = SyncState.SYNCED, etag = "etag-new"))) } + ) + + coordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) + + assertEquals(SyncState.CONFLICT_LOCAL_MODIFIED_SERVER_MODIFIED, icalRepo.upserts.single().syncState) + } + + @Test + fun serverUpdate_sameEtag_isSkippedWithoutFetching() = runTest { + val local = entry(syncState = SyncState.SYNCED, etag = "etag-same") + val icalRepo = FakeIcalEntryRepository(byHref = mapOf(entryHref.toString() to local)) + val remote = FakeRemote( + syncCollectionResult = { MultigetSyncCollectionResult.Success("token2", mapOf(entryHref to "etag-same")) } + // fetchSingleResult intentionally left as the throwing default - must not be called. + ) + + coordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) + + assertEquals(0, remote.fetchCount) + assertTrue(icalRepo.upserts.isEmpty()) + } + + // --- applyServerchanges: remove (deleted hrefs) --- + + @Test + fun serverDeleted_syncedEntry_movesToTrashbin() = runTest { + val icalRepo = FakeIcalEntryRepository(entriesByHrefs = listOf(entry(syncState = SyncState.SYNCED))) + val remote = FakeRemote( + syncCollectionResult = { MultigetSyncCollectionResult.Success("token2", mapOf(entryHref to null)) } + ) + + coordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) + + assertEquals(SyncState.REMOTE_DELETED_LOCAL_TRASHBIN, icalRepo.upserts.single().syncState) + } + + @Test + fun serverDeleted_locallyModifiedEntry_yieldsConflict() = runTest { + val icalRepo = FakeIcalEntryRepository(entriesByHrefs = listOf(entry(syncState = SyncState.LOCAL_MODIFIED))) + val remote = FakeRemote( + syncCollectionResult = { MultigetSyncCollectionResult.Success("token2", mapOf(entryHref to null)) } + ) + + coordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) + + assertEquals(SyncState.CONFLICT_LOCAL_MODIFIED_SERVER_DELETED, icalRepo.upserts.single().syncState) + } + + // --- calendar sync status mapping --- + + @Test + fun notAuthorized_setsNotAuthorizedStatusAndDoesNotPush() = runTest { + val icalRepo = FakeIcalEntryRepository(dirty = listOf(entry(syncState = SyncState.LOCAL_MODIFIED))) + val remote = FakeRemote(syncCollectionResult = { MultigetSyncCollectionResult.NotAuthorized }) + val calRepo = FakeCalendarRepository() + + coordinator(remote, icalRepo, calRepo).syncCalendarWithSyncLock(calendar()) + + assertEquals(CalendarSyncStatusType.NOT_AUTHORIZED, calRepo.lastStatusType()) + assertTrue(icalRepo.syncMetadataUpdates.isEmpty(), "must not push local changes when not authorized") + } + + @Test + fun syncTokenFailed_fallsBackToFullSyncWithoutToken() = runTest { + val icalRepo = FakeIcalEntryRepository( + entriesByHrefs = emptyList(), + deletedDeltaHrefs = emptyList() + ) + val remote = FakeRemote( + syncCollectionResult = { MultigetSyncCollectionResult.Failed(HttpStatusCode.Conflict, "invalid sync token") }, + multigetHrefsResult = { MultigetResourceHrefETagResult.Success(emptyMap(), "fresh-token") } + ) + val calRepo = FakeCalendarRepository() + + coordinator(remote, icalRepo, calRepo).syncCalendarWithSyncLock(calendar()) + + // Failed sync-token report triggers the tokenless REPORT fallback, which then succeeds. + assertEquals(1, remote.multigetHrefsCount) + assertEquals(CalendarSyncStatusType.SYNCED, calRepo.lastStatusType()) + } + + // --- per-calendar locking --- + + @Test + fun secondConcurrentSyncOfSameCalendarIsSkipped() = runTest { + val gate = CompletableDeferred() + val icalRepo = FakeIcalEntryRepository() + val remote = FakeRemote( + syncCollectionResult = { + gate.await() // hold the first sync inside its critical section + MultigetSyncCollectionResult.Success("token2", emptyMap()) + } + ) + val cal = calendar(id = 99L) + val coordinator = coordinator(remote, icalRepo) + + val first = launch { coordinator.syncCalendarWithSyncLock(cal) } + runCurrent() // let the first sync acquire the per-calendar lock and park on the gate + + coordinator.syncCalendarWithSyncLock(cal) // second call: tryLock fails -> returns immediately + assertEquals(1, remote.syncCollectionCount, "second concurrent sync should be skipped") + + gate.complete(Unit) + first.join() + assertEquals(1, remote.syncCollectionCount) + } +} + +// --- Fakes --- + +private class FakeFileManager : FileManager { + override fun getAttachmentsDirectory() = "/fake" + override fun saveAttachment(fileName: String, bytes: ByteArray) = "/fake/$fileName" + override fun readAttachment(path: String) = ByteArray(0) + override fun deleteAttachment(path: String) = false + override fun exists(path: String) = true +} + +/** + * Scriptable server. Each response is a lambda so a test can inject conflicts, failures, or + * suspensions. Mutating calls (put/get/delete/upload/fetch) default to throwing, so a test + * path that reaches the server unexpectedly fails loudly instead of silently passing. + */ +private class FakeRemote( + private val syncCollectionResult: suspend () -> MultigetSyncCollectionResult = + { MultigetSyncCollectionResult.Success("token", emptyMap()) }, + private val multigetHrefsResult: suspend () -> MultigetResourceHrefETagResult = + { MultigetResourceHrefETagResult.Success(emptyMap(), "token") }, + // Mutating/fetch defaults throw AssertionError (an Error, so it is NOT swallowed by + // sync()'s catch(Exception)) - an unexpected server call propagates out and fails the test. + private val fetchSingleResult: suspend () -> MultigetResourceResult = + { throw AssertionError("fetchSingleEntry not expected in this test") }, + private val putResult: suspend () -> PutResourceResult = + { throw AssertionError("putResource not expected in this test") }, + private val getResult: suspend () -> GetResourceResult = + { throw AssertionError("getResource not expected in this test") }, + private val deleteResult: suspend () -> DeleteResourceResult = + { throw AssertionError("deleteResource not expected in this test") }, +) : WebDavRemoteDataSource { + + var syncCollectionCount = 0 + var multigetHrefsCount = 0 + var fetchCount = 0 + + override suspend fun syncCollection(calendar: Calendar): MultigetSyncCollectionResult { + syncCollectionCount++ + return syncCollectionResult() + } + + override suspend fun multigetResourceHrefs(calendar: Calendar): MultigetResourceHrefETagResult { + multigetHrefsCount++ + return multigetHrefsResult() + } + + override suspend fun fetchSingleEntry(calendar: Calendar, href: Url): MultigetResourceResult { + fetchCount++ + return fetchSingleResult() + } + + override suspend fun putResource(calendar: Calendar, icalEntry: IcalEntry) = putResult() + override suspend fun getResource(calendar: Calendar, icalEntry: IcalEntry) = getResult() + override suspend fun deleteResource(calendar: Calendar, icalEntry: IcalEntry) = deleteResult() + override suspend fun uploadFile(targetUrl: Url, bytes: ByteArray, mimeType: String?) = HttpStatusCode.Created +} + +private data class SyncMetadataUpdate(val etag: String?, val href: Url?, val syncState: SyncState?, val id: Long) + +private class FakeIcalEntryRepository( + private val dirty: List = emptyList(), + private val byHref: Map = emptyMap(), + private val byUid: Map = emptyMap(), + private val entriesByHrefs: List = emptyList(), + private val deletedDeltaHrefs: List = emptyList(), +) : IcalEntryRepository { + + val upserts = mutableListOf() + val syncMetadataUpdates = mutableListOf() + val attachmentUpserts = mutableListOf() + + override suspend fun getDirtyIcalEntriesByCalendar(calendarId: Long) = dirty + override suspend fun getIcalEntryByHref(href: Url) = byHref[href.toString()] + override suspend fun getIcalEntryByUid(calendarId: Long, uid: String) = byUid[uid] + override suspend fun getIcalEntriesByHrefs(hrefs: List) = entriesByHrefs + override suspend fun getDeletedDeltaHrefs(calendarId: Long, allServerHrefs: List) = deletedDeltaHrefs + + override suspend fun insertOrUpdateIcalEntry(icalEntry: IcalEntry): IcalEntry { + upserts += icalEntry + return icalEntry + } + + override suspend fun updateSyncMetadata(etag: String?, href: Url?, syncState: SyncState?, id: Long) { + syncMetadataUpdates += SyncMetadataUpdate(etag, href, syncState, id) + } + + override suspend fun insertOrUpdateAttachment(attachment: Attachment) { + attachmentUpserts += attachment + } + + // --- not exercised by the sync path --- + override fun getIcalEntriesByCalendarFlow(calendarId: Long): Flow> = TODO() + override fun getIcalEntryByUidFlow(calendarId: Long, uid: String): Flow = TODO() + override fun getAllColors(): Flow> = TODO() + override fun getAllCategories(): Flow> = TODO() + override fun getLastUsedTimezones(): Flow> = TODO() + override fun getSubtasksByParentUid(calendarId: Long, parentUid: String): Flow> = TODO() + override suspend fun getIcalEntryById(id: Long): IcalEntry? = TODO() + override suspend fun getIcalEntriesByCalendar(calendarId: Long): List = TODO() + override suspend fun markAsDeleted(ids: List) = TODO() + override suspend fun updateProgress(id: Long, percentComplete: Long, status: Status?, lastModified: IcsDateTime?, syncState: SyncState) = TODO() + override suspend fun updateOrderNo(sortedIcalEntryIds: List) = TODO() + override suspend fun updateColor(id: Long, color: Color?, lastModified: IcsDateTime?, syncState: SyncState) = TODO() + override suspend fun updateCategory(id: Long, categories: List, lastModified: IcsDateTime?, syncState: SyncState) = TODO() + override suspend fun deleteTrashed(cutoffDateTime: IcsDateTime) = TODO() + override suspend fun deleteAttachment(id: Long) = TODO() + override suspend fun getAttachmentsForEntry(entryId: Long): List = TODO() +} + +private class FakeCalendarRepository : CalendarRepository { + + val statuses = mutableListOf() + + fun lastStatusType(): CalendarSyncStatusType? = + statuses.lastOrNull()?.let { CalendarSyncStatus.deserialize(it).type } + + override suspend fun updateCalendarSyncStatus(calendarSyncStatus: String?, syncToken: String?, id: Long) { + statuses += calendarSyncStatus + } + + override suspend fun getPrincipalForCalendar(calendarId: Long): Principal? = null + + // --- not exercised by the instance sync path --- + override fun getAllPrincipalsFlow(): Flow> = TODO() + override fun getAllHomeCollectionsFlow(): Flow> = TODO() + override fun getAllCalendarsFlow(): Flow> = TODO() + override suspend fun getAllPrincipals(): List = TODO() + override suspend fun getAllHomeCollections(): List = TODO() + override suspend fun getAllCalendars(): List = TODO() + override suspend fun getCalendarById(id: Long): Calendar? = TODO() + override suspend fun getPrincipalUrlForCalendarId(calendarId: Long): String? = TODO() + override suspend fun getCalendarsForPrincipalUrl(principalUrl: String): List = TODO() + override suspend fun getCalendarsByIds(calendarIds: List): List = TODO() + override suspend fun upsertPrincipal(principal: Principal): Long = TODO() + override suspend fun upsertHomeCollection(homeCollection: HomeCollection, principalUrl: Url): Long = TODO() + override suspend fun upsertCalendar(calendar: Calendar, homeCollectionUrl: Url): Long = TODO() + override suspend fun deletePrincipal(id: Long) = TODO() + override suspend fun deleteCalendar(id: Long) = TODO() +} From fcc6790ce36103c1e2eeebd48b489423e3067ba4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 11:27:38 +0000 Subject: [PATCH 11/25] Route all CalDAV calls through injectable data-source interfaces Every DAV server call now goes through one of two injectable interfaces instead of a direct top-level function call, so the whole DAV surface is mockable and swappable - and there's a clean seam to lift into a standalone DAV library later. - WebDavRemoteCalendarDataSource: discovery (principals/home collections/ calendars) + calendar create/update/delete. - WebDavRemoteIcalEntryDataSource: entry sync (the two REPORTs, moved here from the calendar side where they never belonged - they enumerate a calendar's entries), single-entry fetch/put/get/delete, and attachment upload/download. Both are stateless with respect to credentials - credentials are passed per call - so the Default implementations hold only the transport (and a FileManager for inline attachments) and are registered as app-wide Koin singletons. This is what makes "inject once, call everywhere, construct nowhere" actually hold: nothing news up a data source or calls a *Multiplatform function except the two Default impls. Consumers migrated to inject the interfaces: AccountListViewModel (drops its HttpClient entirely), DetailsViewModel (keeps HttpClient only for the Claude client + SyncCoordinator), and SyncCoordinator (primary constructor now takes the IcalEntry data source + credentials; the 5-arg production constructor is unchanged, so companion/SyncTrigger call sites are untouched). Renamed discoverHomeCollections/discoverCalendars to the *Multiplatform suffix for naming consistency and to avoid a member/ top-level clash inside the Default impl. Tests: the SyncCoordinator fake now implements WebDavRemoteIcalEntryDataSource (credentials-per-call); no behavioral change. --- .../presentation/AccountListViewModel.kt | 23 +++---- .../screens/core/SyncCoordinator.kt | 40 ++++++------ .../RemoteDataSourceCalendarDiscovery.kt | 4 +- .../webdav/WebDavRemoteCalendarDataSource.kt | 48 ++++++++++++++ .../data/webdav/WebDavRemoteDataSource.kt | 54 ---------------- .../webdav/WebDavRemoteIcalEntryDataSource.kt | 62 +++++++++++++++++++ .../spectacled/screens/core/koin/Modules.kt | 9 +++ .../details/presentation/DetailsViewModel.kt | 5 +- .../screens/core/SyncCoordinatorTest.kt | 27 ++++---- 9 files changed, 168 insertions(+), 104 deletions(-) create mode 100644 shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteCalendarDataSource.kt delete mode 100644 shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteDataSource.kt create mode 100644 shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteIcalEntryDataSource.kt diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/AccountListViewModel.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/AccountListViewModel.kt index 8dfc1241..eb081817 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/AccountListViewModel.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/AccountListViewModel.kt @@ -13,12 +13,7 @@ import at.techbee.spectacled.screens.core.data.webdav.DiscoverCalendarsResult import at.techbee.spectacled.screens.core.data.webdav.DiscoverHomeCollectionsResult import at.techbee.spectacled.screens.core.data.webdav.DiscoverPrincipalsResult import at.techbee.spectacled.screens.core.data.webdav.UpsertCalendarResult -import at.techbee.spectacled.screens.core.data.webdav.createCalendarMultiplatform -import at.techbee.spectacled.screens.core.data.webdav.deleteCalendarMultiplatform -import at.techbee.spectacled.screens.core.data.webdav.discoverCalendars -import at.techbee.spectacled.screens.core.data.webdav.discoverHomeCollections -import at.techbee.spectacled.screens.core.data.webdav.discoverPrincipalsMultiplatform -import at.techbee.spectacled.screens.core.data.webdav.updateCalDavCalendarMultiplatform +import at.techbee.spectacled.screens.core.data.webdav.WebDavRemoteCalendarDataSource import at.techbee.spectacled.screens.core.domain.CalDavPrivilege import at.techbee.spectacled.screens.core.domain.Calendar import at.techbee.spectacled.screens.core.domain.CalendarSyncStatus @@ -27,7 +22,6 @@ import at.techbee.spectacled.screens.core.domain.HomeCollection import at.techbee.spectacled.screens.core.domain.Principal import at.techbee.spectacled.screens.core.domain.repository.CalendarRepository import io.github.aakira.napier.Napier -import io.ktor.client.HttpClient import io.ktor.http.Url import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -46,7 +40,7 @@ class AccountListViewModel( private val calendarRepository: CalendarRepository, private val credentialStore: PlatformCredentialStore, private val platformSyncTrigger: PlatformSyncTrigger, - private val client: HttpClient, + private val webDavCalendarDataSource: WebDavRemoteCalendarDataSource, val spectacledVariant: SpectacledVariant, val userAppPreferencesStore: PlatformUserAppPreferencesStore ): ViewModel() { @@ -154,7 +148,7 @@ class AccountListViewModel( try { val credentials = credentialStore.load(principal.principalUrl) ?: throw Exception("Credentials not found") - deleteCalendarMultiplatform(client, calendar, credentials).let { remoteResult -> + webDavCalendarDataSource.deleteCalendar(calendar, credentials).let { remoteResult -> when (remoteResult) { is DeleteCalendarResult.SuccessfullyDeleted, is DeleteCalendarResult.AlreadyDeleted -> { @@ -264,7 +258,7 @@ class AccountListViewModel( try { // STEP 1: Discover principals - val discoverPrincipalsResult = discoverPrincipalsMultiplatform(client, credentials.server, credentials) + val discoverPrincipalsResult = webDavCalendarDataSource.discoverPrincipals(credentials.server, credentials) when(discoverPrincipalsResult) { is DiscoverPrincipalsResult.Failed -> { _state.update { it.copy(processingState = ProcessingState.Error(message = discoverPrincipalsResult.message, detail = discoverPrincipalsResult.details)) } @@ -292,7 +286,7 @@ class AccountListViewModel( val discoveredCalendars = mutableListOf() discoverPrincipalsResult.principals.forEach { principal -> - when(val discoverHomeCollectionsResult = discoverHomeCollections(client, principal, credentials)) { + when(val discoverHomeCollectionsResult = webDavCalendarDataSource.discoverHomeCollections(principal, credentials)) { is DiscoverHomeCollectionsResult.Failed -> { _state.update { it.copy( processingState = ProcessingState.Error( @@ -318,8 +312,7 @@ class AccountListViewModel( // STEP 3: Discover Calendars discoveredHomeCollections.forEach { homeCollection -> - when(val discoverCalendarsResult = discoverCalendars( - client = client, + when(val discoverCalendarsResult = webDavCalendarDataSource.discoverCalendars( homeCollection = homeCollection, credentials = credentials )) { @@ -398,9 +391,9 @@ class AccountListViewModel( try { val credentials = credentialStore.load(principal.principalUrl) ?: throw Exception("Credentials not found") val upsertCalendarResult = if(calendar.id == 0L) { - createCalendarMultiplatform(client,calendar, credentials) + webDavCalendarDataSource.createCalendar(calendar, credentials) } else { - updateCalDavCalendarMultiplatform(client, calendar, credentials) + webDavCalendarDataSource.updateCalendar(calendar, credentials) } when(upsertCalendarResult) { diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/SyncCoordinator.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/SyncCoordinator.kt index 9b657a28..8e89abe5 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/SyncCoordinator.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/SyncCoordinator.kt @@ -3,14 +3,14 @@ package at.techbee.spectacled.screens.core import at.techbee.spectacled.screens.core.data.CredentialStore import at.techbee.spectacled.screens.core.data.Credentials import at.techbee.spectacled.screens.core.data.ics.IcsDateTime -import at.techbee.spectacled.screens.core.data.webdav.DefaultWebDavRemoteDataSource +import at.techbee.spectacled.screens.core.data.webdav.DefaultWebDavRemoteIcalEntryDataSource import at.techbee.spectacled.screens.core.data.webdav.DeleteResourceResult import at.techbee.spectacled.screens.core.data.webdav.GetResourceResult import at.techbee.spectacled.screens.core.data.webdav.MultigetResourceHrefETagResult import at.techbee.spectacled.screens.core.data.webdav.MultigetResourceResult import at.techbee.spectacled.screens.core.data.webdav.MultigetSyncCollectionResult import at.techbee.spectacled.screens.core.data.webdav.PutResourceResult -import at.techbee.spectacled.screens.core.data.webdav.WebDavRemoteDataSource +import at.techbee.spectacled.screens.core.data.webdav.WebDavRemoteIcalEntryDataSource import at.techbee.spectacled.screens.core.domain.AttachmentSyncState import at.techbee.spectacled.screens.core.domain.Calendar import at.techbee.spectacled.screens.core.domain.CalendarSyncStatus @@ -42,13 +42,14 @@ class SyncCoordinator( val calendarRepository: CalendarRepository, val icalEntryRepository: IcalEntryRepository, val fileManager: FileManager, - private val remote: WebDavRemoteDataSource, + private val remote: WebDavRemoteIcalEntryDataSource, + private val credentials: Credentials?, ) { - // Production entry point: builds the real server-backed data source from an HttpClient and - // credentials. Every existing call site uses this 5-arg form unchanged. Tests use the - // primary constructor with a fake WebDavRemoteDataSource, so the sync/conflict state machine - // can run without a server - or an HttpClient - at all. + // Production entry point: builds the real server-backed data source from an HttpClient. + // Every existing call site uses this 5-arg (client, credentials) form unchanged. Tests use + // the primary constructor with a fake WebDavRemoteIcalEntryDataSource, so the sync/conflict + // state machine can run without a server - or an HttpClient - at all. constructor( calendarRepository: CalendarRepository, icalEntryRepository: IcalEntryRepository, @@ -59,7 +60,8 @@ class SyncCoordinator( calendarRepository, icalEntryRepository, fileManager, - DefaultWebDavRemoteDataSource(client, credentials, fileManager), + DefaultWebDavRemoteIcalEntryDataSource(client, fileManager), + credentials, ) companion object { @@ -158,7 +160,7 @@ class SyncCoordinator( calendar.id ) - val syncCollectionResponse = remote.syncCollection(calendar) + val syncCollectionResponse = remote.syncCollection(calendar, credentials) when (syncCollectionResponse) { is MultigetSyncCollectionResult.Failed -> { @@ -268,7 +270,7 @@ class SyncCoordinator( } private suspend fun syncWithoutSyncToken(calendar: Calendar) { - when (val multigetResourceHrefsMultiplatformResult = remote.multigetResourceHrefs(calendar)) { + when (val multigetResourceHrefsMultiplatformResult = remote.multigetResourceHrefs(calendar, credentials)) { is MultigetResourceHrefETagResult.Failed -> { calendarRepository.updateCalendarSyncStatus( @@ -339,7 +341,7 @@ class SyncCoordinator( if (localIcalEntry?.href != null && localIcalEntry.etag == eTag) return // no eTag change, we skip - val serverIcalEntry = when (val fetchSingleResult = remote.fetchSingleEntry(calendar, href)) { + val serverIcalEntry = when (val fetchSingleResult = remote.fetchSingleEntry(calendar, href, credentials)) { is MultigetResourceResult.Failed -> return // skip failed entries MultigetResourceResult.NotAuthorized -> return // skip failed entries MultigetResourceResult.NotFound -> return // skip failed entries @@ -447,11 +449,11 @@ class SyncCoordinator( SyncState.LOCAL_MODIFIED -> { val entryToPush = pushAttachments(dirtyIcalEntry, calendar) // TODO: store error? - val insertOrUpdateIcalEntryResult = remote.putResource(calendar, entryToPush) + val insertOrUpdateIcalEntryResult = remote.putResource(calendar, entryToPush, credentials) when (insertOrUpdateIcalEntryResult) { // Conflict was detected, we get the latest resource PutResourceResult.Conflict -> { - val conflictingServerIcalEntryResult = remote.getResource(calendar, entryToPush) + val conflictingServerIcalEntryResult = remote.getResource(calendar, entryToPush, credentials) when (conflictingServerIcalEntryResult) { is GetResourceResult.Failed -> Unit // failed will be kept for another retry TODO: Review if this is sufficient in future @@ -483,11 +485,11 @@ class SyncCoordinator( // entry was locally modified, we put and see if there's a conflict SyncState.USER_DECIDED_CLIENT_WINS -> { val entryToPush = pushAttachments(dirtyIcalEntry, calendar) - val insertOrUpdateIcalEntryResult = remote.putResource(calendar, entryToPush) + val insertOrUpdateIcalEntryResult = remote.putResource(calendar, entryToPush, credentials) when (insertOrUpdateIcalEntryResult) { // Conflict was detected, we get the latest resource PutResourceResult.Conflict -> { - val conflictingServerIcalEntryResult = remote.getResource(calendar, entryToPush) + val conflictingServerIcalEntryResult = remote.getResource(calendar, entryToPush, credentials) when (conflictingServerIcalEntryResult) { // failed will be kept for another retry TODO: Review if this is sufficient in future @@ -536,7 +538,7 @@ class SyncCoordinator( // entry was locally modified, we put and see if there's a conflict SyncState.USER_DECIDED_SERVER_WINS -> { //TODO!! - val conflictingServerIcalEntryResult = remote.getResource(calendar, dirtyIcalEntry) + val conflictingServerIcalEntryResult = remote.getResource(calendar, dirtyIcalEntry, credentials) when (conflictingServerIcalEntryResult) { // failed will be kept for another retry TODO: Review if this is sufficient in future @@ -559,7 +561,7 @@ class SyncCoordinator( } SyncState.LOCAL_DELETED -> { - val deleteResourceResult = remote.deleteResource(calendar, dirtyIcalEntry) + val deleteResourceResult = remote.deleteResource(calendar, dirtyIcalEntry, credentials) when (deleteResourceResult) { // The entry was already deleted or successfully deleted on the server. We delete it locally. @@ -569,7 +571,7 @@ class SyncCoordinator( // There was a conflict, the resourcew as changed on the server, we discard the local delete and update the entry instead // TODO: Review in future DeleteResourceResult.Conflict -> { - val conflictingServerIcalEntryResult = remote.getResource(calendar, dirtyIcalEntry) + val conflictingServerIcalEntryResult = remote.getResource(calendar, dirtyIcalEntry, credentials) when (conflictingServerIcalEntryResult) { // failed will be kept for another retry TODO: Review if this is sufficient in future @@ -603,7 +605,7 @@ class SyncCoordinator( val bytes = fileManager.readAttachment(attachment.localPath) - val uploadResult = remote.uploadFile(safeTargetUrl, bytes, attachment.mimeType) + val uploadResult = remote.uploadFile(safeTargetUrl, bytes, attachment.mimeType, credentials) if (uploadResult.isSuccess()) { val syncedAttachment = attachment.copy( remoteUrl = safeTargetUrl.toString(), diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/RemoteDataSourceCalendarDiscovery.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/RemoteDataSourceCalendarDiscovery.kt index f76b9da1..8e34e64b 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/RemoteDataSourceCalendarDiscovery.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/RemoteDataSourceCalendarDiscovery.kt @@ -191,7 +191,7 @@ private suspend fun discoverPrincipalsInternal( } } -suspend fun discoverHomeCollections( +suspend fun discoverHomeCollectionsMultiplatform( client: HttpClient, principal: Principal, credentials: Credentials? @@ -281,7 +281,7 @@ suspend fun discoverHomeCollections( } } -suspend fun discoverCalendars( +suspend fun discoverCalendarsMultiplatform( client: HttpClient, homeCollection: HomeCollection, credentials: Credentials? diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteCalendarDataSource.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteCalendarDataSource.kt new file mode 100644 index 00000000..09480433 --- /dev/null +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteCalendarDataSource.kt @@ -0,0 +1,48 @@ +package at.techbee.spectacled.screens.core.data.webdav + +import at.techbee.spectacled.screens.core.data.Credentials +import at.techbee.spectacled.screens.core.domain.Calendar +import at.techbee.spectacled.screens.core.domain.HomeCollection +import at.techbee.spectacled.screens.core.domain.Principal +import io.ktor.client.HttpClient +import io.ktor.http.Url + +/** + * All CalDAV server operations about principals, home collections, and calendars (discovery + + * calendar management), behind an interface so every DAV call goes through an injectable seam + * rather than a direct function call. The data source is stateless with respect to credentials - + * they are passed per call - so [DefaultWebDavRemoteCalendarDataSource] can be a single injected + * singleton holding only the transport. This is also the natural boundary to lift into a + * standalone DAV library later. + */ +interface WebDavRemoteCalendarDataSource { + suspend fun discoverPrincipals(location: Url, credentials: Credentials?): DiscoverPrincipalsResult + suspend fun discoverHomeCollections(principal: Principal, credentials: Credentials?): DiscoverHomeCollectionsResult + suspend fun discoverCalendars(homeCollection: HomeCollection, credentials: Credentials?): DiscoverCalendarsResult + suspend fun createCalendar(calendar: Calendar, credentials: Credentials?): UpsertCalendarResult + suspend fun updateCalendar(calendar: Calendar, credentials: Credentials?): UpsertCalendarResult + suspend fun deleteCalendar(calendar: Calendar, credentials: Credentials?): DeleteCalendarResult +} + +class DefaultWebDavRemoteCalendarDataSource( + private val client: HttpClient, +) : WebDavRemoteCalendarDataSource { + + override suspend fun discoverPrincipals(location: Url, credentials: Credentials?) = + discoverPrincipalsMultiplatform(client, location, credentials) + + override suspend fun discoverHomeCollections(principal: Principal, credentials: Credentials?) = + discoverHomeCollectionsMultiplatform(client, principal, credentials) + + override suspend fun discoverCalendars(homeCollection: HomeCollection, credentials: Credentials?) = + discoverCalendarsMultiplatform(client, homeCollection, credentials) + + override suspend fun createCalendar(calendar: Calendar, credentials: Credentials?) = + createCalendarMultiplatform(client, calendar, credentials) + + override suspend fun updateCalendar(calendar: Calendar, credentials: Credentials?) = + updateCalDavCalendarMultiplatform(client, calendar, credentials) + + override suspend fun deleteCalendar(calendar: Calendar, credentials: Credentials?) = + deleteCalendarMultiplatform(client, calendar, credentials) +} diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteDataSource.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteDataSource.kt deleted file mode 100644 index dc1ecf07..00000000 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteDataSource.kt +++ /dev/null @@ -1,54 +0,0 @@ -package at.techbee.spectacled.screens.core.data.webdav - -import at.techbee.spectacled.screens.core.FileManager -import at.techbee.spectacled.screens.core.data.Credentials -import at.techbee.spectacled.screens.core.domain.Calendar -import at.techbee.spectacled.screens.core.domain.IcalEntry -import io.ktor.client.HttpClient -import io.ktor.http.HttpStatusCode -import io.ktor.http.Url - -/** - * The set of CalDAV server operations the SyncCoordinator needs, behind an interface so the - * sync/conflict state machine can be exercised in tests without a real server. The production - * implementation ([DefaultWebDavRemoteDataSource]) just delegates to the existing top-level - * *Multiplatform functions; the transport concerns (HttpClient, Credentials, FileManager) are - * held by the implementation so callers pass only the semantic arguments. - */ -interface WebDavRemoteDataSource { - suspend fun syncCollection(calendar: Calendar): MultigetSyncCollectionResult - suspend fun multigetResourceHrefs(calendar: Calendar): MultigetResourceHrefETagResult - suspend fun fetchSingleEntry(calendar: Calendar, href: Url): MultigetResourceResult - suspend fun putResource(calendar: Calendar, icalEntry: IcalEntry): PutResourceResult - suspend fun getResource(calendar: Calendar, icalEntry: IcalEntry): GetResourceResult - suspend fun deleteResource(calendar: Calendar, icalEntry: IcalEntry): DeleteResourceResult - suspend fun uploadFile(targetUrl: Url, bytes: ByteArray, mimeType: String?): HttpStatusCode -} - -class DefaultWebDavRemoteDataSource( - private val client: HttpClient, - private val credentials: Credentials?, - private val fileManager: FileManager?, -) : WebDavRemoteDataSource { - - override suspend fun syncCollection(calendar: Calendar) = - syncCollectionMultiplatform(client, calendar, credentials) - - override suspend fun multigetResourceHrefs(calendar: Calendar) = - multigetResourceHrefsMultiplatform(client, calendar, credentials) - - override suspend fun fetchSingleEntry(calendar: Calendar, href: Url) = - fetchSingleEntryMultiplatform(client, calendar, href, credentials, fileManager) - - override suspend fun putResource(calendar: Calendar, icalEntry: IcalEntry) = - putResourceMultiplatform(client, calendar, icalEntry, credentials, fileManager) - - override suspend fun getResource(calendar: Calendar, icalEntry: IcalEntry) = - getResourceMultiplatform(client, calendar, icalEntry, credentials, fileManager) - - override suspend fun deleteResource(calendar: Calendar, icalEntry: IcalEntry) = - deleteResourceMultiplatform(client, calendar, icalEntry, credentials) - - override suspend fun uploadFile(targetUrl: Url, bytes: ByteArray, mimeType: String?) = - uploadFileMultiplatform(client, targetUrl, bytes, mimeType, credentials) -} diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteIcalEntryDataSource.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteIcalEntryDataSource.kt new file mode 100644 index 00000000..1b76cc67 --- /dev/null +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteIcalEntryDataSource.kt @@ -0,0 +1,62 @@ +package at.techbee.spectacled.screens.core.data.webdav + +import at.techbee.spectacled.screens.core.FileManager +import at.techbee.spectacled.screens.core.data.Credentials +import at.techbee.spectacled.screens.core.domain.Calendar +import at.techbee.spectacled.screens.core.domain.IcalEntry +import io.ktor.client.HttpClient +import io.ktor.http.HttpStatusCode +import io.ktor.http.Url + +/** + * All CalDAV server operations about the resources within a calendar - enumerating/syncing + * entries, reading/writing individual entries, and transferring attachment files - behind an + * interface so every DAV call goes through an injectable seam. Like + * [WebDavRemoteCalendarDataSource] it is stateless with respect to credentials (passed per + * call), so [DefaultWebDavRemoteIcalEntryDataSource] is a single injected singleton holding only + * the transport and a [FileManager] (used to materialize inline attachments while parsing). + * + * The two REPORT operations (syncCollection / multigetResourceHrefs) live here rather than with + * the calendar data source: they enumerate a calendar's entries, which is an entry concern, not + * calendar management. + */ +interface WebDavRemoteIcalEntryDataSource { + suspend fun syncCollection(calendar: Calendar, credentials: Credentials?): MultigetSyncCollectionResult + suspend fun multigetResourceHrefs(calendar: Calendar, credentials: Credentials?): MultigetResourceHrefETagResult + suspend fun fetchSingleEntry(calendar: Calendar, href: Url, credentials: Credentials?): MultigetResourceResult + suspend fun putResource(calendar: Calendar, icalEntry: IcalEntry, credentials: Credentials?): PutResourceResult + suspend fun getResource(calendar: Calendar, icalEntry: IcalEntry, credentials: Credentials?): GetResourceResult + suspend fun deleteResource(calendar: Calendar, icalEntry: IcalEntry, credentials: Credentials?): DeleteResourceResult + suspend fun uploadFile(targetUrl: Url, bytes: ByteArray, mimeType: String?, credentials: Credentials?): HttpStatusCode + suspend fun downloadFile(sourceUrl: Url, credentials: Credentials?): ByteArray? +} + +class DefaultWebDavRemoteIcalEntryDataSource( + private val client: HttpClient, + private val fileManager: FileManager, +) : WebDavRemoteIcalEntryDataSource { + + override suspend fun syncCollection(calendar: Calendar, credentials: Credentials?) = + syncCollectionMultiplatform(client, calendar, credentials) + + override suspend fun multigetResourceHrefs(calendar: Calendar, credentials: Credentials?) = + multigetResourceHrefsMultiplatform(client, calendar, credentials) + + override suspend fun fetchSingleEntry(calendar: Calendar, href: Url, credentials: Credentials?) = + fetchSingleEntryMultiplatform(client, calendar, href, credentials, fileManager) + + override suspend fun putResource(calendar: Calendar, icalEntry: IcalEntry, credentials: Credentials?) = + putResourceMultiplatform(client, calendar, icalEntry, credentials, fileManager) + + override suspend fun getResource(calendar: Calendar, icalEntry: IcalEntry, credentials: Credentials?) = + getResourceMultiplatform(client, calendar, icalEntry, credentials, fileManager) + + override suspend fun deleteResource(calendar: Calendar, icalEntry: IcalEntry, credentials: Credentials?) = + deleteResourceMultiplatform(client, calendar, icalEntry, credentials) + + override suspend fun uploadFile(targetUrl: Url, bytes: ByteArray, mimeType: String?, credentials: Credentials?) = + uploadFileMultiplatform(client, targetUrl, bytes, mimeType, credentials) + + override suspend fun downloadFile(sourceUrl: Url, credentials: Credentials?) = + downloadFileMultiplatform(client, sourceUrl, credentials) +} diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.kt index dd719d6c..a5376166 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.kt @@ -7,6 +7,10 @@ import at.techbee.spectacled.screens.core.data.UserAppPreferencesStore import at.techbee.spectacled.screens.core.data.getPlatformEngine import at.techbee.spectacled.screens.core.data.repository.CalendarRepositoryImpl import at.techbee.spectacled.screens.core.data.repository.IcalEntryRepositoryImpl +import at.techbee.spectacled.screens.core.data.webdav.DefaultWebDavRemoteCalendarDataSource +import at.techbee.spectacled.screens.core.data.webdav.DefaultWebDavRemoteIcalEntryDataSource +import at.techbee.spectacled.screens.core.data.webdav.WebDavRemoteCalendarDataSource +import at.techbee.spectacled.screens.core.data.webdav.WebDavRemoteIcalEntryDataSource import at.techbee.spectacled.screens.core.domain.repository.CalendarRepository import at.techbee.spectacled.screens.core.domain.repository.IcalEntryRepository import at.techbee.spectacled.screens.details.presentation.DetailsViewModel @@ -28,6 +32,11 @@ val sharedModule = module { singleOf(::CalendarRepositoryImpl) { bind() } singleOf(::IcalEntryRepositoryImpl) { bind() } + // Stateless DAV data sources - hold only the transport (and a FileManager for entry + // attachments); credentials are passed per call, so these can be app-wide singletons. + single { DefaultWebDavRemoteCalendarDataSource(get()) } + single { DefaultWebDavRemoteIcalEntryDataSource(get(), get()) } + viewModelOf(::ListViewModel) viewModelOf(::AccountListViewModel) viewModelOf(::DetailsViewModel) diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt index a20abb2a..8bd975a7 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt @@ -16,7 +16,7 @@ import at.techbee.spectacled.screens.core.data.PlatformUserAppPreferencesStore import at.techbee.spectacled.screens.core.data.claude.ClaudeRemoteResponseResult import at.techbee.spectacled.screens.core.data.claude.KtorRemoteClaudeDataSource import at.techbee.spectacled.screens.core.data.ics.IcsDateTime -import at.techbee.spectacled.screens.core.data.webdav.downloadFileMultiplatform +import at.techbee.spectacled.screens.core.data.webdav.WebDavRemoteIcalEntryDataSource import at.techbee.spectacled.screens.core.domain.Attachment import at.techbee.spectacled.screens.core.domain.AttachmentSyncState import at.techbee.spectacled.screens.core.domain.IcalEntry @@ -67,6 +67,7 @@ class DetailsViewModel( private val fileManager: PlatformFileManager, private val fileLauncher: PlatformFileLauncher, private val client: HttpClient, + private val webDavIcalEntryDataSource: WebDavRemoteIcalEntryDataSource, private val platformSyncTrigger: PlatformSyncTrigger, private val shareManager: PlatformShareManager, private val userAppPreferencesStore: PlatformUserAppPreferencesStore, @@ -768,7 +769,7 @@ class DetailsViewModel( ?.let { Url(it) } ?: throw Exception("Principal not found") val credentials = credentialStore.load(principalUrl) ?: throw Exception("Credentials not found") - val bytes = downloadFileMultiplatform(client, Url(attachment.remoteUrl), credentials) + val bytes = webDavIcalEntryDataSource.downloadFile(Url(attachment.remoteUrl), credentials) if (bytes != null) { // On Web, we open directly from bytes. On Native, we save then open. if (getPlatform().platform == Platforms.WASM) { diff --git a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/SyncCoordinatorTest.kt b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/SyncCoordinatorTest.kt index 27f2d16b..4ae71161 100644 --- a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/SyncCoordinatorTest.kt +++ b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/SyncCoordinatorTest.kt @@ -1,6 +1,7 @@ package at.techbee.spectacled.screens.core import androidx.compose.ui.graphics.Color +import at.techbee.spectacled.screens.core.data.Credentials import at.techbee.spectacled.screens.core.data.ics.IcsDateTime import at.techbee.spectacled.screens.core.data.webdav.DeleteResourceResult import at.techbee.spectacled.screens.core.data.webdav.GetResourceResult @@ -8,7 +9,7 @@ import at.techbee.spectacled.screens.core.data.webdav.MultigetResourceHrefETagRe import at.techbee.spectacled.screens.core.data.webdav.MultigetResourceResult import at.techbee.spectacled.screens.core.data.webdav.MultigetSyncCollectionResult import at.techbee.spectacled.screens.core.data.webdav.PutResourceResult -import at.techbee.spectacled.screens.core.data.webdav.WebDavRemoteDataSource +import at.techbee.spectacled.screens.core.data.webdav.WebDavRemoteIcalEntryDataSource import at.techbee.spectacled.screens.core.domain.Attachment import at.techbee.spectacled.screens.core.domain.Calendar import at.techbee.spectacled.screens.core.domain.CalendarComponent @@ -34,7 +35,7 @@ import kotlin.test.assertTrue /** * Drives the SyncCoordinator's conflict/sync state machine through its public - * syncCalendarWithSyncLock entry point, with a fake WebDavRemoteDataSource scripting the + * syncCalendarWithSyncLock entry point, with a fake WebDavRemoteIcalEntryDataSource scripting the * server's responses and fake repositories recording what the coordinator decided to write. * No real HttpClient, server, or database is involved. */ @@ -64,10 +65,10 @@ class SyncCoordinatorTest { ) private fun coordinator( - remote: WebDavRemoteDataSource, + remote: WebDavRemoteIcalEntryDataSource, icalRepo: FakeIcalEntryRepository, calRepo: FakeCalendarRepository = FakeCalendarRepository() - ) = SyncCoordinator(calRepo, icalRepo, FakeFileManager(), remote) + ) = SyncCoordinator(calRepo, icalRepo, FakeFileManager(), remote, credentials = null) // --- pushLocalChanges: LOCAL_MODIFIED --- @@ -348,31 +349,33 @@ private class FakeRemote( { throw AssertionError("getResource not expected in this test") }, private val deleteResult: suspend () -> DeleteResourceResult = { throw AssertionError("deleteResource not expected in this test") }, -) : WebDavRemoteDataSource { +) : WebDavRemoteIcalEntryDataSource { var syncCollectionCount = 0 var multigetHrefsCount = 0 var fetchCount = 0 - override suspend fun syncCollection(calendar: Calendar): MultigetSyncCollectionResult { + override suspend fun syncCollection(calendar: Calendar, credentials: Credentials?): MultigetSyncCollectionResult { syncCollectionCount++ return syncCollectionResult() } - override suspend fun multigetResourceHrefs(calendar: Calendar): MultigetResourceHrefETagResult { + override suspend fun multigetResourceHrefs(calendar: Calendar, credentials: Credentials?): MultigetResourceHrefETagResult { multigetHrefsCount++ return multigetHrefsResult() } - override suspend fun fetchSingleEntry(calendar: Calendar, href: Url): MultigetResourceResult { + override suspend fun fetchSingleEntry(calendar: Calendar, href: Url, credentials: Credentials?): MultigetResourceResult { fetchCount++ return fetchSingleResult() } - override suspend fun putResource(calendar: Calendar, icalEntry: IcalEntry) = putResult() - override suspend fun getResource(calendar: Calendar, icalEntry: IcalEntry) = getResult() - override suspend fun deleteResource(calendar: Calendar, icalEntry: IcalEntry) = deleteResult() - override suspend fun uploadFile(targetUrl: Url, bytes: ByteArray, mimeType: String?) = HttpStatusCode.Created + override suspend fun putResource(calendar: Calendar, icalEntry: IcalEntry, credentials: Credentials?) = putResult() + override suspend fun getResource(calendar: Calendar, icalEntry: IcalEntry, credentials: Credentials?) = getResult() + override suspend fun deleteResource(calendar: Calendar, icalEntry: IcalEntry, credentials: Credentials?) = deleteResult() + override suspend fun uploadFile(targetUrl: Url, bytes: ByteArray, mimeType: String?, credentials: Credentials?) = HttpStatusCode.Created + override suspend fun downloadFile(sourceUrl: Url, credentials: Credentials?): ByteArray = + throw AssertionError("downloadFile not expected in a sync test") } private data class SyncMetadataUpdate(val etag: String?, val href: Url?, val syncState: SyncState?, val id: Long) From fd8644ed5793a2b9a6317686b5a464287f993af3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 11:52:57 +0000 Subject: [PATCH 12/25] Harmonize DAV data-source Koin bindings to singleOf, matching the repositories Both Default DAV data-source constructors take only injectable params, so they can use the same singleOf(::Ctor) { bind() } form the repository singletons already use, instead of a single { ... } lambda. (The HttpClient binding stays a single { } lambda since it is built from a factory method, not a constructor.) --- .../kotlin/at/techbee/spectacled/screens/core/koin/Modules.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.kt index a5376166..271e8a65 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.kt @@ -34,8 +34,8 @@ val sharedModule = module { // Stateless DAV data sources - hold only the transport (and a FileManager for entry // attachments); credentials are passed per call, so these can be app-wide singletons. - single { DefaultWebDavRemoteCalendarDataSource(get()) } - single { DefaultWebDavRemoteIcalEntryDataSource(get(), get()) } + singleOf(::DefaultWebDavRemoteCalendarDataSource) { bind() } + singleOf(::DefaultWebDavRemoteIcalEntryDataSource) { bind() } viewModelOf(::ListViewModel) viewModelOf(::AccountListViewModel) From 7414654f9209978b829917e678e244150c5c8cbf Mon Sep 17 00:00:00 2001 From: Patrick Lang <72232737+patrickunterwegs@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:56:09 +0200 Subject: [PATCH 13/25] Minor code formatting update, removed comment --- .../kotlin/at/techbee/spectacled/screens/core/koin/Modules.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.kt index 271e8a65..665a8f5c 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.kt @@ -22,6 +22,7 @@ import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module val sharedModule = module { + single { val preferences: UserAppPreferencesStore = get() HttpClientFactory.create( @@ -29,11 +30,10 @@ val sharedModule = module { userProxyUrlProvider = { preferences.userProxyServer } ) } + singleOf(::CalendarRepositoryImpl) { bind() } singleOf(::IcalEntryRepositoryImpl) { bind() } - // Stateless DAV data sources - hold only the transport (and a FileManager for entry - // attachments); credentials are passed per call, so these can be app-wide singletons. singleOf(::DefaultWebDavRemoteCalendarDataSource) { bind() } singleOf(::DefaultWebDavRemoteIcalEntryDataSource) { bind() } From 2b757d28b33897f954e816e87ebc6f0cb71633f6 Mon Sep 17 00:00:00 2001 From: Patrick Lang <72232737+patrickunterwegs@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:59:01 +0200 Subject: [PATCH 14/25] Resolved warnings --- .../spectacled/screens/core/SyncCoordinatorTest.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/SyncCoordinatorTest.kt b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/SyncCoordinatorTest.kt index 4ae71161..37969bc8 100644 --- a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/SyncCoordinatorTest.kt +++ b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/SyncCoordinatorTest.kt @@ -25,6 +25,7 @@ import at.techbee.spectacled.screens.core.domain.repository.IcalEntryRepository import io.ktor.http.HttpStatusCode import io.ktor.http.Url import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import kotlinx.coroutines.test.runCurrent @@ -294,6 +295,7 @@ class SyncCoordinatorTest { // --- per-calendar locking --- + @OptIn(ExperimentalCoroutinesApi::class) @Test fun secondConcurrentSyncOfSameCalendarIsSkipped() = runTest { val gate = CompletableDeferred() @@ -418,7 +420,7 @@ private class FakeIcalEntryRepository( override fun getAllCategories(): Flow> = TODO() override fun getLastUsedTimezones(): Flow> = TODO() override fun getSubtasksByParentUid(calendarId: Long, parentUid: String): Flow> = TODO() - override suspend fun getIcalEntryById(id: Long): IcalEntry? = TODO() + override suspend fun getIcalEntryById(id: Long): IcalEntry = TODO() override suspend fun getIcalEntriesByCalendar(calendarId: Long): List = TODO() override suspend fun markAsDeleted(ids: List) = TODO() override suspend fun updateProgress(id: Long, percentComplete: Long, status: Status?, lastModified: IcsDateTime?, syncState: SyncState) = TODO() @@ -450,8 +452,8 @@ private class FakeCalendarRepository : CalendarRepository { override suspend fun getAllPrincipals(): List = TODO() override suspend fun getAllHomeCollections(): List = TODO() override suspend fun getAllCalendars(): List = TODO() - override suspend fun getCalendarById(id: Long): Calendar? = TODO() - override suspend fun getPrincipalUrlForCalendarId(calendarId: Long): String? = TODO() + override suspend fun getCalendarById(id: Long): Calendar = TODO() + override suspend fun getPrincipalUrlForCalendarId(calendarId: Long): String = TODO() override suspend fun getCalendarsForPrincipalUrl(principalUrl: String): List = TODO() override suspend fun getCalendarsByIds(calendarIds: List): List = TODO() override suspend fun upsertPrincipal(principal: Principal): Long = TODO() From e40bc9c6714f0db78a7bdc50f2ea26600af05a4a Mon Sep 17 00:00:00 2001 From: Patrick Lang <72232737+patrickunterwegs@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:01:49 +0200 Subject: [PATCH 15/25] variable namings update --- .../screens/core/SyncCoordinatorTest.kt | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/SyncCoordinatorTest.kt b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/SyncCoordinatorTest.kt index 37969bc8..011f47a2 100644 --- a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/SyncCoordinatorTest.kt +++ b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/SyncCoordinatorTest.kt @@ -65,11 +65,11 @@ class SyncCoordinatorTest { calendarComponent = CalendarComponent.VJOURNAL ) - private fun coordinator( - remote: WebDavRemoteIcalEntryDataSource, - icalRepo: FakeIcalEntryRepository, - calRepo: FakeCalendarRepository = FakeCalendarRepository() - ) = SyncCoordinator(calRepo, icalRepo, FakeFileManager(), remote, credentials = null) + private fun fakeSyncCoordinator( + fakeWebDavRemoteIcalEntryDataSource: WebDavRemoteIcalEntryDataSource, + fakeIcalEntryRepository: FakeIcalEntryRepository, + fakeCalendarRepository: FakeCalendarRepository = FakeCalendarRepository() + ) = SyncCoordinator(fakeCalendarRepository, fakeIcalEntryRepository, FakeFileManager(), fakeWebDavRemoteIcalEntryDataSource, credentials = null) // --- pushLocalChanges: LOCAL_MODIFIED --- @@ -79,7 +79,7 @@ class SyncCoordinatorTest { val remote = FakeRemote(putResult = { PutResourceResult.Success(entry(syncState = SyncState.SYNCED, etag = "etag-new")) }) val calRepo = FakeCalendarRepository() - coordinator(remote, icalRepo, calRepo).syncCalendarWithSyncLock(calendar()) + fakeSyncCoordinator(remote, icalRepo, calRepo).syncCalendarWithSyncLock(calendar()) val update = icalRepo.syncMetadataUpdates.single() assertEquals(SyncState.SYNCED, update.syncState) @@ -96,7 +96,7 @@ class SyncCoordinatorTest { getResult = { GetResourceResult.Success(entry(syncState = SyncState.SYNCED, etag = "etag-server")) } ) - coordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) + fakeSyncCoordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) assertEquals(SyncState.CONFLICT_LOCAL_MODIFIED_SERVER_MODIFIED, icalRepo.upserts.single().syncState) } @@ -109,7 +109,7 @@ class SyncCoordinatorTest { getResult = { GetResourceResult.NotFound } ) - coordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) + fakeSyncCoordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) assertEquals(SyncState.CONFLICT_LOCAL_MODIFIED_SERVER_DELETED, icalRepo.upserts.single().syncState) } @@ -119,7 +119,7 @@ class SyncCoordinatorTest { val icalRepo = FakeIcalEntryRepository(dirty = listOf(entry(syncState = SyncState.LOCAL_MODIFIED))) val remote = FakeRemote(putResult = { PutResourceResult.NotFound }) - coordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) + fakeSyncCoordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) assertEquals(SyncState.CONFLICT_LOCAL_MODIFIED_SERVER_DELETED, icalRepo.upserts.single().syncState) } @@ -129,7 +129,7 @@ class SyncCoordinatorTest { val icalRepo = FakeIcalEntryRepository(dirty = listOf(entry(syncState = SyncState.LOCAL_MODIFIED))) val remote = FakeRemote(putResult = { PutResourceResult.Failed(HttpStatusCode.InternalServerError, "boom") }) - coordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) + fakeSyncCoordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) // Left untouched for a later retry - no entry write, no metadata update. assertTrue(icalRepo.upserts.isEmpty()) @@ -143,7 +143,7 @@ class SyncCoordinatorTest { val icalRepo = FakeIcalEntryRepository(dirty = listOf(entry(syncState = SyncState.LOCAL_DELETED))) val remote = FakeRemote(deleteResult = { DeleteResourceResult.Success }) - coordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) + fakeSyncCoordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) assertEquals(SyncState.REMOTE_DELETED_LOCAL_TRASHBIN, icalRepo.upserts.single().syncState) } @@ -156,7 +156,7 @@ class SyncCoordinatorTest { getResult = { GetResourceResult.Success(entry(syncState = SyncState.SYNCED)) } ) - coordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) + fakeSyncCoordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) assertEquals(SyncState.CONFLICT_LOCAL_DELETED_SERVER_MODIFIED, icalRepo.upserts.single().syncState) } @@ -174,7 +174,7 @@ class SyncCoordinatorTest { // A remote whose every mutating call throws would blow up if these were pushed. val remote = FakeRemote() - coordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) + fakeSyncCoordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) assertTrue(icalRepo.upserts.isEmpty()) assertTrue(icalRepo.syncMetadataUpdates.isEmpty()) @@ -185,7 +185,7 @@ class SyncCoordinatorTest { val icalRepo = FakeIcalEntryRepository(dirty = listOf(entry(syncState = SyncState.USER_DECIDED_SERVER_WINS))) val remote = FakeRemote(getResult = { GetResourceResult.NotFound }) - coordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) + fakeSyncCoordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) assertEquals(SyncState.REMOTE_DELETED_LOCAL_TRASHBIN, icalRepo.upserts.single().syncState) } @@ -200,7 +200,7 @@ class SyncCoordinatorTest { fetchSingleResult = { MultigetResourceResult.Success(listOf(entry(syncState = SyncState.SYNCED))) } ) - coordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) + fakeSyncCoordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) assertEquals(SyncState.SYNCED, icalRepo.upserts.single().syncState) } @@ -214,7 +214,7 @@ class SyncCoordinatorTest { fetchSingleResult = { MultigetResourceResult.Success(listOf(entry(syncState = SyncState.SYNCED, etag = "etag-new"))) } ) - coordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) + fakeSyncCoordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) assertEquals(SyncState.CONFLICT_LOCAL_MODIFIED_SERVER_MODIFIED, icalRepo.upserts.single().syncState) } @@ -228,7 +228,7 @@ class SyncCoordinatorTest { // fetchSingleResult intentionally left as the throwing default - must not be called. ) - coordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) + fakeSyncCoordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) assertEquals(0, remote.fetchCount) assertTrue(icalRepo.upserts.isEmpty()) @@ -243,7 +243,7 @@ class SyncCoordinatorTest { syncCollectionResult = { MultigetSyncCollectionResult.Success("token2", mapOf(entryHref to null)) } ) - coordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) + fakeSyncCoordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) assertEquals(SyncState.REMOTE_DELETED_LOCAL_TRASHBIN, icalRepo.upserts.single().syncState) } @@ -255,7 +255,7 @@ class SyncCoordinatorTest { syncCollectionResult = { MultigetSyncCollectionResult.Success("token2", mapOf(entryHref to null)) } ) - coordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) + fakeSyncCoordinator(remote, icalRepo).syncCalendarWithSyncLock(calendar()) assertEquals(SyncState.CONFLICT_LOCAL_MODIFIED_SERVER_DELETED, icalRepo.upserts.single().syncState) } @@ -268,7 +268,7 @@ class SyncCoordinatorTest { val remote = FakeRemote(syncCollectionResult = { MultigetSyncCollectionResult.NotAuthorized }) val calRepo = FakeCalendarRepository() - coordinator(remote, icalRepo, calRepo).syncCalendarWithSyncLock(calendar()) + fakeSyncCoordinator(remote, icalRepo, calRepo).syncCalendarWithSyncLock(calendar()) assertEquals(CalendarSyncStatusType.NOT_AUTHORIZED, calRepo.lastStatusType()) assertTrue(icalRepo.syncMetadataUpdates.isEmpty(), "must not push local changes when not authorized") @@ -286,7 +286,7 @@ class SyncCoordinatorTest { ) val calRepo = FakeCalendarRepository() - coordinator(remote, icalRepo, calRepo).syncCalendarWithSyncLock(calendar()) + fakeSyncCoordinator(remote, icalRepo, calRepo).syncCalendarWithSyncLock(calendar()) // Failed sync-token report triggers the tokenless REPORT fallback, which then succeeds. assertEquals(1, remote.multigetHrefsCount) @@ -307,7 +307,7 @@ class SyncCoordinatorTest { } ) val cal = calendar(id = 99L) - val coordinator = coordinator(remote, icalRepo) + val coordinator = fakeSyncCoordinator(remote, icalRepo) val first = launch { coordinator.syncCalendarWithSyncLock(cal) } runCurrent() // let the first sync acquire the per-calendar lock and park on the gate From 8f206649e8547a76a26ce864b4269297f8477f55 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 12:15:50 +0000 Subject: [PATCH 16/25] Add wire-format tests for the low-level DAV parsing functions Adds ktor-client-mock as a commonTest dependency and a WebDavParsingTest suite that drives the *Multiplatform DAV functions against scripted MockEngine responses. These cover the parsing seam the fake-interface SyncCoordinator tests deliberately skip: - sync-collection: sync-token + etag map parsing, null-etag deletion signal, relative-href resolution, REPORT/Depth/Basic-auth request shape, and 404/401/500 status mapping - multiget href report: 200-only propstat filtering - principal discovery: current-user-principal href resolution and the all-403 -> NotAuthorized path - home-collection discovery: home-set/displayname/address-set extraction - calendar discovery: component/resourcetype filtering (VJOURNAL kept, VEVENT-only dropped) and home-collection privilege extraction Also verifies credentials-per-call: a call with credentials sends Basic auth, a call with null sends none. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Xt7MRuFpW1jtcHX1MPC4EN --- gradle/libs.versions.toml | 1 + shared/build.gradle.kts | 1 + .../core/data/webdav/WebDavParsingTest.kt | 404 ++++++++++++++++++ 3 files changed, 406 insertions(+) create mode 100644 shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavParsingTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 00537b7e..a79c5b56 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -94,6 +94,7 @@ ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "k ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-serialization-kotlinx-xml = { module = "io.ktor:ktor-serialization-kotlinx-xml", version.ref = "ktor" } ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index a74ee632..7ef9d085 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -109,6 +109,7 @@ kotlin { commonTest.dependencies { implementation(libs.kotlin.test) implementation(libs.kotlinx.coroutines.test) + implementation(libs.ktor.client.mock) } androidMain.dependencies { diff --git a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavParsingTest.kt b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavParsingTest.kt new file mode 100644 index 00000000..3eb379af --- /dev/null +++ b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavParsingTest.kt @@ -0,0 +1,404 @@ +package at.techbee.spectacled.screens.core.data.webdav + +import at.techbee.spectacled.screens.core.data.Credentials +import at.techbee.spectacled.screens.core.domain.CalDavPrivilege +import at.techbee.spectacled.screens.core.domain.Calendar +import at.techbee.spectacled.screens.core.domain.CalendarComponent +import at.techbee.spectacled.screens.core.domain.HomeCollection +import at.techbee.spectacled.screens.core.domain.Principal +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.request.HttpRequestData +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.Url +import io.ktor.http.headersOf +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Wire-format tests for the low-level `*Multiplatform` DAV functions. These exercise the seam + * that the fake-interface [at.techbee.spectacled.screens.core.SyncCoordinatorTest] deliberately + * skips: the parsing of real CalDAV multistatus XML into domain results, plus the HTTP status-code + * mapping and per-call credential handling. A Ktor [MockEngine] scripts canned server responses so + * the parser can be driven with realistic Nextcloud/Radicale-style payloads without a network. + */ +class WebDavParsingTest { + + // --- test infrastructure ------------------------------------------------------------------ + + /** Captures the single request the function under test issues, for request-shape assertions. */ + private class RequestCapture { + var request: HttpRequestData? = null + } + + /** A client whose one handler always answers with [body] and [status]. */ + private fun mockClient( + status: HttpStatusCode = HttpStatusCode.MultiStatus, + body: String = "", + capture: RequestCapture? = null, + ): HttpClient = HttpClient(MockEngine) { + engine { + addHandler { request -> + capture?.request = request + respond( + content = body, + status = status, + headers = headersOf(HttpHeaders.ContentType, "application/xml; charset=utf-8"), + ) + } + } + } + + private val credentials = Credentials( + server = Url("https://dav.example.com"), + username = "alice", + password = "s3cret", + ) + + private fun calendar(url: String = "https://dav.example.com/cal/personal/") = Calendar( + id = 1L, + homeCollectionId = 1L, + url = Url(url), + displayName = "Personal", + calendarDescription = null, + color = null, + ctag = null, + supportedComponents = listOf(CalendarComponent.VJOURNAL), + calDavPrivileges = emptyList(), + calendarSyncStatus = null, + syncToken = "sync-token-1", + ) + + // --- syncCollectionMultiplatform ---------------------------------------------------------- + + @Test + fun syncCollection_parsesSyncTokenAndEtagMap() = runTest { + // entry1 changed (200 + etag), deleted.ics removed (a non-200 propstat -> null etag). + val body = """ + + + http://sync.example.com/token/2 + + /cal/personal/entry1.ics + + "etag-1" + HTTP/1.1 200 OK + + + + /cal/personal/deleted.ics + + + HTTP/1.1 404 Not Found + + + + """.trimIndent() + + val result = syncCollectionMultiplatform(mockClient(body = body), calendar(), credentials) + + val success = assertIs(result) + assertEquals("http://sync.example.com/token/2", success.syncToken) + assertEquals(2, success.hrefs.size) + val changed = success.hrefs.entries.single { it.key.toString().endsWith("entry1.ics") } + val removed = success.hrefs.entries.single { it.key.toString().endsWith("deleted.ics") } + assertEquals("\"etag-1\"", changed.value) + assertNull(removed.value, "a deleted resource must map to a null etag (the deletion signal)") + } + + @Test + fun syncCollection_resolvesRelativeHrefAgainstCalendarUrl() = runTest { + val body = """ + + t + + /cal/personal/entry1.ics + "e"HTTP/1.1 200 OK + + + """.trimIndent() + + val result = syncCollectionMultiplatform(mockClient(body = body), calendar(), credentials) + + val success = assertIs(result) + assertEquals( + "https://dav.example.com/cal/personal/entry1.ics", + success.hrefs.keys.single().toString(), + ) + } + + @Test + fun syncCollection_sendsReportWithDepthAndCredentials() = runTest { + val capture = RequestCapture() + val body = """t""" + + syncCollectionMultiplatform(mockClient(body = body, capture = capture), calendar(), credentials) + + val request = capture.request!! + assertEquals("REPORT", request.method.value) + assertEquals("1", request.headers[HttpHeaders.Depth]) + assertTrue( + request.headers[HttpHeaders.Authorization]?.startsWith("Basic ") == true, + "credentials passed per call must produce a Basic auth header", + ) + } + + @Test + fun syncCollection_withoutCredentialsSendsNoAuthHeader() = runTest { + val capture = RequestCapture() + val body = """t""" + + syncCollectionMultiplatform(mockClient(body = body, capture = capture), calendar(), credentials = null) + + assertNull(capture.request!!.headers[HttpHeaders.Authorization]) + } + + @Test + fun syncCollection_maps404ToNotFound() = runTest { + val result = syncCollectionMultiplatform( + mockClient(status = HttpStatusCode.NotFound), calendar(), credentials, + ) + assertIs(result) + } + + @Test + fun syncCollection_mapsUnauthorizedToNotAuthorized() = runTest { + val result = syncCollectionMultiplatform( + mockClient(status = HttpStatusCode.Unauthorized), calendar(), credentials, + ) + assertIs(result) + } + + @Test + fun syncCollection_mapsServerErrorToFailed() = runTest { + val result = syncCollectionMultiplatform( + mockClient(status = HttpStatusCode.InternalServerError), calendar(), credentials, + ) + val failed = assertIs(result) + assertEquals(HttpStatusCode.InternalServerError, failed.status) + } + + // --- multigetResourceHrefsMultiplatform --------------------------------------------------- + + @Test + fun multigetHrefs_includesOnlyOkPropstats() = runTest { + val body = """ + + + /cal/personal/a.ics + "e-a"HTTP/1.1 200 OK + + + /cal/personal/forbidden.ics + HTTP/1.1 403 Forbidden + + + """.trimIndent() + + val result = multigetResourceHrefsMultiplatform(mockClient(body = body), calendar(), credentials) + + val success = assertIs(result) + assertEquals(1, success.hrefs.size, "a non-200 propstat must be excluded from the multiget map") + val only = success.hrefs.entries.single() + assertTrue(only.key.toString().endsWith("a.ics")) + assertEquals("\"e-a\"", only.value) + } + + @Test + fun multigetHrefs_mapsUnauthorizedToNotAuthorized() = runTest { + val result = multigetResourceHrefsMultiplatform( + mockClient(status = HttpStatusCode.Forbidden), calendar(), credentials, + ) + assertIs(result) + } + + // --- discoverPrincipalsMultiplatform ------------------------------------------------------ + + @Test + fun discoverPrincipals_extractsAndResolvesPrincipalHref() = runTest { + val body = """ + + + / + + + /principals/users/alice/ + + HTTP/1.1 200 OK + + + + """.trimIndent() + + val result = discoverPrincipalsMultiplatform( + mockClient(body = body), Url("https://dav.example.com/"), credentials, + ) + + val success = assertIs(result) + assertEquals(1, success.principals.size) + assertEquals( + "https://dav.example.com/principals/users/alice/", + success.principals.single().principalUrl.toString(), + ) + } + + @Test + fun discoverPrincipals_allForbiddenPropstatsBecomeNotAuthorized() = runTest { + // A 207 at the HTTP layer, but every inner propstat is 403: must be read as "not authorized". + val body = """ + + + / + HTTP/1.1 403 Forbidden + + + """.trimIndent() + + val result = discoverPrincipalsMultiplatform( + mockClient(body = body), Url("https://dav.example.com/"), credentials, + ) + + assertIs(result) + } + + // --- discoverHomeCollectionsMultiplatform ------------------------------------------------- + + @Test + fun discoverHomeCollections_extractsHomeSetDisplayNameAndAddressSet() = runTest { + val body = """ + + + /principals/users/alice/ + + + Alice + mailto:alice@example.com + /cal/ + + HTTP/1.1 200 OK + + + + """.trimIndent() + + val principal = Principal( + id = 1L, + principalUrl = Url("https://dav.example.com/principals/users/alice/"), + displayName = null, + calendarUserAddressSet = emptyList(), + ) + + val result = discoverHomeCollectionsMultiplatform(mockClient(body = body), principal, credentials) + + val success = assertIs(result) + assertEquals("Alice", success.principalDisplayName) + assertEquals(listOf("mailto:alice@example.com"), success.principalCalendarUserAddressSet) + assertEquals(1, success.homeCollections.size) + assertEquals( + "https://dav.example.com/cal/", + success.homeCollections.single().url.toString(), + ) + } + + // --- discoverCalendarsMultiplatform ------------------------------------------------------- + + @Test + fun discoverCalendars_keepsSupportedCalendarsAndExtractsHomeCollectionPrivileges() = runTest { + // Three responses: the home collection itself (privileges only, not a calendar), + // a VJOURNAL calendar (kept), and a VEVENT-only calendar (dropped - unsupported component). + val body = """ + + + /cal/ + + + + + + + + + HTTP/1.1 200 OK + + + + /cal/journal/ + + + My Journal + Diary + ctag-99 + #FF3366CC + + + + + + + + HTTP/1.1 200 OK + + + + /cal/events/ + + + Events + + + + HTTP/1.1 200 OK + + + + """.trimIndent() + + val homeCollection = HomeCollection( + id = 1L, + principalId = 1L, + url = Url("https://dav.example.com/cal/"), + calDavPrivileges = emptyList(), + ) + + val result = discoverCalendarsMultiplatform(mockClient(body = body), homeCollection, credentials) + + val success = assertIs(result) + + // Only the VJOURNAL calendar survives filtering. + assertEquals(1, success.calendars.size) + val journal = success.calendars.single() + assertEquals("https://dav.example.com/cal/journal/", journal.url.toString()) + assertEquals("My Journal", journal.displayName) + assertEquals("Diary", journal.calendarDescription) + assertEquals("ctag-99", journal.ctag) + assertTrue(journal.color != null, "calendar-color must be parsed") + assertTrue(journal.supportedComponents.contains(CalendarComponent.VJOURNAL)) + assertTrue(journal.calDavPrivileges.contains(CalDavPrivilege.READ)) + assertTrue(journal.calDavPrivileges.contains(CalDavPrivilege.WRITE)) + + // Home-collection-level privileges come from the response whose href IS the home collection. + assertTrue(success.calDavPrivileges.contains(CalDavPrivilege.BIND)) + assertTrue(success.calDavPrivileges.contains(CalDavPrivilege.UNBIND)) + } + + @Test + fun discoverCalendars_maps404ToNotFound() = runTest { + val homeCollection = HomeCollection( + id = 1L, + principalId = 1L, + url = Url("https://dav.example.com/cal/"), + calDavPrivileges = emptyList(), + ) + val result = discoverCalendarsMultiplatform( + mockClient(status = HttpStatusCode.NotFound), homeCollection, credentials, + ) + assertIs(result) + } +} From 07685693c687a01c66ebd553caa714cb3b7f1d9d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 12:40:35 +0000 Subject: [PATCH 17/25] Load the IANA tz database on web so kotlinx-datetime resolves named zones kotlinx-datetime on the js/wasmJs targets has no time-zone database, so TimeZone.of("Europe/Vienna") throws IllegalTimeZoneException in the browser. That made 20 ICS timezone tests fail under :shared:allTests (they pass under :shared:jvmTest, which uses the JDK's zones), and it also degraded the web runtime: the ICS parser silently fell back to UTC for zoned entries, and TimeZoneSerializer.deserialize would throw outright when loading a stored entry with a named zone. Add the @js-joda/timezone npm dependency to the shared web source set - the documented remedy - which registers the tz database with the js-joda backend kotlinx-datetime uses on both js and wasmJs. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Xt7MRuFpW1jtcHX1MPC4EN --- gradle/libs.versions.toml | 1 + shared/build.gradle.kts | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index db7f4230..ca0974e1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,7 @@ kotlin = "2.4.10" kotlinx-coroutines = "1.11.0" ktor = "3.5.1" kotlinxDatetime = "0.8.0" +jsJodaTimezone = "2.18.2" kotlinxSerialization = "1.11.0" ksafe = "2.2.1" logback = "1.5.38" diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 7ef9d085..60e6eb9d 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -146,6 +146,11 @@ kotlin { implementation(devNpm("copy-webpack-plugin", libs.versions.webPackPlugin.get())) implementation(npm("@cashapp/sqldelight-sqljs-worker", libs.versions.sqldelight.get())) implementation(npm("sql.js", libs.versions.sqlJs.get())) + // Provides the IANA time-zone database for kotlinx-datetime on js/wasmJs. Without it, + // TimeZone.of("Europe/Vienna") and friends throw IllegalTimeZoneException in the browser + // (the JVM/native targets get their zones from the platform). Needed both for the ICS + // timezone tests and for correct wall-clock handling of zoned entries on the web target. + implementation(npm("@js-joda/timezone", libs.versions.jsJodaTimezone.get())) } } } From 549e7a62b2445164edf16e450fc8ac0af045a3b1 Mon Sep 17 00:00:00 2001 From: Patrick Lang <72232737+patrickunterwegs@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:56:19 +0200 Subject: [PATCH 18/25] Updated yarn.lock files --- kotlin-js-store/wasm/yarn.lock | 5 +++++ kotlin-js-store/yarn.lock | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/kotlin-js-store/wasm/yarn.lock b/kotlin-js-store/wasm/yarn.lock index 1114020f..056907f6 100644 --- a/kotlin-js-store/wasm/yarn.lock +++ b/kotlin-js-store/wasm/yarn.lock @@ -12,6 +12,11 @@ resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273" integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg== +"@js-joda/timezone@2.18.2": + version "2.18.2" + resolved "https://registry.yarnpkg.com/@js-joda/timezone/-/timezone-2.18.2.tgz#8c915f5f715679413a65eb923e637f1e75ccce7f" + integrity sha512-4binOw4XBO4H6nbw0tPg1IOIrf3MF7gPeXkNiPuK1QAHPaBC0YsvtRt8BiJTqhEaBqh/Kbqp6MChlnbJ8dxQ5Q== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" diff --git a/kotlin-js-store/yarn.lock b/kotlin-js-store/yarn.lock index 6fd48600..3c9182f2 100644 --- a/kotlin-js-store/yarn.lock +++ b/kotlin-js-store/yarn.lock @@ -68,6 +68,11 @@ resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273" integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg== +"@js-joda/timezone@2.18.2": + version "2.18.2" + resolved "https://registry.yarnpkg.com/@js-joda/timezone/-/timezone-2.18.2.tgz#8c915f5f715679413a65eb923e637f1e75ccce7f" + integrity sha512-4binOw4XBO4H6nbw0tPg1IOIrf3MF7gPeXkNiPuK1QAHPaBC0YsvtRt8BiJTqhEaBqh/Kbqp6MChlnbJ8dxQ5Q== + "@jsonjoy.com/base64@17.67.0": version "17.67.0" resolved "https://registry.yarnpkg.com/@jsonjoy.com/base64/-/base64-17.67.0.tgz#7eeda3cb41138d77a90408fd2e42b2aba10576d7" From 5a0202bd2cef1f3b3cd6a01790c7ffbd41432f32 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 12:59:26 +0000 Subject: [PATCH 19/25] Actually import @js-joda/timezone in the js/wasmJs test bundles Declaring the @js-joda/timezone npm dependency was not sufficient: kotlinx-datetime only uses it if the module is genuinely imported, and its tz-database registration runs as an import side effect that webpack strips when nothing references it. So the IANA database was never loaded and TimeZone.of("Europe/Vienna") kept throwing IllegalTimeZoneException under :shared:allTests (js and wasmJs browser targets), while :shared:jvmTest stayed green via the JDK's zones. Add an @JsModule external declaration for the module in the jsTest and wasmJsTest source sets (they differ: js needs @JsNonModule, wasmJs does not) and reference it from a real test. The reference forces webpack to emit the require, whose side effect registers the database at module load - before any commonTest timezone test class is constructed - so the existing ICS/date-time suite can resolve named zones on the browser targets. Each test also asserts the database is present, guarding against regressions. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Xt7MRuFpW1jtcHX1MPC4EN --- .../spectacled/TimeZoneDatabaseJsTest.kt | 34 ++++++++++++++++++ .../spectacled/TimeZoneDatabaseWasmJsTest.kt | 35 +++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 shared/src/jsTest/kotlin/at/techbee/spectacled/TimeZoneDatabaseJsTest.kt create mode 100644 shared/src/wasmJsTest/kotlin/at/techbee/spectacled/TimeZoneDatabaseWasmJsTest.kt diff --git a/shared/src/jsTest/kotlin/at/techbee/spectacled/TimeZoneDatabaseJsTest.kt b/shared/src/jsTest/kotlin/at/techbee/spectacled/TimeZoneDatabaseJsTest.kt new file mode 100644 index 00000000..f9c30fcb --- /dev/null +++ b/shared/src/jsTest/kotlin/at/techbee/spectacled/TimeZoneDatabaseJsTest.kt @@ -0,0 +1,34 @@ +package at.techbee.spectacled + +import kotlinx.datetime.TimeZone +import kotlin.js.JsModule +import kotlin.js.JsNonModule +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +/** + * Loads the IANA time-zone database into the Kotlin/JS browser test bundle. + * + * kotlinx-datetime resolves named zones on JS through js-joda, which only has the full database + * when the `@js-joda/timezone` npm module is actually imported. Declaring the npm dependency + * (see shared/build.gradle.kts, webMain) is not enough on its own - the module registers the + * database as an import side effect, which only runs if something references it. The external + * declaration below, referenced from the test, is what forces webpack to emit that import. The + * require is hoisted to module load, so the database is registered before any commonTest timezone + * test class is constructed. Without it, TimeZone.of("Europe/Vienna") throws + * IllegalTimeZoneException on the js target. + */ +@JsModule("@js-joda/timezone") +@JsNonModule +external object JsJodaTimeZoneModule + +class TimeZoneDatabaseJsTest { + @Test + fun namedTimeZonesResolveOnJs() { + // Referencing the module keeps the @js-joda/timezone import (and its registration side + // effect) alive through tree-shaking. + assertNotNull(JsJodaTimeZoneModule) + assertEquals("Europe/Vienna", TimeZone.of("Europe/Vienna").id) + } +} diff --git a/shared/src/wasmJsTest/kotlin/at/techbee/spectacled/TimeZoneDatabaseWasmJsTest.kt b/shared/src/wasmJsTest/kotlin/at/techbee/spectacled/TimeZoneDatabaseWasmJsTest.kt new file mode 100644 index 00000000..b5c12bd7 --- /dev/null +++ b/shared/src/wasmJsTest/kotlin/at/techbee/spectacled/TimeZoneDatabaseWasmJsTest.kt @@ -0,0 +1,35 @@ +package at.techbee.spectacled + +import kotlinx.datetime.TimeZone +import kotlin.js.JsModule +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +/** + * Loads the IANA time-zone database into the Kotlin/wasmJs browser test bundle. + * + * kotlinx-datetime resolves named zones on wasmJs through js-joda, which only has the full database + * when the `@js-joda/timezone` npm module is actually imported. Declaring the npm dependency + * (see shared/build.gradle.kts, webMain) is not enough on its own - the module registers the + * database as an import side effect, which only runs if something references it. The external + * declaration below, referenced from the test, is what forces webpack to emit that import. The + * require is hoisted to module load, so the database is registered before any commonTest timezone + * test class is constructed. Without it, TimeZone.of("Europe/Vienna") throws + * IllegalTimeZoneException on the wasmJs target. + * + * The wasmJs declaration omits @JsNonModule (unlike the js one), matching kotlinx-datetime's + * documented setup for each target. + */ +@JsModule("@js-joda/timezone") +external object JsJodaTimeZoneModule + +class TimeZoneDatabaseWasmJsTest { + @Test + fun namedTimeZonesResolveOnWasmJs() { + // Referencing the module keeps the @js-joda/timezone import (and its registration side + // effect) alive through tree-shaking. + assertNotNull(JsJodaTimeZoneModule) + assertEquals("Europe/Vienna", TimeZone.of("Europe/Vienna").id) + } +} From 4c13d61433b932e213e4651880ea5bb2dc0f755b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 15:22:01 +0000 Subject: [PATCH 20/25] Load the tz database via a shared expect/actual and guard the serializer Replaces the two throwing js/wasmJs guard tests with a single expect/actual 'ensureTimeZoneDatabaseLoaded()'. The web actuals hold the @JsModule declaration for @js-joda/timezone (js keeps @JsNonModule, wasmJs does not) and reference it so the bundler emits the import whose registration side effect runs at module load; the wasmJs actual wraps the reference in runCatching because materializing the side-effect-only module as a value throws on wasmJs (the earlier guard test failed on exactly that, even though the database had already loaded). JVM/Android/iOS actuals are no-ops. This also applies the two follow-ups: - Production web fix: the function is called from TimeZoneSerializer (init) and from parseIcalEntries, so the @js-joda/timezone import is pulled into the app bundle and named TZIDs resolve on the web target instead of silently degrading to UTC (parser) or throwing while deserializing a stored zoned entry (serializer). - Defense in depth: TimeZoneSerializer.deserialize now falls back to UTC for unknown/unresolvable zone ids via runCatching, mirroring the ICS parser, instead of throwing. A single commonTest TimeZoneDatabaseTest asserts named zones resolve on every target; on js/wasmJs its call to ensureTimeZoneDatabaseLoaded() is what pulls the import into the test bundle, before any other timezone test class is constructed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Xt7MRuFpW1jtcHX1MPC4EN --- .../core/data/ics/TimeZoneDatabase.android.kt | 5 +++ .../screens/core/data/ics/IcsDateTime.kt | 15 +++++++- .../screens/core/data/ics/TimeZoneDatabase.kt | 16 +++++++++ .../core/mapper/ics/IcalEntryIcsParser.kt | 5 +++ .../core/data/ics/TimeZoneDatabaseTest.kt | 21 +++++++++++ .../core/data/ics/TimeZoneDatabase.ios.kt | 4 +++ .../core/data/ics/TimeZoneDatabase.js.kt | 20 +++++++++++ .../spectacled/TimeZoneDatabaseJsTest.kt | 34 ------------------ .../core/data/ics/TimeZoneDatabase.jvm.kt | 4 +++ .../core/data/ics/TimeZoneDatabase.wasmJs.kt | 20 +++++++++++ .../spectacled/TimeZoneDatabaseWasmJsTest.kt | 35 ------------------- 11 files changed, 109 insertions(+), 70 deletions(-) create mode 100644 shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.android.kt create mode 100644 shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.kt create mode 100644 shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabaseTest.kt create mode 100644 shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.ios.kt create mode 100644 shared/src/jsMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.js.kt delete mode 100644 shared/src/jsTest/kotlin/at/techbee/spectacled/TimeZoneDatabaseJsTest.kt create mode 100644 shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.jvm.kt create mode 100644 shared/src/wasmJsMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.wasmJs.kt delete mode 100644 shared/src/wasmJsTest/kotlin/at/techbee/spectacled/TimeZoneDatabaseWasmJsTest.kt diff --git a/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.android.kt b/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.android.kt new file mode 100644 index 00000000..02edd880 --- /dev/null +++ b/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.android.kt @@ -0,0 +1,5 @@ +package at.techbee.spectacled.screens.core.data.ics + +// Android resolves IANA time zones through the platform (ICU / core-library desugaring), +// so no extra setup is needed. +actual fun ensureTimeZoneDatabaseLoaded() {} diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/ics/IcsDateTime.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/ics/IcsDateTime.kt index a1cb658e..d537fa50 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/ics/IcsDateTime.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/ics/IcsDateTime.kt @@ -138,7 +138,20 @@ data class IcsDateTime( } object TimeZoneSerializer : KSerializer { + init { + // Referencing this here makes it part of the app's reachable graph, so on the web targets + // the @js-joda/timezone import is pulled into the bundle and its database registers at load. + ensureTimeZoneDatabaseLoaded() + } + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("TimeZone", PrimitiveKind.STRING) override fun serialize(encoder: Encoder, value: TimeZone) = encoder.encodeString(value.id) - override fun deserialize(decoder: Decoder): TimeZone = TimeZone.of(decoder.decodeString()) + + // Fall back to UTC for unknown or unresolvable zone ids instead of throwing, mirroring the ICS + // parser (IcalEntryIcsParser). This also guards the web targets should the tz database ever be + // unavailable, rather than crashing while loading a stored entry with a named zone. + override fun deserialize(decoder: Decoder): TimeZone { + val id = decoder.decodeString() + return runCatching { TimeZone.of(id) }.getOrDefault(TimeZone.UTC) + } } diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.kt new file mode 100644 index 00000000..5d2078ff --- /dev/null +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.kt @@ -0,0 +1,16 @@ +package at.techbee.spectacled.screens.core.data.ics + +/** + * Ensures that named IANA time zones (e.g. "Europe/Vienna") can be resolved via + * [kotlinx.datetime.TimeZone.of] on the current platform. + * + * It is a no-op on JVM, Android and iOS, where the platform ships a time-zone database. On the + * web targets (js/wasmJs) kotlinx-datetime resolves zones through js-joda, which only has the full + * database once the `@js-joda/timezone` module is imported; the web actuals reference that module + * so the bundler emits the import, whose registration side effect runs at module load. + * + * Calling this from a code path the app always reaches (see [TimeZoneSerializer.deserialize]) is + * enough to pull that import into the app bundle - the registration happens at load, before any + * zone is resolved, so callers do not need to invoke this before every lookup. + */ +expect fun ensureTimeZoneDatabaseLoaded() diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryIcsParser.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryIcsParser.kt index cdb384ae..a93c7ea5 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryIcsParser.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryIcsParser.kt @@ -2,6 +2,7 @@ package at.techbee.spectacled.screens.core.mapper.ics import androidx.compose.ui.graphics.Color import at.techbee.spectacled.screens.core.data.ics.IcsDateTime +import at.techbee.spectacled.screens.core.data.ics.ensureTimeZoneDatabaseLoaded import at.techbee.spectacled.screens.core.data.ics.IcsProperty import at.techbee.spectacled.screens.core.data.ics.KnownIcsParamName import at.techbee.spectacled.screens.core.data.ics.KnownIcsPropertyName @@ -390,6 +391,10 @@ fun parseIcalEntries( fileManager: FileManager? = null ): List { + // Ensure named zones (TZID=...) can be resolved before parsing. No-op except on the web + // targets, where it loads the @js-joda/timezone database. + ensureTimeZoneDatabaseLoaded() + val lines = unfoldLines(ics) val calendarComponentBlocks = extractComponents(lines) diff --git a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabaseTest.kt b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabaseTest.kt new file mode 100644 index 00000000..c7c4d300 --- /dev/null +++ b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabaseTest.kt @@ -0,0 +1,21 @@ +package at.techbee.spectacled.screens.core.data.ics + +import kotlinx.datetime.TimeZone +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Guards that named IANA zones resolve on every target. On js/wasmJs the call to + * [ensureTimeZoneDatabaseLoaded] is what pulls the @js-joda/timezone import into the test bundle + * (its registration side effect runs at bundle load, before any other timezone test class is + * constructed); on JVM/Android/iOS it is a no-op and the platform database is used. Without the + * web setup this assertion - and the rest of the ICS date-time suite - fails with + * IllegalTimeZoneException on the browser targets. + */ +class TimeZoneDatabaseTest { + @Test + fun namedTimeZonesResolve() { + ensureTimeZoneDatabaseLoaded() + assertEquals("Europe/Vienna", TimeZone.of("Europe/Vienna").id) + } +} diff --git a/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.ios.kt b/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.ios.kt new file mode 100644 index 00000000..6c9fd9a7 --- /dev/null +++ b/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.ios.kt @@ -0,0 +1,4 @@ +package at.techbee.spectacled.screens.core.data.ics + +// iOS resolves IANA time zones through Foundation, so no extra setup is needed. +actual fun ensureTimeZoneDatabaseLoaded() {} diff --git a/shared/src/jsMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.js.kt b/shared/src/jsMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.js.kt new file mode 100644 index 00000000..0a7ca7a9 --- /dev/null +++ b/shared/src/jsMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.js.kt @@ -0,0 +1,20 @@ +package at.techbee.spectacled.screens.core.data.ics + +import kotlin.js.JsModule +import kotlin.js.JsNonModule + +/** + * The `@js-joda/timezone` npm module. Referencing it forces the bundler to emit the import, whose + * registration side effect installs the IANA time-zone database into the js-joda backend + * kotlinx-datetime uses on Kotlin/JS. Without it, TimeZone.of("Europe/Vienna") throws + * IllegalTimeZoneException. + */ +@JsModule("@js-joda/timezone") +@JsNonModule +external object JsJodaTimeZoneModule + +actual fun ensureTimeZoneDatabaseLoaded() { + // The reference is what keeps the import (and therefore the registration side effect) alive + // through tree-shaking; the actual load happens once, when this module is first evaluated. + checkNotNull(JsJodaTimeZoneModule) +} diff --git a/shared/src/jsTest/kotlin/at/techbee/spectacled/TimeZoneDatabaseJsTest.kt b/shared/src/jsTest/kotlin/at/techbee/spectacled/TimeZoneDatabaseJsTest.kt deleted file mode 100644 index f9c30fcb..00000000 --- a/shared/src/jsTest/kotlin/at/techbee/spectacled/TimeZoneDatabaseJsTest.kt +++ /dev/null @@ -1,34 +0,0 @@ -package at.techbee.spectacled - -import kotlinx.datetime.TimeZone -import kotlin.js.JsModule -import kotlin.js.JsNonModule -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull - -/** - * Loads the IANA time-zone database into the Kotlin/JS browser test bundle. - * - * kotlinx-datetime resolves named zones on JS through js-joda, which only has the full database - * when the `@js-joda/timezone` npm module is actually imported. Declaring the npm dependency - * (see shared/build.gradle.kts, webMain) is not enough on its own - the module registers the - * database as an import side effect, which only runs if something references it. The external - * declaration below, referenced from the test, is what forces webpack to emit that import. The - * require is hoisted to module load, so the database is registered before any commonTest timezone - * test class is constructed. Without it, TimeZone.of("Europe/Vienna") throws - * IllegalTimeZoneException on the js target. - */ -@JsModule("@js-joda/timezone") -@JsNonModule -external object JsJodaTimeZoneModule - -class TimeZoneDatabaseJsTest { - @Test - fun namedTimeZonesResolveOnJs() { - // Referencing the module keeps the @js-joda/timezone import (and its registration side - // effect) alive through tree-shaking. - assertNotNull(JsJodaTimeZoneModule) - assertEquals("Europe/Vienna", TimeZone.of("Europe/Vienna").id) - } -} diff --git a/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.jvm.kt b/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.jvm.kt new file mode 100644 index 00000000..430ed321 --- /dev/null +++ b/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.jvm.kt @@ -0,0 +1,4 @@ +package at.techbee.spectacled.screens.core.data.ics + +// The JVM ships its own IANA time-zone database, so no extra setup is needed. +actual fun ensureTimeZoneDatabaseLoaded() {} diff --git a/shared/src/wasmJsMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.wasmJs.kt b/shared/src/wasmJsMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.wasmJs.kt new file mode 100644 index 00000000..ea448c8f --- /dev/null +++ b/shared/src/wasmJsMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.wasmJs.kt @@ -0,0 +1,20 @@ +package at.techbee.spectacled.screens.core.data.ics + +import kotlin.js.JsModule + +/** + * The `@js-joda/timezone` npm module. Referencing it forces the bundler to emit the import, whose + * registration side effect installs the IANA time-zone database into the js-joda backend + * kotlinx-datetime uses on Kotlin/wasmJs. Without it, TimeZone.of("Europe/Vienna") throws + * IllegalTimeZoneException. (The wasmJs declaration omits @JsNonModule, unlike the js one.) + */ +@JsModule("@js-joda/timezone") +external object JsJodaTimeZoneModule + +actual fun ensureTimeZoneDatabaseLoaded() { + // The reference keeps the import (and its registration side effect, which runs at module load) + // alive through tree-shaking. On wasmJs the module is side-effect-only with no default export, + // so materializing it as a value throws - harmless, and unrelated to the registration that has + // already happened, so we swallow it. + runCatching { checkNotNull(JsJodaTimeZoneModule) } +} diff --git a/shared/src/wasmJsTest/kotlin/at/techbee/spectacled/TimeZoneDatabaseWasmJsTest.kt b/shared/src/wasmJsTest/kotlin/at/techbee/spectacled/TimeZoneDatabaseWasmJsTest.kt deleted file mode 100644 index b5c12bd7..00000000 --- a/shared/src/wasmJsTest/kotlin/at/techbee/spectacled/TimeZoneDatabaseWasmJsTest.kt +++ /dev/null @@ -1,35 +0,0 @@ -package at.techbee.spectacled - -import kotlinx.datetime.TimeZone -import kotlin.js.JsModule -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull - -/** - * Loads the IANA time-zone database into the Kotlin/wasmJs browser test bundle. - * - * kotlinx-datetime resolves named zones on wasmJs through js-joda, which only has the full database - * when the `@js-joda/timezone` npm module is actually imported. Declaring the npm dependency - * (see shared/build.gradle.kts, webMain) is not enough on its own - the module registers the - * database as an import side effect, which only runs if something references it. The external - * declaration below, referenced from the test, is what forces webpack to emit that import. The - * require is hoisted to module load, so the database is registered before any commonTest timezone - * test class is constructed. Without it, TimeZone.of("Europe/Vienna") throws - * IllegalTimeZoneException on the wasmJs target. - * - * The wasmJs declaration omits @JsNonModule (unlike the js one), matching kotlinx-datetime's - * documented setup for each target. - */ -@JsModule("@js-joda/timezone") -external object JsJodaTimeZoneModule - -class TimeZoneDatabaseWasmJsTest { - @Test - fun namedTimeZonesResolveOnWasmJs() { - // Referencing the module keeps the @js-joda/timezone import (and its registration side - // effect) alive through tree-shaking. - assertNotNull(JsJodaTimeZoneModule) - assertEquals("Europe/Vienna", TimeZone.of("Europe/Vienna").id) - } -} From 24e1df16f500f701a0470358d18eb5e444972038 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 15:42:56 +0000 Subject: [PATCH 21/25] Load the web tz database from web startup instead of a common expect/actual Replaces the expect fun ensureTimeZoneDatabaseLoaded() and its five actuals (three of them no-ops on JVM/Android/iOS) with a single web-only loader in webMain: loadTimeZoneDatabase() plus the @JsModule declaration for @js-joda/timezone, shared by both js and wasmJs. Loading the IANA tz database is purely a web concern, so it no longer leaks into common code. Production triggers it once from SecureStorageReadyGate (the web-only startup wrapper all three apps already use), so the @js-joda/timezone import lands in the app bundle and named zones resolve at startup. The two web test triggers (jsTest/wasmJsTest) call the same loader so the import lands in the test bundles too, before any timezone test class is constructed - :shared:allTests does not run the app startup path. TimeZoneSerializer.deserialize keeps its runCatching-to-UTC fallback for unknown/unresolvable ids. The temporary calls added to the serializer init and to parseIcalEntries are removed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Xt7MRuFpW1jtcHX1MPC4EN --- .../core/data/ics/TimeZoneDatabase.android.kt | 5 ---- .../screens/core/data/ics/IcsDateTime.kt | 10 ++----- .../screens/core/data/ics/TimeZoneDatabase.kt | 16 ----------- .../core/mapper/ics/IcalEntryIcsParser.kt | 5 ---- .../core/data/ics/TimeZoneDatabaseTest.kt | 21 --------------- .../core/data/ics/TimeZoneDatabase.ios.kt | 4 --- .../core/data/ics/TimeZoneDatabase.js.kt | 20 -------------- .../core/data/ics/TimeZoneDatabaseJsTest.kt | 19 +++++++++++++ .../core/data/ics/TimeZoneDatabase.jvm.kt | 4 --- .../core/data/ics/TimeZoneDatabase.wasmJs.kt | 20 -------------- .../data/ics/TimeZoneDatabaseWasmJsTest.kt | 19 +++++++++++++ .../screens/core/SecureStorageReadyGate.kt | 5 ++++ .../core/data/ics/TimeZoneDatabase.web.kt | 27 +++++++++++++++++++ 13 files changed, 72 insertions(+), 103 deletions(-) delete mode 100644 shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.android.kt delete mode 100644 shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.kt delete mode 100644 shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabaseTest.kt delete mode 100644 shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.ios.kt delete mode 100644 shared/src/jsMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.js.kt create mode 100644 shared/src/jsTest/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabaseJsTest.kt delete mode 100644 shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.jvm.kt delete mode 100644 shared/src/wasmJsMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.wasmJs.kt create mode 100644 shared/src/wasmJsTest/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabaseWasmJsTest.kt create mode 100644 shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.web.kt diff --git a/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.android.kt b/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.android.kt deleted file mode 100644 index 02edd880..00000000 --- a/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.android.kt +++ /dev/null @@ -1,5 +0,0 @@ -package at.techbee.spectacled.screens.core.data.ics - -// Android resolves IANA time zones through the platform (ICU / core-library desugaring), -// so no extra setup is needed. -actual fun ensureTimeZoneDatabaseLoaded() {} diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/ics/IcsDateTime.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/ics/IcsDateTime.kt index d537fa50..0204603e 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/ics/IcsDateTime.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/ics/IcsDateTime.kt @@ -138,18 +138,12 @@ data class IcsDateTime( } object TimeZoneSerializer : KSerializer { - init { - // Referencing this here makes it part of the app's reachable graph, so on the web targets - // the @js-joda/timezone import is pulled into the bundle and its database registers at load. - ensureTimeZoneDatabaseLoaded() - } - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("TimeZone", PrimitiveKind.STRING) override fun serialize(encoder: Encoder, value: TimeZone) = encoder.encodeString(value.id) // Fall back to UTC for unknown or unresolvable zone ids instead of throwing, mirroring the ICS - // parser (IcalEntryIcsParser). This also guards the web targets should the tz database ever be - // unavailable, rather than crashing while loading a stored entry with a named zone. + // parser (IcalEntryIcsParser). On web the tz database is loaded at startup (SecureStorageReadyGate); + // this guard keeps a corrupt or unexpected id from crashing while loading a stored entry. override fun deserialize(decoder: Decoder): TimeZone { val id = decoder.decodeString() return runCatching { TimeZone.of(id) }.getOrDefault(TimeZone.UTC) diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.kt deleted file mode 100644 index 5d2078ff..00000000 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.kt +++ /dev/null @@ -1,16 +0,0 @@ -package at.techbee.spectacled.screens.core.data.ics - -/** - * Ensures that named IANA time zones (e.g. "Europe/Vienna") can be resolved via - * [kotlinx.datetime.TimeZone.of] on the current platform. - * - * It is a no-op on JVM, Android and iOS, where the platform ships a time-zone database. On the - * web targets (js/wasmJs) kotlinx-datetime resolves zones through js-joda, which only has the full - * database once the `@js-joda/timezone` module is imported; the web actuals reference that module - * so the bundler emits the import, whose registration side effect runs at module load. - * - * Calling this from a code path the app always reaches (see [TimeZoneSerializer.deserialize]) is - * enough to pull that import into the app bundle - the registration happens at load, before any - * zone is resolved, so callers do not need to invoke this before every lookup. - */ -expect fun ensureTimeZoneDatabaseLoaded() diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryIcsParser.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryIcsParser.kt index a93c7ea5..cdb384ae 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryIcsParser.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryIcsParser.kt @@ -2,7 +2,6 @@ package at.techbee.spectacled.screens.core.mapper.ics import androidx.compose.ui.graphics.Color import at.techbee.spectacled.screens.core.data.ics.IcsDateTime -import at.techbee.spectacled.screens.core.data.ics.ensureTimeZoneDatabaseLoaded import at.techbee.spectacled.screens.core.data.ics.IcsProperty import at.techbee.spectacled.screens.core.data.ics.KnownIcsParamName import at.techbee.spectacled.screens.core.data.ics.KnownIcsPropertyName @@ -391,10 +390,6 @@ fun parseIcalEntries( fileManager: FileManager? = null ): List { - // Ensure named zones (TZID=...) can be resolved before parsing. No-op except on the web - // targets, where it loads the @js-joda/timezone database. - ensureTimeZoneDatabaseLoaded() - val lines = unfoldLines(ics) val calendarComponentBlocks = extractComponents(lines) diff --git a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabaseTest.kt b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabaseTest.kt deleted file mode 100644 index c7c4d300..00000000 --- a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabaseTest.kt +++ /dev/null @@ -1,21 +0,0 @@ -package at.techbee.spectacled.screens.core.data.ics - -import kotlinx.datetime.TimeZone -import kotlin.test.Test -import kotlin.test.assertEquals - -/** - * Guards that named IANA zones resolve on every target. On js/wasmJs the call to - * [ensureTimeZoneDatabaseLoaded] is what pulls the @js-joda/timezone import into the test bundle - * (its registration side effect runs at bundle load, before any other timezone test class is - * constructed); on JVM/Android/iOS it is a no-op and the platform database is used. Without the - * web setup this assertion - and the rest of the ICS date-time suite - fails with - * IllegalTimeZoneException on the browser targets. - */ -class TimeZoneDatabaseTest { - @Test - fun namedTimeZonesResolve() { - ensureTimeZoneDatabaseLoaded() - assertEquals("Europe/Vienna", TimeZone.of("Europe/Vienna").id) - } -} diff --git a/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.ios.kt b/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.ios.kt deleted file mode 100644 index 6c9fd9a7..00000000 --- a/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.ios.kt +++ /dev/null @@ -1,4 +0,0 @@ -package at.techbee.spectacled.screens.core.data.ics - -// iOS resolves IANA time zones through Foundation, so no extra setup is needed. -actual fun ensureTimeZoneDatabaseLoaded() {} diff --git a/shared/src/jsMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.js.kt b/shared/src/jsMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.js.kt deleted file mode 100644 index 0a7ca7a9..00000000 --- a/shared/src/jsMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.js.kt +++ /dev/null @@ -1,20 +0,0 @@ -package at.techbee.spectacled.screens.core.data.ics - -import kotlin.js.JsModule -import kotlin.js.JsNonModule - -/** - * The `@js-joda/timezone` npm module. Referencing it forces the bundler to emit the import, whose - * registration side effect installs the IANA time-zone database into the js-joda backend - * kotlinx-datetime uses on Kotlin/JS. Without it, TimeZone.of("Europe/Vienna") throws - * IllegalTimeZoneException. - */ -@JsModule("@js-joda/timezone") -@JsNonModule -external object JsJodaTimeZoneModule - -actual fun ensureTimeZoneDatabaseLoaded() { - // The reference is what keeps the import (and therefore the registration side effect) alive - // through tree-shaking; the actual load happens once, when this module is first evaluated. - checkNotNull(JsJodaTimeZoneModule) -} diff --git a/shared/src/jsTest/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabaseJsTest.kt b/shared/src/jsTest/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabaseJsTest.kt new file mode 100644 index 00000000..577f1a4b --- /dev/null +++ b/shared/src/jsTest/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabaseJsTest.kt @@ -0,0 +1,19 @@ +package at.techbee.spectacled.screens.core.data.ics + +import kotlinx.datetime.TimeZone +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Pulls the @js-joda/timezone import into the Kotlin/JS test bundle - its registration side effect + * runs at bundle load, before any timezone test class is constructed - and asserts named zones + * resolve. In production SecureStorageReadyGate triggers the same load; the test needs its own + * because :shared:allTests does not exercise the web app startup. + */ +class TimeZoneDatabaseJsTest { + @Test + fun namedTimeZonesResolveOnJs() { + loadTimeZoneDatabase() + assertEquals("Europe/Vienna", TimeZone.of("Europe/Vienna").id) + } +} diff --git a/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.jvm.kt b/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.jvm.kt deleted file mode 100644 index 430ed321..00000000 --- a/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.jvm.kt +++ /dev/null @@ -1,4 +0,0 @@ -package at.techbee.spectacled.screens.core.data.ics - -// The JVM ships its own IANA time-zone database, so no extra setup is needed. -actual fun ensureTimeZoneDatabaseLoaded() {} diff --git a/shared/src/wasmJsMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.wasmJs.kt b/shared/src/wasmJsMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.wasmJs.kt deleted file mode 100644 index ea448c8f..00000000 --- a/shared/src/wasmJsMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.wasmJs.kt +++ /dev/null @@ -1,20 +0,0 @@ -package at.techbee.spectacled.screens.core.data.ics - -import kotlin.js.JsModule - -/** - * The `@js-joda/timezone` npm module. Referencing it forces the bundler to emit the import, whose - * registration side effect installs the IANA time-zone database into the js-joda backend - * kotlinx-datetime uses on Kotlin/wasmJs. Without it, TimeZone.of("Europe/Vienna") throws - * IllegalTimeZoneException. (The wasmJs declaration omits @JsNonModule, unlike the js one.) - */ -@JsModule("@js-joda/timezone") -external object JsJodaTimeZoneModule - -actual fun ensureTimeZoneDatabaseLoaded() { - // The reference keeps the import (and its registration side effect, which runs at module load) - // alive through tree-shaking. On wasmJs the module is side-effect-only with no default export, - // so materializing it as a value throws - harmless, and unrelated to the registration that has - // already happened, so we swallow it. - runCatching { checkNotNull(JsJodaTimeZoneModule) } -} diff --git a/shared/src/wasmJsTest/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabaseWasmJsTest.kt b/shared/src/wasmJsTest/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabaseWasmJsTest.kt new file mode 100644 index 00000000..021152d4 --- /dev/null +++ b/shared/src/wasmJsTest/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabaseWasmJsTest.kt @@ -0,0 +1,19 @@ +package at.techbee.spectacled.screens.core.data.ics + +import kotlinx.datetime.TimeZone +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Pulls the @js-joda/timezone import into the Kotlin/wasmJs test bundle - its registration side + * effect runs at bundle load, before any timezone test class is constructed - and asserts named + * zones resolve. In production SecureStorageReadyGate triggers the same load; the test needs its + * own because :shared:allTests does not exercise the web app startup. + */ +class TimeZoneDatabaseWasmJsTest { + @Test + fun namedTimeZonesResolveOnWasmJs() { + loadTimeZoneDatabase() + assertEquals("Europe/Vienna", TimeZone.of("Europe/Vienna").id) + } +} diff --git a/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/SecureStorageReadyGate.kt b/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/SecureStorageReadyGate.kt index 6e7cb93c..445e3c7b 100644 --- a/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/SecureStorageReadyGate.kt +++ b/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/SecureStorageReadyGate.kt @@ -10,6 +10,7 @@ import at.techbee.spectacled.SpectacledVariant import at.techbee.spectacled.doInitKoin import at.techbee.spectacled.screens.core.data.CredentialStore import at.techbee.spectacled.screens.core.data.UserAppPreferencesStore +import at.techbee.spectacled.screens.core.data.ics.loadTimeZoneDatabase import org.koin.mp.KoinPlatform /** @@ -32,6 +33,10 @@ fun SecureStorageReadyGate(spectacledVariant: SpectacledVariant, content: @Compo var ready by remember { mutableStateOf(false) } LaunchedEffect(Unit) { + // Load the IANA tz database so named zones (TZID=...) resolve on web. The import actually + // runs at module load; this call just keeps it reachable and marks the startup intent. + loadTimeZoneDatabase() + // Resolved through Koin (not a fresh KSafe(...)) so this awaits the exact same // instances the rest of the app reads from - per KSafe's own docs, two separate // instances on the same fileName still diverge caches on web. diff --git a/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.web.kt b/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.web.kt new file mode 100644 index 00000000..add95d7a --- /dev/null +++ b/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/data/ics/TimeZoneDatabase.web.kt @@ -0,0 +1,27 @@ +package at.techbee.spectacled.screens.core.data.ics + +import kotlin.js.JsModule + +/** + * The `@js-joda/timezone` npm module (declared in shared/build.gradle.kts, webMain). Referencing it + * makes the bundler emit its import, whose registration side effect installs the IANA time-zone + * database into the js-joda backend kotlinx-datetime uses on the web targets. Without it, + * TimeZone.of("Europe/Vienna") throws IllegalTimeZoneException on js/wasmJs. JVM/Android/iOS get + * their zones from the platform, so this whole file - and any call to [loadTimeZoneDatabase] - is + * web-only; there are deliberately no no-op counterparts on the other targets. + */ +@OptIn(ExperimentalWasmJsInterop::class) +@JsModule("@js-joda/timezone") +external object JsJodaTimeZoneModule + +/** + * Loads the IANA time-zone database so named zones (TZID=...) resolve on the web targets. Call once + * at web startup (see SecureStorageReadyGate). The import runs at module load, so this only needs to + * be reachable, not invoked before every lookup; calling it more than once is harmless. + */ +fun loadTimeZoneDatabase() { + // The reference keeps the import (and its registration side effect) alive through tree-shaking. + // On wasmJs, materializing the side-effect-only module as a value throws - harmless, and after + // the registration has already run at import - so we swallow it. + runCatching { checkNotNull(JsJodaTimeZoneModule) } +} From 90219255d8f8b8e3cf5dbb49fe08f19fa141e3f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 15:55:07 +0000 Subject: [PATCH 22/25] Silence webpack 'os'/'path' not-found noise in the shared browser test bundle A transitive dependency (the ktor JS client) references Node core modules os and path in code paths the browser never runs; webpack 5 drops the old auto-polyfills and prints 'Module not found' for them when bundling spectacled-shared-test. Add a shared/webpack.config.d entry mapping os and path to false (empty module), the same resolve.fallback mechanism the app modules already use for sql.js, merged into the existing resolve config so Kotlin's aliases are preserved. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Xt7MRuFpW1jtcHX1MPC4EN --- shared/webpack.config.d/node-core-fallback.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 shared/webpack.config.d/node-core-fallback.js diff --git a/shared/webpack.config.d/node-core-fallback.js b/shared/webpack.config.d/node-core-fallback.js new file mode 100644 index 00000000..9e71c075 --- /dev/null +++ b/shared/webpack.config.d/node-core-fallback.js @@ -0,0 +1,13 @@ +// Silences the "Module not found: Error: Can't resolve 'os' / 'path'" webpack messages emitted when +// bundling the shared module's browser test bundle (spectacled-shared-test). A transitive +// dependency (the ktor JS client) references those Node core modules in code paths the browser +// never runs; webpack 5 no longer auto-polyfills them. Mapping them to `false` resolves them to an +// empty module, which is correct for the browser and removes the noise. +// +// Merged into the existing resolve config (rather than replacing it) so Kotlin's own aliases and +// extension settings are preserved. +config.resolve = config.resolve || {}; +config.resolve.fallback = Object.assign({}, config.resolve.fallback, { + os: false, + path: false, +}); From 74c9c8dfd44ba506932caa6e2bf8a271738bfeab Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 07:24:50 +0000 Subject: [PATCH 23/25] QUA-12: push IO dispatch into the network + credential data sources Move withContext(ioDispatcher) out of the ViewModel launch sites and down into the layer that actually does the I/O, the way the repositories already self- dispatch, so ViewModels no longer name a dispatcher for network work: - Both DefaultWebDavRemote*DataSource impls wrap every call in withContext(ioDispatcher) (network, plus iCal parsing/serialization and inline attachment file I/O on the fetch/put paths). The Compose Desktop rationale - the Main dispatcher not resuming Ktor continuations, which was the motivating hang - now lives here, at the layer it applies to. - KtorRemoteGitHub{Contributor,Release}DataSource and KtorRemoteClaudeDataSource self-dispatch their network + JSON work. - CredentialStore (all four platform actuals) self-dispatches its KSafe reads/ writes; already suspend, so no caller changes. ViewModel launches that were purely network/credential/repository revert to a plain launch { }: AccountListViewModel's delete/discover/create-calendar and DetailsViewModel's Claude request. AccountListViewModel no longer imports ioDispatcher at all. Deliberately left dispatched (with updated comments explaining why): AboutViewModel (inline libraries.json parse), DetailsViewModel's sync (SyncCoordinator still reads attachment bytes off disk directly) and the three attachment file-I/O launches, and ListViewModel (CPU-bound recompute()). These remain because FileManager and the UserAppPreferencesStore property accessors are synchronous by design - used in the pure, tested ICS mapper, a composable, and theme reads - so making them suspend is a larger, separate change tracked as the QUA-12 follow-up. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Xt7MRuFpW1jtcHX1MPC4EN --- .../core/data/CredentialsStore.android.kt | 14 ++++-- .../KtorRemoteGitHubContributorDataSource.kt | 7 ++- .../data/KtorRemoteGitHubReleaseDataSource.kt | 7 ++- .../about/presentation/AboutViewModel.kt | 4 +- .../presentation/AccountListViewModel.kt | 14 +++--- .../data/claude/KtorRemoteClaudeDataSource.kt | 50 +++++++++++-------- .../webdav/WebDavRemoteCalendarDataSource.kt | 22 +++++--- .../webdav/WebDavRemoteIcalEntryDataSource.kt | 25 +++++++--- .../details/presentation/DetailsViewModel.kt | 17 ++++--- .../list/presentation/ListViewModel.kt | 4 +- .../screens/core/data/CredentialsStore.ios.kt | 14 ++++-- .../screens/core/data/CredentialsStore.jvm.kt | 14 ++++-- .../screens/core/data/CredentialsStore.web.kt | 16 ++++-- 13 files changed, 138 insertions(+), 70 deletions(-) diff --git a/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.android.kt b/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.android.kt index 972fec80..503fa489 100644 --- a/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.android.kt +++ b/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.android.kt @@ -1,21 +1,29 @@ package at.techbee.spectacled.screens.core.data import android.content.Context +import at.techbee.spectacled.screens.core.ioDispatcher import eu.anifantakis.lib.ksafe.KSafe import eu.anifantakis.lib.ksafe.KSafeWriteMode import io.ktor.http.Url +import kotlinx.coroutines.withContext actual class PlatformCredentialStore(context: Context): CredentialStore { private val ksafe = KSafe(context.applicationContext, CREDENTIALS_FILE_NAME) actual override suspend fun save(credentials: Credentials) { - ksafe.put(credentials.server.toString(), credentials, KSafeWriteMode.Encrypted()) + withContext(ioDispatcher) { + ksafe.put(credentials.server.toString(), credentials, KSafeWriteMode.Encrypted()) + } } - actual override suspend fun load(server: Url): Credentials? = ksafe.get(server.toString(), null) + actual override suspend fun load(server: Url): Credentials? = withContext(ioDispatcher) { + ksafe.get(server.toString(), null) + } actual override suspend fun clear(server: Url) { - ksafe.delete(server.toString()) + withContext(ioDispatcher) { + ksafe.delete(server.toString()) + } } } diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/about/data/KtorRemoteGitHubContributorDataSource.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/about/data/KtorRemoteGitHubContributorDataSource.kt index 11e449ba..35ce2bab 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/about/data/KtorRemoteGitHubContributorDataSource.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/about/data/KtorRemoteGitHubContributorDataSource.kt @@ -1,12 +1,14 @@ package at.techbee.spectacled.screens.about.data import at.techbee.spectacled.screens.about.domain.GitHubContributor +import at.techbee.spectacled.screens.core.ioDispatcher import io.github.aakira.napier.Napier import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.get import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext //TODO: Change for release!!! private const val BASE_URL = "https://api.github.com/repos/TechbeeAT/jtxBoard/contributors" @@ -15,8 +17,9 @@ class KtorRemoteGitHubContributorDataSource( val client: HttpClient ) { - suspend fun getContributors(): List { - return try { + // Dispatches its own network + JSON work onto ioDispatcher so callers don't have to. + suspend fun getContributors(): List = withContext(ioDispatcher) { + try { val response = client.get(BASE_URL) response.body>().map { it.toGitHubContributor() } } catch (e: Exception) { diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/about/data/KtorRemoteGitHubReleaseDataSource.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/about/data/KtorRemoteGitHubReleaseDataSource.kt index 57841ade..2c914f12 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/about/data/KtorRemoteGitHubReleaseDataSource.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/about/data/KtorRemoteGitHubReleaseDataSource.kt @@ -1,12 +1,14 @@ package at.techbee.spectacled.screens.about.data import at.techbee.spectacled.screens.about.domain.GitHubRelease +import at.techbee.spectacled.screens.core.ioDispatcher import io.github.aakira.napier.Napier import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.get import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext private const val BASE_URL = "https://api.github.com/repos/TechbeeAT/jtxBoard/releases?per_page=100" @@ -14,8 +16,9 @@ class KtorRemoteGitHubReleaseDataSource( val client: HttpClient ) { - suspend fun getReleases(): List { - return try { + // Dispatches its own network + JSON work onto ioDispatcher so callers don't have to. + suspend fun getReleases(): List = withContext(ioDispatcher) { + try { val response = client.get(BASE_URL) response.body>().map { it.toGitHubRelease() } } catch (e: Exception) { diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/about/presentation/AboutViewModel.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/about/presentation/AboutViewModel.kt index 7e80dce4..072b53f2 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/about/presentation/AboutViewModel.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/about/presentation/AboutViewModel.kt @@ -24,8 +24,8 @@ class AboutViewModel( val state = _state.asStateFlow() init { - // GitHub network calls, the resource read and JSON parsing must not run on the - // Main dispatcher; run them on IO (repository/DB work self-dispatches, network does not). + // The GitHub data sources self-dispatch their network now, but the AboutLibraries step + // still reads and parses libraries.json (CPU) inline here, so keep this launch on IO. viewModelScope.launch(ioDispatcher) { launch { diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/AccountListViewModel.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/AccountListViewModel.kt index 3565aed0..3aa35a94 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/AccountListViewModel.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/AccountListViewModel.kt @@ -5,7 +5,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.techbee.spectacled.SpectacledVariant import at.techbee.spectacled.screens.core.PlatformSyncTrigger -import at.techbee.spectacled.screens.core.ioDispatcher import at.techbee.spectacled.screens.core.data.Credentials import at.techbee.spectacled.screens.core.data.PlatformCredentialStore import at.techbee.spectacled.screens.core.data.PlatformUserAppPreferencesStore @@ -144,7 +143,7 @@ class AccountListViewModel( private fun deleteCalendar(principal: Principal, calendar: Calendar) { _state.update { it.copy(processingState = ProcessingState.Processing) } - viewModelScope.launch(ioDispatcher) { + viewModelScope.launch { try { val credentials = credentialStore.load(principal.principalUrl) ?: throw Exception("Credentials not found") @@ -255,11 +254,10 @@ class AccountListViewModel( Napier.d("Adding principals") _state.update { it.copy(processingState = ProcessingState.Processing) } - // Run the whole discovery pipeline off the Main dispatcher. On Compose Desktop the - // viewModelScope's Main dispatcher does not reliably resume suspended network - // continuations (nor the HttpTimeout timer), so leaving this on Main makes discovery - // hang forever without ever hitting a timeout. IO resumes reliably on every platform. - viewModelScope.launch(ioDispatcher) { + // Plain launch on Main: the WebDAV data source dispatches its own network work onto IO + // (which is also what keeps continuations resuming on Compose Desktop), and the + // repositories/credential store self-dispatch too, so nothing here blocks the UI thread. + viewModelScope.launch { try { // STEP 1: Discover principals @@ -391,7 +389,7 @@ class AccountListViewModel( Napier.d("Adding calendar") _state.update { it.copy(processingState = ProcessingState.Processing) } - viewModelScope.launch(ioDispatcher) { + viewModelScope.launch { try { val credentials = credentialStore.load(principal.principalUrl) ?: throw Exception("Credentials not found") diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/claude/KtorRemoteClaudeDataSource.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/claude/KtorRemoteClaudeDataSource.kt index db9c3251..25b00556 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/claude/KtorRemoteClaudeDataSource.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/claude/KtorRemoteClaudeDataSource.kt @@ -2,8 +2,10 @@ package at.techbee.spectacled.screens.core.data.claude import at.techbee.spectacled.screens.core.data.ics.IcsDateTime import at.techbee.spectacled.screens.core.domain.IcalEntry +import at.techbee.spectacled.screens.core.ioDispatcher import at.techbee.spectacled.screens.core.mapper.ics.formatIcsDateTime import io.github.aakira.napier.Napier +import kotlinx.coroutines.withContext import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.header @@ -68,31 +70,35 @@ class KtorRemoteClaudeDataSource( ${icalEntry.description} """.trimIndent() - try { - val response = client.post(ANTHROPIC_BASE_URL) { - contentType(ContentType.Application.Json) - header("x-api-key", claudeUserApiKey) - header("anthropic-version", "2023-06-01") - setBody(buildJsonObject { - put("model", "claude-sonnet-4-6") - put("max_tokens", 1000) - putJsonArray("messages") { - addJsonObject { - put("role", "user") - put("content", prompt) + // Dispatch the network call (and response parsing) onto ioDispatcher so callers launch on + // Main without naming a dispatcher. + return withContext(ioDispatcher) { + try { + val response = client.post(ANTHROPIC_BASE_URL) { + contentType(ContentType.Application.Json) + header("x-api-key", claudeUserApiKey) + header("anthropic-version", "2023-06-01") + setBody(buildJsonObject { + put("model", "claude-sonnet-4-6") + put("max_tokens", 1000) + putJsonArray("messages") { + addJsonObject { + put("role", "user") + put("content", prompt) + } } - } - }) - }.body() + }) + }.body() - return ClaudeRemoteResponseResult.Success(response.applyClaudeResponse(icalEntry)) + ClaudeRemoteResponseResult.Success(response.applyClaudeResponse(icalEntry)) - } catch (e: Exception) { - Napier.e("AI metadata request failed", e) - return ClaudeRemoteResponseResult.Failed( - message = "Fetching AI response failed", - details = e.message - ) + } catch (e: Exception) { + Napier.e("AI metadata request failed", e) + ClaudeRemoteResponseResult.Failed( + message = "Fetching AI response failed", + details = e.message + ) + } } } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteCalendarDataSource.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteCalendarDataSource.kt index 09480433..fa26c7e1 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteCalendarDataSource.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteCalendarDataSource.kt @@ -4,8 +4,10 @@ import at.techbee.spectacled.screens.core.data.Credentials import at.techbee.spectacled.screens.core.domain.Calendar import at.techbee.spectacled.screens.core.domain.HomeCollection import at.techbee.spectacled.screens.core.domain.Principal +import at.techbee.spectacled.screens.core.ioDispatcher import io.ktor.client.HttpClient import io.ktor.http.Url +import kotlinx.coroutines.withContext /** * All CalDAV server operations about principals, home collections, and calendars (discovery + @@ -24,25 +26,33 @@ interface WebDavRemoteCalendarDataSource { suspend fun deleteCalendar(calendar: Calendar, credentials: Credentials?): DeleteCalendarResult } +/** + * Every call dispatches its own network work onto [ioDispatcher], so callers (ViewModels) can + * launch on the Main dispatcher without thinking about threading — the same contract the + * repositories already follow. This is also load-bearing on Compose Desktop: the Main dispatcher + * there doesn't reliably resume suspended Ktor network continuations (nor fire the HttpTimeout + * timer), so a discovery/sync call left on Main hangs forever without ever timing out. Running on + * IO resumes reliably on every platform. + */ class DefaultWebDavRemoteCalendarDataSource( private val client: HttpClient, ) : WebDavRemoteCalendarDataSource { override suspend fun discoverPrincipals(location: Url, credentials: Credentials?) = - discoverPrincipalsMultiplatform(client, location, credentials) + withContext(ioDispatcher) { discoverPrincipalsMultiplatform(client, location, credentials) } override suspend fun discoverHomeCollections(principal: Principal, credentials: Credentials?) = - discoverHomeCollectionsMultiplatform(client, principal, credentials) + withContext(ioDispatcher) { discoverHomeCollectionsMultiplatform(client, principal, credentials) } override suspend fun discoverCalendars(homeCollection: HomeCollection, credentials: Credentials?) = - discoverCalendarsMultiplatform(client, homeCollection, credentials) + withContext(ioDispatcher) { discoverCalendarsMultiplatform(client, homeCollection, credentials) } override suspend fun createCalendar(calendar: Calendar, credentials: Credentials?) = - createCalendarMultiplatform(client, calendar, credentials) + withContext(ioDispatcher) { createCalendarMultiplatform(client, calendar, credentials) } override suspend fun updateCalendar(calendar: Calendar, credentials: Credentials?) = - updateCalDavCalendarMultiplatform(client, calendar, credentials) + withContext(ioDispatcher) { updateCalDavCalendarMultiplatform(client, calendar, credentials) } override suspend fun deleteCalendar(calendar: Calendar, credentials: Credentials?) = - deleteCalendarMultiplatform(client, calendar, credentials) + withContext(ioDispatcher) { deleteCalendarMultiplatform(client, calendar, credentials) } } diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteIcalEntryDataSource.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteIcalEntryDataSource.kt index 1b76cc67..86165ad8 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteIcalEntryDataSource.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteIcalEntryDataSource.kt @@ -4,9 +4,11 @@ import at.techbee.spectacled.screens.core.FileManager import at.techbee.spectacled.screens.core.data.Credentials import at.techbee.spectacled.screens.core.domain.Calendar import at.techbee.spectacled.screens.core.domain.IcalEntry +import at.techbee.spectacled.screens.core.ioDispatcher import io.ktor.client.HttpClient import io.ktor.http.HttpStatusCode import io.ktor.http.Url +import kotlinx.coroutines.withContext /** * All CalDAV server operations about the resources within a calendar - enumerating/syncing @@ -31,32 +33,39 @@ interface WebDavRemoteIcalEntryDataSource { suspend fun downloadFile(sourceUrl: Url, credentials: Credentials?): ByteArray? } +/** + * Every call dispatches its own work onto [ioDispatcher] — the network transfer plus, for the + * fetch/put paths, the iCal parsing/serialization and inline-attachment file I/O that runs inside + * those top-level functions. Callers can launch on Main without naming a dispatcher, matching the + * repositories. As with [WebDavRemoteCalendarDataSource], IO is also what makes network + * continuations resume reliably on Compose Desktop. + */ class DefaultWebDavRemoteIcalEntryDataSource( private val client: HttpClient, private val fileManager: FileManager, ) : WebDavRemoteIcalEntryDataSource { override suspend fun syncCollection(calendar: Calendar, credentials: Credentials?) = - syncCollectionMultiplatform(client, calendar, credentials) + withContext(ioDispatcher) { syncCollectionMultiplatform(client, calendar, credentials) } override suspend fun multigetResourceHrefs(calendar: Calendar, credentials: Credentials?) = - multigetResourceHrefsMultiplatform(client, calendar, credentials) + withContext(ioDispatcher) { multigetResourceHrefsMultiplatform(client, calendar, credentials) } override suspend fun fetchSingleEntry(calendar: Calendar, href: Url, credentials: Credentials?) = - fetchSingleEntryMultiplatform(client, calendar, href, credentials, fileManager) + withContext(ioDispatcher) { fetchSingleEntryMultiplatform(client, calendar, href, credentials, fileManager) } override suspend fun putResource(calendar: Calendar, icalEntry: IcalEntry, credentials: Credentials?) = - putResourceMultiplatform(client, calendar, icalEntry, credentials, fileManager) + withContext(ioDispatcher) { putResourceMultiplatform(client, calendar, icalEntry, credentials, fileManager) } override suspend fun getResource(calendar: Calendar, icalEntry: IcalEntry, credentials: Credentials?) = - getResourceMultiplatform(client, calendar, icalEntry, credentials, fileManager) + withContext(ioDispatcher) { getResourceMultiplatform(client, calendar, icalEntry, credentials, fileManager) } override suspend fun deleteResource(calendar: Calendar, icalEntry: IcalEntry, credentials: Credentials?) = - deleteResourceMultiplatform(client, calendar, icalEntry, credentials) + withContext(ioDispatcher) { deleteResourceMultiplatform(client, calendar, icalEntry, credentials) } override suspend fun uploadFile(targetUrl: Url, bytes: ByteArray, mimeType: String?, credentials: Credentials?) = - uploadFileMultiplatform(client, targetUrl, bytes, mimeType, credentials) + withContext(ioDispatcher) { uploadFileMultiplatform(client, targetUrl, bytes, mimeType, credentials) } override suspend fun downloadFile(sourceUrl: Url, credentials: Credentials?) = - downloadFileMultiplatform(client, sourceUrl, credentials) + withContext(ioDispatcher) { downloadFileMultiplatform(client, sourceUrl, credentials) } } diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt index 7e259048..f7f7e987 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt @@ -567,7 +567,9 @@ class DetailsViewModel( _state.update { it.copy(isLoading = true) } - // Full CalDAV sync (network + iCal parsing) — keep it off the Main dispatcher. + // The data sources dispatch the network + iCal parsing themselves, but SyncCoordinator + // still reads attachment bytes off disk directly (synchronous FileManager) while pushing, + // so keep this launch on IO until FileManager itself becomes suspend (QUA-12 follow-up). viewModelScope.launch(ioDispatcher) { try { @@ -690,8 +692,8 @@ class DetailsViewModel( _state.update { it.copy(isLoading = true) } - // Claude API network call — keep it off the Main dispatcher. - viewModelScope.launch(ioDispatcher) { + // Plain launch on Main: KtorRemoteClaudeDataSource dispatches its own network call onto IO. + viewModelScope.launch { val remoteResult = KtorRemoteClaudeDataSource(client, state.value.claudeUserApiKey?:"").applyAiMetadata(_state.value.icalEntry) @@ -734,7 +736,8 @@ class DetailsViewModel( @OptIn(ExperimentalTime::class) private fun onAddAttachment(fileName: String, bytes: ByteArray, mimeType: String?, isInline: Boolean = false) { - // Writes the attachment bytes to disk — keep the file I/O off the Main dispatcher. + // Direct synchronous FileManager disk write — kept on IO until FileManager becomes + // suspend (QUA-12 follow-up); the mapper and a composable read it synchronously today. viewModelScope.launch(ioDispatcher) { val attachmentUid = Uuid.random().toString() val localPath = fileManager.saveAttachment("$fileName-${attachmentUid.take(8)}", bytes) @@ -767,7 +770,9 @@ class DetailsViewModel( if (attachment.localPath != null && fileManager.exists(attachment.localPath)) { fileLauncher.openFile(attachment.localPath, attachment.mimeType) } else if (attachment.remoteUrl != null) { - // WebDAV download + saving the file to disk — keep it off the Main dispatcher. + // The download self-dispatches now, but the save-to-disk (synchronous FileManager) + // does not, so keep this on IO (QUA-12 follow-up: make FileManager suspend). The + // openFile UI calls below hop back to Main explicitly. viewModelScope.launch(ioDispatcher) { try { _state.update { it.copy(downloadingAttachmentUids = it.downloadingAttachmentUids + attachmentUid) } @@ -800,7 +805,7 @@ class DetailsViewModel( } private fun onDeleteAttachment(attachmentUid: String) { - // Deletes the attachment file from disk — keep the file I/O off the Main dispatcher. + // Direct synchronous FileManager disk delete — kept on IO (QUA-12 follow-up: suspend FileManager). viewModelScope.launch(ioDispatcher) { val attachment = _state.value.icalEntry.attachments.find { it.uid == attachmentUid } if (attachment != null) { diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/ListViewModel.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/ListViewModel.kt index e9f0dcda..3cb71ead 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/ListViewModel.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/ListViewModel.kt @@ -67,8 +67,8 @@ class ListViewModel( userAppPreferencesStore.lastUsedCalendarId = calendarId - // Off the Main dispatcher: reads the credential store (disk) and keeps the flow - // collectors — including the CPU-bound recompute() — off the UI thread. + // Kept on IO for the CPU-bound recompute() run by the flow collectors below — that work + // isn't behind a self-dispatching data source (the credential store now dispatches itself). observationJob = viewModelScope.launch(ioDispatcher) { val principal = calendarRepository.getPrincipalForCalendar(calendarId) val credentials = principal?.let { credentialStore.load(it.principalUrl) } diff --git a/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.ios.kt b/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.ios.kt index 0920ec8f..4a21bc4e 100644 --- a/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.ios.kt +++ b/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.ios.kt @@ -1,20 +1,28 @@ package at.techbee.spectacled.screens.core.data +import at.techbee.spectacled.screens.core.ioDispatcher import eu.anifantakis.lib.ksafe.KSafe import eu.anifantakis.lib.ksafe.KSafeWriteMode import io.ktor.http.Url +import kotlinx.coroutines.withContext actual class PlatformCredentialStore(): CredentialStore { private val ksafe = KSafe(CREDENTIALS_FILE_NAME) actual override suspend fun save(credentials: Credentials) { - ksafe.put(credentials.server.toString(), credentials, KSafeWriteMode.Encrypted()) + withContext(ioDispatcher) { + ksafe.put(credentials.server.toString(), credentials, KSafeWriteMode.Encrypted()) + } } - actual override suspend fun load(server: Url): Credentials? = ksafe.get(server.toString(), null) + actual override suspend fun load(server: Url): Credentials? = withContext(ioDispatcher) { + ksafe.get(server.toString(), null) + } actual override suspend fun clear(server: Url) { - ksafe.delete(server.toString()) + withContext(ioDispatcher) { + ksafe.delete(server.toString()) + } } } diff --git a/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.jvm.kt b/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.jvm.kt index 0920ec8f..4a21bc4e 100644 --- a/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.jvm.kt +++ b/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.jvm.kt @@ -1,20 +1,28 @@ package at.techbee.spectacled.screens.core.data +import at.techbee.spectacled.screens.core.ioDispatcher import eu.anifantakis.lib.ksafe.KSafe import eu.anifantakis.lib.ksafe.KSafeWriteMode import io.ktor.http.Url +import kotlinx.coroutines.withContext actual class PlatformCredentialStore(): CredentialStore { private val ksafe = KSafe(CREDENTIALS_FILE_NAME) actual override suspend fun save(credentials: Credentials) { - ksafe.put(credentials.server.toString(), credentials, KSafeWriteMode.Encrypted()) + withContext(ioDispatcher) { + ksafe.put(credentials.server.toString(), credentials, KSafeWriteMode.Encrypted()) + } } - actual override suspend fun load(server: Url): Credentials? = ksafe.get(server.toString(), null) + actual override suspend fun load(server: Url): Credentials? = withContext(ioDispatcher) { + ksafe.get(server.toString(), null) + } actual override suspend fun clear(server: Url) { - ksafe.delete(server.toString()) + withContext(ioDispatcher) { + ksafe.delete(server.toString()) + } } } diff --git a/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.web.kt b/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.web.kt index 2eee0055..c0cda30b 100644 --- a/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.web.kt +++ b/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.web.kt @@ -1,22 +1,32 @@ package at.techbee.spectacled.screens.core.data +import at.techbee.spectacled.screens.core.ioDispatcher import eu.anifantakis.lib.ksafe.KSafe import eu.anifantakis.lib.ksafe.KSafeWriteMode import eu.anifantakis.lib.ksafe.awaitCacheReady import io.ktor.http.Url +import kotlinx.coroutines.withContext actual class PlatformCredentialStore(): CredentialStore { private val ksafe = KSafe(CREDENTIALS_FILE_NAME) + // On web ioDispatcher is Dispatchers.Default (the single JS thread), so these stay effectively + // on-thread; the wrapping is kept for parity with the other platforms and the repositories. actual override suspend fun save(credentials: Credentials) { - ksafe.put(credentials.server.toString(), credentials, KSafeWriteMode.Encrypted()) + withContext(ioDispatcher) { + ksafe.put(credentials.server.toString(), credentials, KSafeWriteMode.Encrypted()) + } } - actual override suspend fun load(server: Url): Credentials? = ksafe.get(server.toString(), null) + actual override suspend fun load(server: Url): Credentials? = withContext(ioDispatcher) { + ksafe.get(server.toString(), null) + } actual override suspend fun clear(server: Url) { - ksafe.delete(server.toString()) + withContext(ioDispatcher) { + ksafe.delete(server.toString()) + } } override suspend fun awaitReady() = ksafe.awaitCacheReady() From a20a22fc7f9a2c366147bdef1892023e73c5314e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 11:45:54 +0000 Subject: [PATCH 24/25] Revert QUA-12 (#52): dispatcher refactor broke credentials on iOS/Desktop QUA-12 moved withContext(ioDispatcher) out of the ViewModel launches and into the data sources + the KSafe-backed CredentialStore, so ViewModels could launch on Main. It works on Android but breaks credential loading on iOS and Desktop: the app reads credentials back as null, prompts to update the password, and the password entry is then unusable. Root cause is that the ioDispatcher-at-the-launch-site pattern was load-bearing for reasons this refactor's premise ignored: - CredentialStore is KSafe, a stateful store with its own cache/coroutine machinery. It was proven working when called from inside an ioDispatcher launch; wrapping its suspend calls in a nested withContext(ioDispatcher) (and/or invoking them from a Main-dispatched coroutine after reverting the launches) misbehaves on iOS/Native and Desktop while Android tolerates it - reads come back as the default (null). - On Compose Desktop the Main dispatcher doesn't reliably resume suspended continuations, which is exactly why these launches were on ioDispatcher to begin with; letting ViewModels launch on Main reintroduces that fragility. Reverts the squashed #52 wholesale, restoring the known-good state on all platforms (the later string-resource and password-sheet commits are untouched). The one genuinely good idea in there - the network data sources dispatching their own IO for the future extractable DAV library - can be reintroduced on its own later, with the ViewModel launches left on ioDispatcher and the KSafe store left exactly as it is. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Xt7MRuFpW1jtcHX1MPC4EN --- .../core/data/CredentialsStore.android.kt | 14 ++---- .../KtorRemoteGitHubContributorDataSource.kt | 7 +-- .../data/KtorRemoteGitHubReleaseDataSource.kt | 7 +-- .../about/presentation/AboutViewModel.kt | 4 +- .../presentation/AccountListViewModel.kt | 14 +++--- .../data/claude/KtorRemoteClaudeDataSource.kt | 50 ++++++++----------- .../webdav/WebDavRemoteCalendarDataSource.kt | 22 +++----- .../webdav/WebDavRemoteIcalEntryDataSource.kt | 25 +++------- .../details/presentation/DetailsViewModel.kt | 17 +++---- .../list/presentation/ListViewModel.kt | 4 +- .../screens/core/data/CredentialsStore.ios.kt | 14 ++---- .../screens/core/data/CredentialsStore.jvm.kt | 14 ++---- .../screens/core/data/CredentialsStore.web.kt | 16 ++---- 13 files changed, 70 insertions(+), 138 deletions(-) diff --git a/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.android.kt b/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.android.kt index 503fa489..972fec80 100644 --- a/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.android.kt +++ b/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.android.kt @@ -1,29 +1,21 @@ package at.techbee.spectacled.screens.core.data import android.content.Context -import at.techbee.spectacled.screens.core.ioDispatcher import eu.anifantakis.lib.ksafe.KSafe import eu.anifantakis.lib.ksafe.KSafeWriteMode import io.ktor.http.Url -import kotlinx.coroutines.withContext actual class PlatformCredentialStore(context: Context): CredentialStore { private val ksafe = KSafe(context.applicationContext, CREDENTIALS_FILE_NAME) actual override suspend fun save(credentials: Credentials) { - withContext(ioDispatcher) { - ksafe.put(credentials.server.toString(), credentials, KSafeWriteMode.Encrypted()) - } + ksafe.put(credentials.server.toString(), credentials, KSafeWriteMode.Encrypted()) } - actual override suspend fun load(server: Url): Credentials? = withContext(ioDispatcher) { - ksafe.get(server.toString(), null) - } + actual override suspend fun load(server: Url): Credentials? = ksafe.get(server.toString(), null) actual override suspend fun clear(server: Url) { - withContext(ioDispatcher) { - ksafe.delete(server.toString()) - } + ksafe.delete(server.toString()) } } diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/about/data/KtorRemoteGitHubContributorDataSource.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/about/data/KtorRemoteGitHubContributorDataSource.kt index 35ce2bab..11e449ba 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/about/data/KtorRemoteGitHubContributorDataSource.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/about/data/KtorRemoteGitHubContributorDataSource.kt @@ -1,14 +1,12 @@ package at.techbee.spectacled.screens.about.data import at.techbee.spectacled.screens.about.domain.GitHubContributor -import at.techbee.spectacled.screens.core.ioDispatcher import io.github.aakira.napier.Napier import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.get import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.withContext //TODO: Change for release!!! private const val BASE_URL = "https://api.github.com/repos/TechbeeAT/jtxBoard/contributors" @@ -17,9 +15,8 @@ class KtorRemoteGitHubContributorDataSource( val client: HttpClient ) { - // Dispatches its own network + JSON work onto ioDispatcher so callers don't have to. - suspend fun getContributors(): List = withContext(ioDispatcher) { - try { + suspend fun getContributors(): List { + return try { val response = client.get(BASE_URL) response.body>().map { it.toGitHubContributor() } } catch (e: Exception) { diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/about/data/KtorRemoteGitHubReleaseDataSource.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/about/data/KtorRemoteGitHubReleaseDataSource.kt index 2c914f12..57841ade 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/about/data/KtorRemoteGitHubReleaseDataSource.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/about/data/KtorRemoteGitHubReleaseDataSource.kt @@ -1,14 +1,12 @@ package at.techbee.spectacled.screens.about.data import at.techbee.spectacled.screens.about.domain.GitHubRelease -import at.techbee.spectacled.screens.core.ioDispatcher import io.github.aakira.napier.Napier import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.get import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.withContext private const val BASE_URL = "https://api.github.com/repos/TechbeeAT/jtxBoard/releases?per_page=100" @@ -16,9 +14,8 @@ class KtorRemoteGitHubReleaseDataSource( val client: HttpClient ) { - // Dispatches its own network + JSON work onto ioDispatcher so callers don't have to. - suspend fun getReleases(): List = withContext(ioDispatcher) { - try { + suspend fun getReleases(): List { + return try { val response = client.get(BASE_URL) response.body>().map { it.toGitHubRelease() } } catch (e: Exception) { diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/about/presentation/AboutViewModel.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/about/presentation/AboutViewModel.kt index 072b53f2..7e80dce4 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/about/presentation/AboutViewModel.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/about/presentation/AboutViewModel.kt @@ -24,8 +24,8 @@ class AboutViewModel( val state = _state.asStateFlow() init { - // The GitHub data sources self-dispatch their network now, but the AboutLibraries step - // still reads and parses libraries.json (CPU) inline here, so keep this launch on IO. + // GitHub network calls, the resource read and JSON parsing must not run on the + // Main dispatcher; run them on IO (repository/DB work self-dispatches, network does not). viewModelScope.launch(ioDispatcher) { launch { diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/AccountListViewModel.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/AccountListViewModel.kt index 3aa35a94..3565aed0 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/AccountListViewModel.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/AccountListViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.techbee.spectacled.SpectacledVariant import at.techbee.spectacled.screens.core.PlatformSyncTrigger +import at.techbee.spectacled.screens.core.ioDispatcher import at.techbee.spectacled.screens.core.data.Credentials import at.techbee.spectacled.screens.core.data.PlatformCredentialStore import at.techbee.spectacled.screens.core.data.PlatformUserAppPreferencesStore @@ -143,7 +144,7 @@ class AccountListViewModel( private fun deleteCalendar(principal: Principal, calendar: Calendar) { _state.update { it.copy(processingState = ProcessingState.Processing) } - viewModelScope.launch { + viewModelScope.launch(ioDispatcher) { try { val credentials = credentialStore.load(principal.principalUrl) ?: throw Exception("Credentials not found") @@ -254,10 +255,11 @@ class AccountListViewModel( Napier.d("Adding principals") _state.update { it.copy(processingState = ProcessingState.Processing) } - // Plain launch on Main: the WebDAV data source dispatches its own network work onto IO - // (which is also what keeps continuations resuming on Compose Desktop), and the - // repositories/credential store self-dispatch too, so nothing here blocks the UI thread. - viewModelScope.launch { + // Run the whole discovery pipeline off the Main dispatcher. On Compose Desktop the + // viewModelScope's Main dispatcher does not reliably resume suspended network + // continuations (nor the HttpTimeout timer), so leaving this on Main makes discovery + // hang forever without ever hitting a timeout. IO resumes reliably on every platform. + viewModelScope.launch(ioDispatcher) { try { // STEP 1: Discover principals @@ -389,7 +391,7 @@ class AccountListViewModel( Napier.d("Adding calendar") _state.update { it.copy(processingState = ProcessingState.Processing) } - viewModelScope.launch { + viewModelScope.launch(ioDispatcher) { try { val credentials = credentialStore.load(principal.principalUrl) ?: throw Exception("Credentials not found") diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/claude/KtorRemoteClaudeDataSource.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/claude/KtorRemoteClaudeDataSource.kt index 25b00556..db9c3251 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/claude/KtorRemoteClaudeDataSource.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/claude/KtorRemoteClaudeDataSource.kt @@ -2,10 +2,8 @@ package at.techbee.spectacled.screens.core.data.claude import at.techbee.spectacled.screens.core.data.ics.IcsDateTime import at.techbee.spectacled.screens.core.domain.IcalEntry -import at.techbee.spectacled.screens.core.ioDispatcher import at.techbee.spectacled.screens.core.mapper.ics.formatIcsDateTime import io.github.aakira.napier.Napier -import kotlinx.coroutines.withContext import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.header @@ -70,35 +68,31 @@ class KtorRemoteClaudeDataSource( ${icalEntry.description} """.trimIndent() - // Dispatch the network call (and response parsing) onto ioDispatcher so callers launch on - // Main without naming a dispatcher. - return withContext(ioDispatcher) { - try { - val response = client.post(ANTHROPIC_BASE_URL) { - contentType(ContentType.Application.Json) - header("x-api-key", claudeUserApiKey) - header("anthropic-version", "2023-06-01") - setBody(buildJsonObject { - put("model", "claude-sonnet-4-6") - put("max_tokens", 1000) - putJsonArray("messages") { - addJsonObject { - put("role", "user") - put("content", prompt) - } + try { + val response = client.post(ANTHROPIC_BASE_URL) { + contentType(ContentType.Application.Json) + header("x-api-key", claudeUserApiKey) + header("anthropic-version", "2023-06-01") + setBody(buildJsonObject { + put("model", "claude-sonnet-4-6") + put("max_tokens", 1000) + putJsonArray("messages") { + addJsonObject { + put("role", "user") + put("content", prompt) } - }) - }.body() + } + }) + }.body() - ClaudeRemoteResponseResult.Success(response.applyClaudeResponse(icalEntry)) + return ClaudeRemoteResponseResult.Success(response.applyClaudeResponse(icalEntry)) - } catch (e: Exception) { - Napier.e("AI metadata request failed", e) - ClaudeRemoteResponseResult.Failed( - message = "Fetching AI response failed", - details = e.message - ) - } + } catch (e: Exception) { + Napier.e("AI metadata request failed", e) + return ClaudeRemoteResponseResult.Failed( + message = "Fetching AI response failed", + details = e.message + ) } } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteCalendarDataSource.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteCalendarDataSource.kt index fa26c7e1..09480433 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteCalendarDataSource.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteCalendarDataSource.kt @@ -4,10 +4,8 @@ import at.techbee.spectacled.screens.core.data.Credentials import at.techbee.spectacled.screens.core.domain.Calendar import at.techbee.spectacled.screens.core.domain.HomeCollection import at.techbee.spectacled.screens.core.domain.Principal -import at.techbee.spectacled.screens.core.ioDispatcher import io.ktor.client.HttpClient import io.ktor.http.Url -import kotlinx.coroutines.withContext /** * All CalDAV server operations about principals, home collections, and calendars (discovery + @@ -26,33 +24,25 @@ interface WebDavRemoteCalendarDataSource { suspend fun deleteCalendar(calendar: Calendar, credentials: Credentials?): DeleteCalendarResult } -/** - * Every call dispatches its own network work onto [ioDispatcher], so callers (ViewModels) can - * launch on the Main dispatcher without thinking about threading — the same contract the - * repositories already follow. This is also load-bearing on Compose Desktop: the Main dispatcher - * there doesn't reliably resume suspended Ktor network continuations (nor fire the HttpTimeout - * timer), so a discovery/sync call left on Main hangs forever without ever timing out. Running on - * IO resumes reliably on every platform. - */ class DefaultWebDavRemoteCalendarDataSource( private val client: HttpClient, ) : WebDavRemoteCalendarDataSource { override suspend fun discoverPrincipals(location: Url, credentials: Credentials?) = - withContext(ioDispatcher) { discoverPrincipalsMultiplatform(client, location, credentials) } + discoverPrincipalsMultiplatform(client, location, credentials) override suspend fun discoverHomeCollections(principal: Principal, credentials: Credentials?) = - withContext(ioDispatcher) { discoverHomeCollectionsMultiplatform(client, principal, credentials) } + discoverHomeCollectionsMultiplatform(client, principal, credentials) override suspend fun discoverCalendars(homeCollection: HomeCollection, credentials: Credentials?) = - withContext(ioDispatcher) { discoverCalendarsMultiplatform(client, homeCollection, credentials) } + discoverCalendarsMultiplatform(client, homeCollection, credentials) override suspend fun createCalendar(calendar: Calendar, credentials: Credentials?) = - withContext(ioDispatcher) { createCalendarMultiplatform(client, calendar, credentials) } + createCalendarMultiplatform(client, calendar, credentials) override suspend fun updateCalendar(calendar: Calendar, credentials: Credentials?) = - withContext(ioDispatcher) { updateCalDavCalendarMultiplatform(client, calendar, credentials) } + updateCalDavCalendarMultiplatform(client, calendar, credentials) override suspend fun deleteCalendar(calendar: Calendar, credentials: Credentials?) = - withContext(ioDispatcher) { deleteCalendarMultiplatform(client, calendar, credentials) } + deleteCalendarMultiplatform(client, calendar, credentials) } diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteIcalEntryDataSource.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteIcalEntryDataSource.kt index 86165ad8..1b76cc67 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteIcalEntryDataSource.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/WebDavRemoteIcalEntryDataSource.kt @@ -4,11 +4,9 @@ import at.techbee.spectacled.screens.core.FileManager import at.techbee.spectacled.screens.core.data.Credentials import at.techbee.spectacled.screens.core.domain.Calendar import at.techbee.spectacled.screens.core.domain.IcalEntry -import at.techbee.spectacled.screens.core.ioDispatcher import io.ktor.client.HttpClient import io.ktor.http.HttpStatusCode import io.ktor.http.Url -import kotlinx.coroutines.withContext /** * All CalDAV server operations about the resources within a calendar - enumerating/syncing @@ -33,39 +31,32 @@ interface WebDavRemoteIcalEntryDataSource { suspend fun downloadFile(sourceUrl: Url, credentials: Credentials?): ByteArray? } -/** - * Every call dispatches its own work onto [ioDispatcher] — the network transfer plus, for the - * fetch/put paths, the iCal parsing/serialization and inline-attachment file I/O that runs inside - * those top-level functions. Callers can launch on Main without naming a dispatcher, matching the - * repositories. As with [WebDavRemoteCalendarDataSource], IO is also what makes network - * continuations resume reliably on Compose Desktop. - */ class DefaultWebDavRemoteIcalEntryDataSource( private val client: HttpClient, private val fileManager: FileManager, ) : WebDavRemoteIcalEntryDataSource { override suspend fun syncCollection(calendar: Calendar, credentials: Credentials?) = - withContext(ioDispatcher) { syncCollectionMultiplatform(client, calendar, credentials) } + syncCollectionMultiplatform(client, calendar, credentials) override suspend fun multigetResourceHrefs(calendar: Calendar, credentials: Credentials?) = - withContext(ioDispatcher) { multigetResourceHrefsMultiplatform(client, calendar, credentials) } + multigetResourceHrefsMultiplatform(client, calendar, credentials) override suspend fun fetchSingleEntry(calendar: Calendar, href: Url, credentials: Credentials?) = - withContext(ioDispatcher) { fetchSingleEntryMultiplatform(client, calendar, href, credentials, fileManager) } + fetchSingleEntryMultiplatform(client, calendar, href, credentials, fileManager) override suspend fun putResource(calendar: Calendar, icalEntry: IcalEntry, credentials: Credentials?) = - withContext(ioDispatcher) { putResourceMultiplatform(client, calendar, icalEntry, credentials, fileManager) } + putResourceMultiplatform(client, calendar, icalEntry, credentials, fileManager) override suspend fun getResource(calendar: Calendar, icalEntry: IcalEntry, credentials: Credentials?) = - withContext(ioDispatcher) { getResourceMultiplatform(client, calendar, icalEntry, credentials, fileManager) } + getResourceMultiplatform(client, calendar, icalEntry, credentials, fileManager) override suspend fun deleteResource(calendar: Calendar, icalEntry: IcalEntry, credentials: Credentials?) = - withContext(ioDispatcher) { deleteResourceMultiplatform(client, calendar, icalEntry, credentials) } + deleteResourceMultiplatform(client, calendar, icalEntry, credentials) override suspend fun uploadFile(targetUrl: Url, bytes: ByteArray, mimeType: String?, credentials: Credentials?) = - withContext(ioDispatcher) { uploadFileMultiplatform(client, targetUrl, bytes, mimeType, credentials) } + uploadFileMultiplatform(client, targetUrl, bytes, mimeType, credentials) override suspend fun downloadFile(sourceUrl: Url, credentials: Credentials?) = - withContext(ioDispatcher) { downloadFileMultiplatform(client, sourceUrl, credentials) } + downloadFileMultiplatform(client, sourceUrl, credentials) } diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt index f7f7e987..7e259048 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt @@ -567,9 +567,7 @@ class DetailsViewModel( _state.update { it.copy(isLoading = true) } - // The data sources dispatch the network + iCal parsing themselves, but SyncCoordinator - // still reads attachment bytes off disk directly (synchronous FileManager) while pushing, - // so keep this launch on IO until FileManager itself becomes suspend (QUA-12 follow-up). + // Full CalDAV sync (network + iCal parsing) — keep it off the Main dispatcher. viewModelScope.launch(ioDispatcher) { try { @@ -692,8 +690,8 @@ class DetailsViewModel( _state.update { it.copy(isLoading = true) } - // Plain launch on Main: KtorRemoteClaudeDataSource dispatches its own network call onto IO. - viewModelScope.launch { + // Claude API network call — keep it off the Main dispatcher. + viewModelScope.launch(ioDispatcher) { val remoteResult = KtorRemoteClaudeDataSource(client, state.value.claudeUserApiKey?:"").applyAiMetadata(_state.value.icalEntry) @@ -736,8 +734,7 @@ class DetailsViewModel( @OptIn(ExperimentalTime::class) private fun onAddAttachment(fileName: String, bytes: ByteArray, mimeType: String?, isInline: Boolean = false) { - // Direct synchronous FileManager disk write — kept on IO until FileManager becomes - // suspend (QUA-12 follow-up); the mapper and a composable read it synchronously today. + // Writes the attachment bytes to disk — keep the file I/O off the Main dispatcher. viewModelScope.launch(ioDispatcher) { val attachmentUid = Uuid.random().toString() val localPath = fileManager.saveAttachment("$fileName-${attachmentUid.take(8)}", bytes) @@ -770,9 +767,7 @@ class DetailsViewModel( if (attachment.localPath != null && fileManager.exists(attachment.localPath)) { fileLauncher.openFile(attachment.localPath, attachment.mimeType) } else if (attachment.remoteUrl != null) { - // The download self-dispatches now, but the save-to-disk (synchronous FileManager) - // does not, so keep this on IO (QUA-12 follow-up: make FileManager suspend). The - // openFile UI calls below hop back to Main explicitly. + // WebDAV download + saving the file to disk — keep it off the Main dispatcher. viewModelScope.launch(ioDispatcher) { try { _state.update { it.copy(downloadingAttachmentUids = it.downloadingAttachmentUids + attachmentUid) } @@ -805,7 +800,7 @@ class DetailsViewModel( } private fun onDeleteAttachment(attachmentUid: String) { - // Direct synchronous FileManager disk delete — kept on IO (QUA-12 follow-up: suspend FileManager). + // Deletes the attachment file from disk — keep the file I/O off the Main dispatcher. viewModelScope.launch(ioDispatcher) { val attachment = _state.value.icalEntry.attachments.find { it.uid == attachmentUid } if (attachment != null) { diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/ListViewModel.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/ListViewModel.kt index 3cb71ead..e9f0dcda 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/ListViewModel.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/ListViewModel.kt @@ -67,8 +67,8 @@ class ListViewModel( userAppPreferencesStore.lastUsedCalendarId = calendarId - // Kept on IO for the CPU-bound recompute() run by the flow collectors below — that work - // isn't behind a self-dispatching data source (the credential store now dispatches itself). + // Off the Main dispatcher: reads the credential store (disk) and keeps the flow + // collectors — including the CPU-bound recompute() — off the UI thread. observationJob = viewModelScope.launch(ioDispatcher) { val principal = calendarRepository.getPrincipalForCalendar(calendarId) val credentials = principal?.let { credentialStore.load(it.principalUrl) } diff --git a/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.ios.kt b/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.ios.kt index 4a21bc4e..0920ec8f 100644 --- a/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.ios.kt +++ b/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.ios.kt @@ -1,28 +1,20 @@ package at.techbee.spectacled.screens.core.data -import at.techbee.spectacled.screens.core.ioDispatcher import eu.anifantakis.lib.ksafe.KSafe import eu.anifantakis.lib.ksafe.KSafeWriteMode import io.ktor.http.Url -import kotlinx.coroutines.withContext actual class PlatformCredentialStore(): CredentialStore { private val ksafe = KSafe(CREDENTIALS_FILE_NAME) actual override suspend fun save(credentials: Credentials) { - withContext(ioDispatcher) { - ksafe.put(credentials.server.toString(), credentials, KSafeWriteMode.Encrypted()) - } + ksafe.put(credentials.server.toString(), credentials, KSafeWriteMode.Encrypted()) } - actual override suspend fun load(server: Url): Credentials? = withContext(ioDispatcher) { - ksafe.get(server.toString(), null) - } + actual override suspend fun load(server: Url): Credentials? = ksafe.get(server.toString(), null) actual override suspend fun clear(server: Url) { - withContext(ioDispatcher) { - ksafe.delete(server.toString()) - } + ksafe.delete(server.toString()) } } diff --git a/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.jvm.kt b/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.jvm.kt index 4a21bc4e..0920ec8f 100644 --- a/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.jvm.kt +++ b/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.jvm.kt @@ -1,28 +1,20 @@ package at.techbee.spectacled.screens.core.data -import at.techbee.spectacled.screens.core.ioDispatcher import eu.anifantakis.lib.ksafe.KSafe import eu.anifantakis.lib.ksafe.KSafeWriteMode import io.ktor.http.Url -import kotlinx.coroutines.withContext actual class PlatformCredentialStore(): CredentialStore { private val ksafe = KSafe(CREDENTIALS_FILE_NAME) actual override suspend fun save(credentials: Credentials) { - withContext(ioDispatcher) { - ksafe.put(credentials.server.toString(), credentials, KSafeWriteMode.Encrypted()) - } + ksafe.put(credentials.server.toString(), credentials, KSafeWriteMode.Encrypted()) } - actual override suspend fun load(server: Url): Credentials? = withContext(ioDispatcher) { - ksafe.get(server.toString(), null) - } + actual override suspend fun load(server: Url): Credentials? = ksafe.get(server.toString(), null) actual override suspend fun clear(server: Url) { - withContext(ioDispatcher) { - ksafe.delete(server.toString()) - } + ksafe.delete(server.toString()) } } diff --git a/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.web.kt b/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.web.kt index c0cda30b..2eee0055 100644 --- a/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.web.kt +++ b/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.web.kt @@ -1,32 +1,22 @@ package at.techbee.spectacled.screens.core.data -import at.techbee.spectacled.screens.core.ioDispatcher import eu.anifantakis.lib.ksafe.KSafe import eu.anifantakis.lib.ksafe.KSafeWriteMode import eu.anifantakis.lib.ksafe.awaitCacheReady import io.ktor.http.Url -import kotlinx.coroutines.withContext actual class PlatformCredentialStore(): CredentialStore { private val ksafe = KSafe(CREDENTIALS_FILE_NAME) - // On web ioDispatcher is Dispatchers.Default (the single JS thread), so these stay effectively - // on-thread; the wrapping is kept for parity with the other platforms and the repositories. actual override suspend fun save(credentials: Credentials) { - withContext(ioDispatcher) { - ksafe.put(credentials.server.toString(), credentials, KSafeWriteMode.Encrypted()) - } + ksafe.put(credentials.server.toString(), credentials, KSafeWriteMode.Encrypted()) } - actual override suspend fun load(server: Url): Credentials? = withContext(ioDispatcher) { - ksafe.get(server.toString(), null) - } + actual override suspend fun load(server: Url): Credentials? = ksafe.get(server.toString(), null) actual override suspend fun clear(server: Url) { - withContext(ioDispatcher) { - ksafe.delete(server.toString()) - } + ksafe.delete(server.toString()) } override suspend fun awaitReady() = ksafe.awaitCacheReady() From d9c91fd910eaa913629796275617bfbcd40e1a49 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 12:21:45 +0000 Subject: [PATCH 25/25] BLD-6: add web app manifest + offline service worker to the three web apps Makes the Journals/Notes/Tasks web targets installable PWAs with offline shell support, using the same webMain/resources serving mechanism DAT-6 already relies on. Per app: - manifest.webmanifest: name/short_name, standalone display, start_url/scope '.' (relative, so it works under the /journals|/notes|/tasks deploy paths), the variant's brand theme_color (#006896 / #994c2c / #296f23), and a 512x512 icon copied from the app's existing ic_launcher-playstore.png. - service-worker.js: network-first for same-origin GETs of static shell assets, cache only as an offline fallback. Deliberately conservative - it never caches non-GET, cross-origin, or non-shell requests, so CalDAV/proxy traffic and the sql.js persistence worker are untouched, and nothing is served stale while online. Versioned cache, cleaned on activate. - index.html: , apple-touch-icon, theme-color meta, and a guarded service-worker registration (no-op where unsupported / on plain HTTP). BLD-6 was blocked on DAT-6 (web persistence), which has landed. Needs one live browser pass to verify install + offline reload before calling it done - service workers, like DAT-6, tend to only reveal issues in a real browser. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Xt7MRuFpW1jtcHX1MPC4EN --- .../src/webMain/resources/icon-512.png | Bin 0 -> 39487 bytes .../src/webMain/resources/index.html | 14 ++++ .../webMain/resources/manifest.webmanifest | 20 ++++++ .../src/webMain/resources/service-worker.js | 63 ++++++++++++++++++ .../src/webMain/resources/icon-512.png | Bin 0 -> 40465 bytes .../src/webMain/resources/index.html | 14 ++++ .../webMain/resources/manifest.webmanifest | 20 ++++++ .../src/webMain/resources/service-worker.js | 63 ++++++++++++++++++ .../src/webMain/resources/icon-512.png | Bin 0 -> 41348 bytes .../src/webMain/resources/index.html | 14 ++++ .../webMain/resources/manifest.webmanifest | 20 ++++++ .../src/webMain/resources/service-worker.js | 63 ++++++++++++++++++ 12 files changed, 291 insertions(+) create mode 100644 composeJournalsApp/src/webMain/resources/icon-512.png create mode 100644 composeJournalsApp/src/webMain/resources/manifest.webmanifest create mode 100644 composeJournalsApp/src/webMain/resources/service-worker.js create mode 100644 composeNotesApp/src/webMain/resources/icon-512.png create mode 100644 composeNotesApp/src/webMain/resources/manifest.webmanifest create mode 100644 composeNotesApp/src/webMain/resources/service-worker.js create mode 100644 composeTasksApp/src/webMain/resources/icon-512.png create mode 100644 composeTasksApp/src/webMain/resources/manifest.webmanifest create mode 100644 composeTasksApp/src/webMain/resources/service-worker.js diff --git a/composeJournalsApp/src/webMain/resources/icon-512.png b/composeJournalsApp/src/webMain/resources/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..640aca38b560e2ee7f8d982101088854d7f95bff GIT binary patch literal 39487 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 o8L6`Z_a@*=^+4tSspectacled Journals + + + @@ -17,5 +20,16 @@ + \ No newline at end of file diff --git a/composeJournalsApp/src/webMain/resources/manifest.webmanifest b/composeJournalsApp/src/webMain/resources/manifest.webmanifest new file mode 100644 index 00000000..d6dbf8e9 --- /dev/null +++ b/composeJournalsApp/src/webMain/resources/manifest.webmanifest @@ -0,0 +1,20 @@ +{ + "name": "spectacled Journals", + "short_name": "Journals", + "description": "Keep private, standards-based journal entries synced over CalDAV.", + "lang": "en", + "start_url": ".", + "scope": ".", + "display": "standalone", + "orientation": "any", + "background_color": "#ffffff", + "theme_color": "#006896", + "icons": [ + { + "src": "icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + } + ] +} diff --git a/composeJournalsApp/src/webMain/resources/service-worker.js b/composeJournalsApp/src/webMain/resources/service-worker.js new file mode 100644 index 00000000..8c864ce1 --- /dev/null +++ b/composeJournalsApp/src/webMain/resources/service-worker.js @@ -0,0 +1,63 @@ +// Spectacled offline service worker. +// +// Strategy: network-first for same-origin GET requests to static app-shell assets, falling back +// to the cache only when the network fails (i.e. offline). Network-first is deliberate: +// - It never serves a stale bundle or a stale sql.js worker while online, so app updates and +// the DAT-6 IndexedDB persistence mechanism always load the freshest files. The cache is +// purely an offline fallback. +// - Dynamic requests are never cached: non-GET methods (CalDAV PROPFIND/REPORT/PUT/POST), any +// cross-origin request (the CalDAV server / proxy), and same-origin requests that aren't a +// navigation or a known static asset extension all pass straight through, untouched. +// +// Bump CACHE_VERSION whenever this file changes to drop the previous cache on activation. + +const CACHE_VERSION = 'spectacled-shell-v1'; + +// Only these same-origin responses are treated as cacheable app-shell assets. +const SHELL_ASSET = /\.(?:js|mjs|css|wasm|html|ico|png|svg|webmanifest|json|woff2?)$/i; + +self.addEventListener('install', () => { + // Take over as soon as installed instead of waiting for existing tabs to close. + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil((async () => { + const keys = await caches.keys(); + await Promise.all(keys.filter((k) => k !== CACHE_VERSION).map((k) => caches.delete(k))); + await self.clients.claim(); + })()); +}); + +self.addEventListener('fetch', (event) => { + const request = event.request; + const url = new URL(request.url); + + // Leave everything that isn't a same-origin GET for a static shell asset (or a navigation) to + // the browser's default handling — crucially, all CalDAV/proxy traffic. + if (request.method !== 'GET' || url.origin !== self.location.origin) return; + const isNavigation = request.mode === 'navigate'; + if (!isNavigation && !SHELL_ASSET.test(url.pathname)) return; + + event.respondWith((async () => { + const cache = await caches.open(CACHE_VERSION); + try { + const response = await fetch(request); + // Cache a copy of successful same-origin responses for offline use. + if (response && response.ok && response.type === 'basic') { + cache.put(request, response.clone()); + } + return response; + } catch (error) { + // Offline: serve the cached copy if we have one. + const cached = await cache.match(request); + if (cached) return cached; + // For a page load with no cached match, fall back to the cached app shell. + if (isNavigation) { + const shell = (await cache.match('index.html')) || (await cache.match('./')); + if (shell) return shell; + } + throw error; + } + })()); +}); diff --git a/composeNotesApp/src/webMain/resources/icon-512.png b/composeNotesApp/src/webMain/resources/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..bf1f4b0b204235340622481358417d39af74a245 GIT binary patch literal 40465 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 H_SpXcRc0qo literal 0 HcmV?d00001 diff --git a/composeNotesApp/src/webMain/resources/index.html b/composeNotesApp/src/webMain/resources/index.html index dec446d7..24cb1b14 100644 --- a/composeNotesApp/src/webMain/resources/index.html +++ b/composeNotesApp/src/webMain/resources/index.html @@ -6,6 +6,9 @@ spectacled Notes + + + @@ -17,5 +20,16 @@ + \ No newline at end of file diff --git a/composeNotesApp/src/webMain/resources/manifest.webmanifest b/composeNotesApp/src/webMain/resources/manifest.webmanifest new file mode 100644 index 00000000..67b4b01e --- /dev/null +++ b/composeNotesApp/src/webMain/resources/manifest.webmanifest @@ -0,0 +1,20 @@ +{ + "name": "spectacled Notes", + "short_name": "Notes", + "description": "Keep private, standards-based notes synced over CalDAV.", + "lang": "en", + "start_url": ".", + "scope": ".", + "display": "standalone", + "orientation": "any", + "background_color": "#ffffff", + "theme_color": "#994c2c", + "icons": [ + { + "src": "icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + } + ] +} diff --git a/composeNotesApp/src/webMain/resources/service-worker.js b/composeNotesApp/src/webMain/resources/service-worker.js new file mode 100644 index 00000000..8c864ce1 --- /dev/null +++ b/composeNotesApp/src/webMain/resources/service-worker.js @@ -0,0 +1,63 @@ +// Spectacled offline service worker. +// +// Strategy: network-first for same-origin GET requests to static app-shell assets, falling back +// to the cache only when the network fails (i.e. offline). Network-first is deliberate: +// - It never serves a stale bundle or a stale sql.js worker while online, so app updates and +// the DAT-6 IndexedDB persistence mechanism always load the freshest files. The cache is +// purely an offline fallback. +// - Dynamic requests are never cached: non-GET methods (CalDAV PROPFIND/REPORT/PUT/POST), any +// cross-origin request (the CalDAV server / proxy), and same-origin requests that aren't a +// navigation or a known static asset extension all pass straight through, untouched. +// +// Bump CACHE_VERSION whenever this file changes to drop the previous cache on activation. + +const CACHE_VERSION = 'spectacled-shell-v1'; + +// Only these same-origin responses are treated as cacheable app-shell assets. +const SHELL_ASSET = /\.(?:js|mjs|css|wasm|html|ico|png|svg|webmanifest|json|woff2?)$/i; + +self.addEventListener('install', () => { + // Take over as soon as installed instead of waiting for existing tabs to close. + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil((async () => { + const keys = await caches.keys(); + await Promise.all(keys.filter((k) => k !== CACHE_VERSION).map((k) => caches.delete(k))); + await self.clients.claim(); + })()); +}); + +self.addEventListener('fetch', (event) => { + const request = event.request; + const url = new URL(request.url); + + // Leave everything that isn't a same-origin GET for a static shell asset (or a navigation) to + // the browser's default handling — crucially, all CalDAV/proxy traffic. + if (request.method !== 'GET' || url.origin !== self.location.origin) return; + const isNavigation = request.mode === 'navigate'; + if (!isNavigation && !SHELL_ASSET.test(url.pathname)) return; + + event.respondWith((async () => { + const cache = await caches.open(CACHE_VERSION); + try { + const response = await fetch(request); + // Cache a copy of successful same-origin responses for offline use. + if (response && response.ok && response.type === 'basic') { + cache.put(request, response.clone()); + } + return response; + } catch (error) { + // Offline: serve the cached copy if we have one. + const cached = await cache.match(request); + if (cached) return cached; + // For a page load with no cached match, fall back to the cached app shell. + if (isNavigation) { + const shell = (await cache.match('index.html')) || (await cache.match('./')); + if (shell) return shell; + } + throw error; + } + })()); +}); diff --git a/composeTasksApp/src/webMain/resources/icon-512.png b/composeTasksApp/src/webMain/resources/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..7886049684da0226c5a026e2719322ebc60c8151 GIT binary patch literal 41348 zcmeFZk9{x4UzRSx>pra6?Kp+tG50avvAdqL^zt12@2;iT;E|a$qh&SYe=zArX z&j-H)b(DJ=pwI^$&#Pto=LW|NW)|gLpPz>@)Z~39ly7DHhN6G0-|-C|$E_zInA!>Z z0A8u{**BzbTQ|icF1=&wtybp8{N}~m_MeYues9;QJ5CR>vdZ0ft%Tk}gY@tN^zhwI zp&L-2fB)U@K!9{Gqjk(;aX_9PbqAEew1Usf9De8!@HwEI+ye6SITT$F*8Axd3nU^4 z_>5*CG7HgtdWER{{~zN2>t1Y;?gdPy#ih&S+kZj4HT1;XdpQ=~{ zefP{)8VBRHVzhK{g)@*)rYMt_!$4pb!c2fpHJRg&xMk|=mU3^&lWojBLT%;@hpxk| zR)kUYb2&5&CZHT9oAvR-d@FRd%cI`hMx*pxXCjZCGS94)v%#Ku>l-@cg@V}K>f26) zvFP}SP3v)nP27#qlo)L`7j>^a+^MLaAFYIxA8%`qn$S8D;`a<%GHcctOz(4S|5D$j zFQV6FCvVq=81!%J55f~c-exF%apQ3@Y`JG3%t&&!_FErmedCdT@s~6&VA`~I+Zex_ zuhpDX)-7`;JZqzIdsA+i(&PSblrTrw=9EZu2ZHy9R=b5CA|ZJj2H(LLCHoPjpM(af z*Z9;p>v)t2&s!Sy_3*{D%SIwLAH#ADHbxehyWU#8jE28i40Y9oZGC;uS4xyD#ZT_# z`+2I8bHuMhZoBtV$;(~lHhkTi0Mi?m5N*!PXC>Hh*9)oWbu08U`>JL+y8oPc_LzlB zgH)`ymm;O*G-jY1_!83MMucv@G8o-wqu3=DZ<&}&!j_{B@XTIIT?Di6 zhB+&x;E>t6(-DfQzmw}9qh}8_Rism>`T32wh29*5)l|afznC^8Ru%obWokD2LvVoU z!F48S(RtZR?OMKPEaorQa9%Qk_3h#rnr(po5x|LkIkvLl9>A9ApJ=%vCb(ZR7OHF0 z`mS6M4jNmuNI!4q6s<-aSvpiSiF6-cle!?)^@emq^Rm8dsOy8zATkjo*q2}j-q*n8 zH@QOb^G&KYnmdHcB69|Lk!3xuT8=$AN=iAqzjgG+Kd_1M<>X zxm`*js{^gQ2q*o0pL4XIL7Y1it+z$68lgCk;-ploT{$iBZhXZL7)XRCj!^+tXyczB z`d?N5hA*2~>RxrZ6bMYe@~7e^p517t>xMvO3GoXtQPtSU{*h7N^m^Ol=Z*oc>7e@Cn~!WLZE2{Xw3) zsA4(_%2rsc_SN%ti{`iMl{i{~5q3)IoCjnzYuf7rDeb7yLIEnP*<^RGdosDol12cB z^4n*bH&w?pEQeJ{r{}pkQer;~3GNOMb%E=Z$ItpBmY105?=hoEz`1)B>+5*6jgIvz z;>)|*cZ$`+e9_A|TLbZ52ro1jHJ!bTG6oJXVm`g^GgJ0Afq%K!aJtdZ`)zl`rX5Dw z6K4c#C8}STIEplfV_aJ>lLM?{FlZ;;Vg1zcMw2y}xluT<;qsbxiJuVWQhZ_9gyzSd zF3nXU$GWO*;-oUV*8EJ>nzH0a->>Hdm|8fy(bHB6{FsRs0mb;QD>(YEc4f*+Qy$rd zUec*1D?3z#*vWQzIk!y1pAnY)cUcpXwBx0=lQBrWc*lQaWMuEPr8zs{IqiIaZvGT;?{ zW84xP^V2puKmv1Y3H9G%(0&}Wc1%kNYs_r$ewfsCXNWMWg~h!}|4q}_g@zoipfZV6 zEW+MTgRV(ws!Q7Oz{iHtM)u}A_$6s3ZjriJ3asICHLC;YX2jx=iR{lQPtxlkJL!h z8{z9B`VDF!4b2H3(ADtq zsgDUWJ}df}FlVekyezro!D0r&VFnrI!&)KYru85LIinyilctiAqRfZdkx#!)aP`xE zq{5g8R#sCt*+0tuP&2BE&ap~D6Wpe zHw9H=|CrUtpu83+TweSdI<2THPxb|;J8Q|mCN#*0!Pr2|RsTl);e1fsBju(;@@jJx z6Z&nUR@M~OtL!9~W&jZ}mT2j8&0CRCH_;oHYuADaieD)(xT7eGX4nPP}NK~-Da2&D>-2(1=h5c!UR0NTav#BVZq+49op=T*hg zp64;Lh$`&5HeRI9Ez+YpR& z5Fkbg4|rEObeK$if!J;>C#R9nZf)l1SCvPY<=P!~vZZIA$Hw+0M2lgp_p6;4uOsj4 zjkuH__jMYswvjBb$zG(>X}+v0RX^2hF!-M>95hz-WJi=LF%u4FQSF5rzdkgs%1Pwy zpgx_ZNge!hK+iR!Y)2s{yzAx@t)s3<%{&{aF8@g6{Qg-I>eY|UiL!h8Cb#QwdLHJ~ z22rQc-<+!6n7(xRLFI-xAYE~OC9Vy^7M9w|NwBj-Svx0{)g6r1RLE^yzW|Pe40BB!`DyF|#uADp@^+ZfiqB0rRuxkfV$wn5C?-b-hAv^>XnM@ROx zFL81?ln@vKnN67;XErSbmfE8jX}T|xe&4?nD-o}p(9FKA zjlsZQC3#g_;w?*jj9d9Jj;FndC_G`Ax{KGULnp&7i!NB8VX+Jcsr&9}eQ8L#cczD7Fg6pi+J9n-6gKe&22OVxXo ze?%0C^YUvWJtkrGvZinB7<&P(RXTWYqUp*UdsuMofr$%Owc{n zSvfvCh;Q%ZCE>E)FGaPWLk?b03h=F8yto-xvqJg2GzClNagjtMQzT6!*Twa>vu7Ie zj=TT3PhI1qIR(n%%lNvK=x>|+db-`ue^(|xEW#9utJkZa<tdG3aXiA|o}D+G)1g8)$pjyw`_Ok>!`C@3v-Mwansl2IFa5We zvMRU7;TeCeB!xCFDab(2IfvS}&I;(?L&XhgeFCy;7~N9R5dzlc)`T%x-u2(3M#0n& z$`)1$-8YCNOC>qJhORm`6%USUBMr_*Z0OxHPW5C2D6iP3&=7(5`1y3Sd64bl6gnQa z*S=8^+cK***AH2@6tD@InW_nVgS3%ef;N?C|ZB%>!g z=?&|ysA!MWj=^8p>5pk2OdArkm+b7ebZE2e#q{uQmSQC~S)k7knbws9+^OT938$dP zoX4*G;;JseFdc*jPvz#8vTk;iy~H;2(B(x6_<8>rx^?&ALW=_;yUZuczwI>j--vLIjZ1xb zfv+G#Ic(#^BPOC+*G)$be~iAlBhz>J@t~jG(M4GOd>=oQ?GcNoavSNn&|BnXIHo^; zCCmNOGe2v=I9~_;dm(+Y1JH5EflN0P5TXA*Le5`r3H~#Rl z)Oc;LGL7m@PCZFR@7Ztp5f68Z$Rpx@GJpG{JFS}$^-7rUvp6-k*I+0e#L-NGD!==EaXdI*2SC7HY*GH^Vj_^81dyj-V-hGdu< zl`H(;_391UA=kg$3Vte=znkVd_Pl#0ihEFx&!iuzzNO$QKyLF#wB~~<>hH42mns|b z?C)F*(vGy_ zf!r@NB?Fj@3QLitsFh@sr5%IQjBd`Alej-@^iI&{*krHJ8wbWaLTFaH*hYiok(Rks zod0(kkuV{5L7SSYesecbg@Fz|XK)9Y)&i9ZF42{PRQQk!VYB;lfkL?ZJHZd0b@I_T z@RX3@4)R~21m3mD3&O|RZ1?5QSp=rJDE&^KlD^(;rB<4=TIF<<5L8oWwnu|OJ?is^ zv>NA<5NU6EM>xug-Hm`h9vQ-#f6F)dt~IsPko~9xD86r4&nshmUX~N<5<8=zom`KP zFcr2a`@iD=1(3qo((9Xr0{ODZaycf_0b&8n$3p5JF+{RBjedsSbP%6RscT|V7FVxK zU+A%)eALM3aeW3bmEDRJOpr(epbg%X_!2f*MlPnM7)4oLN$6Det%q%A`@^~DZ`-(f z_-$q`TRrOpL#A{=$vWG~%mSZy5-mIKJqZgK{GGs7m#4%mYt4^`F?UXE=&~-W7%WZd z`qGr54XNk0Qf&+O)=#^SH_y;Samo1z?Sx9mA&Ty6!b2-FDct{}4ttMsPUZ~8z>120 z`$y}JeEg+yt@Pszot?c7I!4V0dR%E4@^I2Ao~S+b$7Nz4VIi+*b8ZTfraEgEE{b)2 zaC0K}d}4P_rhK3FcX^{FXUBlfp~o)nl2rpa6IMEq9(RC8m}ezB5VtYr%}f7)&s)D^ zFgN9$Q&-YkFOraY>Hj&%m7@X}?+C}fHva|#ZV~pO)gazl=|D4lRWF}5LU?ZY z=5A{zd^0G2;cfjzdZiluIdbJ!e3(2~@+)|q@h^6;3a|T>$sgF>CHDU}gZHlle%RDy z9MsNHxXTHsh~3vzl_Ft_Ah6(u7-F`u*xanWd8%@?Q|0^1VtscG?`qFdd6dqU&PldO zb=?tx&-i9|0~>$U_jC~So*>t5Fo=~%UNy6NbE{jfwuOgqg(DF^eigmHe~VCd`uA&; z_N4NgoaBt_dZ@@9(_!`{tLFagtN#rklRMM+GFm5XXltG6V3b%Q2Y})2 z*uAk+@Zf#zSa(|N1)mNTwC5ghc91%Ai0+?p_xY;^&%l2N0RO>5E5B1UYiI{kYq^kj zIG(I(`^%oK?D>FxdGqtPaB7#BK{wI!ezjPpeoJFptiSS3v%WSo|MxP) zAJQ{hsrO%C;=qD&3;Fcuuzl+qhuhTZEx6KaN}zgA<|2l?ATX6Zf0!_pDkDf4cf4Vs zchWO17ILG9!lT*)Z?@55OiL+>#_SQnh*EF%EZ0_a4gB8*4`y=$F|&LZ_IW8ze2Z zA0KH#y7UQ3rjY59rPv4a01O%Y$;+J~ejkR+al1crpp!$Wy46}tyyS1L>Dh=HxIc8l zP(^@9YkPG+9W%;{CoHS^)!tF%!lb#CDj^BK-{%dZKIou+x!UI!6;b*N3P%I~@-)3g z7Rb{8q{7r|8L;P$5BUFT;KAXI!!yoq@O7yI0I6%@d?0|fA$YfUzO{1TC5i9SFL%#x z$Mk12mh&qiHJJ2Ahxj9}U`r*IvThuQf~PSrejp76W6oR{{40)_TNn-{;z_f5R|vq$ zXmSC>U-e;tOf`%mh5aI@e-YLou^ zwP02+otM9dz2114)8mF@$84E;D63CN5;%ga)mDCSdt+$*toGl^O#z;w4efe@hk|{B zJ%2st>|G&zn*SRP$RN0n02}_U{CD?Q?(FK{_mRT;6_Vgb9iHHR;q{!+3C_eb&Yq_| z+%f#jAv6CfW54kU{AqE;*k zZ1iuQpo_g%bDw9N|KU0x5}FM8@DQHHG`l!8F=Hz)DOvgVV)W>{Dw-~;2ehwu9p9;32(~6*#3hS`8Onk}2eplKoQr3$Pcj-Fa`l zOjO*tHh+83d3k{&+2qvrj1-2Aznr>I54{C%Ki`8>eZdYBY5xmYmeSbDyw}!ISXKed z%FZo=xFw)6kbUDWa%0@^HmoDSH||T8@c!gKgibejhA0qmKt4i}pmC(?Qg2eFHjcyp zeF5a5G$3R7zdawNt)e-UnS0}9|6~!NDpaBXHTv1`_zzO}2-$7Wdil(a+vG@9LSQ1{kb9ry)Xp27R5aBV?~|JhlRlg{ji z;#_Fje$W3d9@bsUTk@YMf-n33yA%M<|4G6rHWSZc20)&6vP-XcBLxsx;?%3wCV7Bc zsVEUNm;^uWPtarm(ROuFe{2RvC9SUgHL`u!`>hXfTVPrM5YoC1?9xw^^6&Bo$l3E- zr%C9}ExR}ah{Q4CFZRblzO-Qt?6^E;2Dob_&bojS9fL`bX^16OI@j3b>4EiB?t=L9 z6dANzy7K@-hw^QH-c8YIBDaP6G8GIiN3Bt_X;IW`KyhUq%JUSUSL6yLo>-<9nkRo3 zHk_55W2Hds_&}72@swXZO1sscV1g&r!GgFGTdYL?_e31Hvfa)hxEoDiH1re5nu(x7 z+`Z)%M+fr_#V!mOJIviZbM6FBb0$E3WwKZEp}{j;0T$#numez1DS_S?bBZg+P$;;~lg1u*38Z^)N+l+_bG-Rzo927jLVHe zM6xBBA2fxjT@1*=)UT+}HBX7qwZ`Lb8E*YOwP!90Q?!}IUN-z+6(T-}q*AG?`( z%_zRgEOjb+47nQx#e&Jsna|(5;P-5NUy;DQ4t>;OO?n(3=5>0&N10=bduxS}UIvhz z;S@;?I~#H_QqA|jWJ_~F!1q%;D&LoIu&j%rRK@({eugpcLK~n$Rg|SXzrrX`=iOc- z_o62@vC5EdXpumC0D~zfdHz)4koDLj;vZKY3TWiM_Z7x z2w=Wh*g(b+yvVNTP+%S|5fbid|EIe&Xeu=KVm|HLFdp$Wa6_iRp#Y9;jAu!AY%D^9 z^_!;-x1=*kW*hf21M!KMhYgudBhQKXOKu`LV zu((T5qfa5YR3W3CX;bjZpXZ0=tH>)Cu+&uay7@SiFP}W`VJWW;SXU)EXFBA)<5V8Y zBRmP?vJNX>`QC6*7uP_}p7+E`x_sg{18hEf>Lq`5%5o^kPtriX(aSMeQI_7TEykUyu@9LYMS{(7I|*J1^FbQ2<`gy3 zUo=dJomh*{#RxY8G$g`hYh>lw$J*Apm@ArCdV6(S+aSK-B|c6|GH++_Wl6B2FN^r==aAE zw-6lo!Xgym5&IN{&JuzKH>3Nf1h#DZ4`aEgk@ymT3Ia-B6*W?IbL%qeB5*r&39-27 zn|=-8Fb|hoW~2~iw*RCosp@fbsgANB5GyWx%U)kTPD&sS z$+%U@pe`)$84<)2Vc-W~IGHyGF~45_@lm^tKMt`aEy~h0C2*UHt~L(w*ipSHjbTAp zU9kmGwVdDAGU+Em$$cI|vz!Iab!Gwauq)Gk$^ErVv3W$vrw~&ahpYZ;L=MP3+NGq< zPp(VS<@tqSOKj=SvYF+ zcVpAw=8id=#QX|o#93$LhteO-!)0CxCoI@EJXQ`4vorBt+cdKzNGmL1C46-o(Z!Vb zc_%x^BvXC3m7Zv4YMYU@+PZKKiA!z!majB1h~!E+i*+I?`3F6P08=-%#JTP^3pt@g z!mbRCXX5EcWI9qD?S`Zd{ZM)qE`zkoW8(@H^nDl1qwVD$_G~6%`e9koEWG$*hbl%o z;QYpaF@K&twgybq5n&OMiiS~l;P)L|b(d1a(gVEX`XBN0Tp6E7naGU4DZ`UKe~I+t zIQf*{0&d1n>R*DHUO#_LYKL6zc-%`*0w zh@U}hEgUQYveSX?q~4s@*6^-=s)lDo_N@intI=g={}DHm0Bf!O zj8)eik_Tu?VI^?feu82Jy?;Jg@iK>=4moM7xA;d-hfwy^d*4 zS_7f*XGr)AXrdD}GPvdUXRgHo2{-1xEvy4aE+c_#lxgNl5*>EZWI9~{@c4L)xlvSM zSJ`Qne*X%81tbKiLgO!8Ml}_fpQSVpXR(+=Pi{T)1>4ZdesNqId|!+jE${FpWp%pY zN*U*9dSfX!|2B2pAi2d}w)1jeyOMCW`Tft&sz%Gq&n~s+4SJm*p>+VN-%wQ3xF*3; z(2s%XsslIFGfk?srHj74i69fl&&?MbQfhniSPbFupob-Gu#<1Tu)FxM|5qNmTATd< zNDerMjNU?M4VkJywgk#D7MW4b*-epXthcp^utlV1H`hFuyFldhytW9QY%CIdO=jf7 z%f9dpD@ksm2)5={1j>QrM}`!_f7gZ z_8xAtg|6TCxOWeH4Zl}2a%xZ;1H8pjS}OP?c))~WA-;}-7g4Yg97C-mN6=?)M@7n z+)3!XEyL2nd;-uO^JcK@?a-@}$E2K7g7fe``PQT?(UT5431_@1%IO;kJVggfNC1pa z+~ARN6aeI23zKXb>%C>ZhO(VY@Yo$jZu_=eUS)HSX=`(oAkiBWy_qcPA$ybBaX{9G zarO?s{Mpr+B?pj}NO9@)Z`;BZsD7OfFG>T9Ah>^}M>ovPAaf-3rB_o4esETCJzm*M z@mKeb=R$x21rGmFl^)ts|3{oRp_}I}!BGgUSwrL$G-2)-tiBY|K_6Xp4sI%V-CwJa zXun0UyWty#tVdk9zB!otWgIt0!A1n;9+?wOG&{Y9C>wEh&J3H7PKmk<2wD=%M@bZ* zRbbzHIrrI_gpzs$B19Ep9=Jv`8j3aOJ%$@)d!((`JRA2c$hQYdg3qe)g(zl3PlfrK z@@O$AQ4j%oK{fM4XE&U4NdvWB*}7;NJcl90@uFWzBWu_4xw&uJ>Tx%uT7| zh=^m^!OFWTtUFMqsaMu(WPAxlz7C!!`=0kU`?@yp-11VuoZmbg28)DK(+Td^N&Gm zSAO{lDsmuQ_|~>yvqG;(QK+{%3Bj{$wnU{=D?yofIRi~{Y(haaohBhC(-ne>0@qui!RLW;oj*iScik*U z<3`pQ!>Fb*%_DxRcJ^7c*T%)(X7v8ZO4vW^V-Py4}iPRt>Q{{b+5RaM8S|m^&oAFC&yZ!Gg(gqzihO5L=TIfqJW^gk$A|Pe zj|}7*nhM9jdkH>WQKoOB^czaGjk=Hnz838(cO}TC8a9)4=c?D)maQ32cDf$CC;oQp zpjQsZbqf=1q|Wz&Z{^J=t$b@pdqeMoJRtpe;c(0wIa~lCFyu-%! zx^-ET`Q#wf;wyz?xXJObfV?-5O-UlG-O zBW8trq5U`HD1nyQ?TY?u?3I+~8!J;#5|wWshqRHh{Ti2yd2STTz$K}Vs_bbMS`Mt_ zjllNpaP&g8Lz2iE?wTc7P!>NA*OMBKWi+XD^J{hx|w6?2Ih@t0xFwi8D553 zW6gn7Aa8gas+xQE=bWGTJ{>^iA#E$EDg8eC5_j}1a0pWPRrv0k`zLQs6cZXbf23^l zonP}))ItF>k2Z3$O>Y6=n(jD2Y&VHdB{q0v0l`Nz`U%twxC2E+(0&~R2twHO#IG^` z#y*k}{B(nv2zgNQ-t$}=5K(g2QpUQ|$1#nV3(NQlpnlyg=~5*Vqerk{xpuoIiL!XR zwL#c2crOj`rt&heDVmJmE9i1`0M=@o#3+zbtdWges5sojgoUs|fkxaFV z3WXcRUDDiTmN)G4RxB*HpjyqZ;$zPl12ZBRuH1fZSxb0X{i5%Al0?3q`H@O)fClyl ziKh^hS_75|*)%Q8sf2QWhknqZg{2xjn(VBuxV@Ka8M(Lh91L3CAd3Fmt-Pk5p(ed> z64zjUjxcMa?qJi5f6wm`!F{|udK+!tHK-{NQR-u`02EsBOi{|=2=q6xg>YYyIUs0o zH^1Xa)hTLq;&Q%aIR8CwI%+UBV$(lKoeBtF;o3S)gE?#{Qyjck?)`{cVOrFyvnoga zm(k_{KecF!Z1Q+>!Q$q*7H+tYp;ysx-}VV&FLuQr>RcpemD9cju@zcDiEev<;zfpI zABw)iiTfP1FBax>lSqIZx7`Tzrd)djEZ6H?Czn@O6@o&|KK8XE*M_?~xZ@;l6XaNQ zrV5S51kbl4zVMpM6{+ZN=}0}%_?-a7&93Oj7S>W`SuR2#f*|;8_5)FVzh(RdDS3=V zN4{gjw$aOkD5u0FpXXMuV&x7iu=E4uTXeRrPVOoM^H@sW74tVs9q1L3alu*f{BEQ3 zr422HzuOM#R7k;iWU6ix1j3Y5OLZ>P6e20P%A}c-9dar65ETH>`3f18T5fY)Si1^u z)$d1??4|s|)*ames(1`j-x}=aA3QVeiMDl6C(v=Rq#QL^AdD1uf4~RtD;U?ambbkdiv2C{cT4oWdmq4oc2vX*+!LjqFc`ub{YY}n z*DlMf&H>2A{BkP>mkSm^XT@HPVwuAm(c%*YONtX`F8pf;=(*+GITR#%}w&c=kUk0+QmHush=YjY5(RgA635ty4FMIQG-t<5bESkEgOg+CbBQHaCCDTCNvdu=qf%~B2R95Fz{g-)N!EP^y6FWq)lXne zh^#_cF^!V(?*Zpy9JJ=RH;r7*R@jfW-cv>@s+W2NNaXl}7+ttl8)2q{HkdEKgpjj612Ab)1 z8SDhFY-sA%r-jZC=wHZr)`UK#L@@YVpu=P<=T*1J-GUlg0{nd--cW*mW~H;d4cWHxnzPz_U1?mj{drk6VV>}u1uSq#a#(vHR|uIfldLtCLLB^jt~ zw^NX;eFVCihV6km3?c}L_nep2`;tWgbquAmd{LrT%@h1`#;s{Eln~aTKhI}WK0g&( z`g@wxG4gPQguUc25ebDV15 zf?$I5sRxP3bCS7dwk>C@ksw2rX`*T>t?-+w0fNy_=lNTSH@@Q^)0sc3b(>uIC-+ae zFjInG=>v2+_eCk9@Ifv18lMf4)WAoE87hb`a$KhJF8!*ryiVis330j=Z%d+#di{u) zNhT`-{&TT45098oZ)X69BQKF^4YH>_n?)d~T`Mg;3fxRAGvqK)_gbnn(gJ>yv-J#G zWf{G}(PyXN8;{BSdRu_!89nC!tl93MPHY>gCJ7R+sIq7yGQ8(>s+P!wh}m9B2-@;Y zSkDYp9hv%CUI>JMcT*|Q_%x|E8M12KD9u6zuaDo+fyu-Ck&jsU7`Z?>AeL@m{caB5 zdKv8U^FDhik7uj$0sK&)k8x;WXrci$&M`=d_*S8yAwXJ>)l zndQRgy!$}r_cyNU$oO0*IQp(~m47<&IaHAx01>9OQJd}Y)D>pO`^7aaA=(_>7D);H zz)+_yo_>XKU$EUw2&fG}Si})G3()ZdF=l}t)_*D%gv3#Pw{C8*v?zl4gO#~i`#Ae= zh%Jq~KFV;_7-h$(J)Bl_IH)7r%8ztryqVb@3^1%?UvDw1ZU)6~oYWCe52;+%)t1hU zlfWV43Eh5(d1Xkz}CN+3aO}B>aU}z6^wfikPOo5glW;M#UaS^B{J48 zTl;7Vr;(ctt9ag>?@!j5DflKygOsNBB|?P7=vR=!TU4&7PI18?LK`InO8TdRmX9xg zzz(elf<`&E2=)l(Qjjc+-T?KMW@P4oSlTz{4)K~!KxSZn_qO@)GTZfXEB_j*;78p; zt@GWy=LaLgh#yf;-oqei{*O!dP2-YxK{68D#yBb2+I}_shWl^>7UZ|jIUq@JEQ<@# zHGP&5hGon`GV^@P`9-#mId@62RqOX0*bB#IdKA`3%`dNX7ISeV;>OalAMya4jEyp9 zB~Dk)KK8Z<{sI3Iwiaf4*Vi2pFoo0v zr70#Wiz>Fi&ni{1xgPZVM@dHpj)eVN174WzFC#00UF7QP9BTV%2vxFjre{K`9Djp| z7hL3e<|glxy6v2M2s!|n?|e?QPXZ(Ho~py46;6pPk?yo5nu=unoiS{R+Gm#;#BUa( z+UWN*50A^>B0QAw;kh{e%)!n?qz`@n)!BML2SvC>(ACrhx$&gd*hg;{BXGeMU#OHG zrWI6F3Bc~gZ8~=-p%Fo3y@#=gfXhiazH(XYsZh4iXDI4$RC@qCZB~WW;VX3S!{CA!gYG@#lp@!bPTyyupo1 zg0_yUje1PXMZ6+Dg1{g>hBm(tpck-Mt#wc^WbLnprx6}JDd5>F{^b9FzH)ugLu)1T zq2fTJw5GeWtdXP&HM0R2_^El|ioF$Hu*{+);6nQ1`_6owOf8=oc5hyF#sHd8mYGiA zqf)B=&1W^o`nv;+r)JZyL;HKt7^Ia0j8ViUV?M498}sB}V;&-drJ4i>7;utSYm&a> z4D=}HgBswT zD1rgZPdmjC=)x*D-xTTEnKrMwp5}oeH1w7JuHv5V^;G@s4vHmj?TtN78^k~rjyZ@N z&i3$B*#XcEQHLydhp4Fx`|Y;$52l1b(jW|!9aOJY3a)icS4_;p3qKN5&X1>>2ZR&^ zE-%06nMR6RiMC43_Km+%oDDan2=3frc=igjQkqM={pwkz=kjwUX%|vZj>gQsV?zbj zxH{u6IKf?Pua{5mSFz+`r)|;K%1l&C-pa7eeDKS8%IF5PZRLjL>YG5be`hq{$6b36 zv|pre_^OFxEaOW);9li`UegKfLIP|AWs?^QT;>zI7Xlx?hrmEEf~lERxOrx=q~WSJ1_(beS|91W{_Y|BvLVC* zl4taks7DkrEV7(^9*tz65+M1vfunUaRVS_P^L896_ZYg~-g<#^2JgpsQ!#GUI>mpr zTf8$d@J=}N;uH__@W}LO5iT6fX>XlNBhYa5Z_dv^Hh>JA*QRDGwqImIU5^(`_v?q) zbcjYl=11i&-TX!00*isSNzKnGt|mIjobN@AS*~?zfqCG^xiT1e7#qF? z7oP4OU)TX{Jp$rz-=G1n`?E1YvR(8aOL?Q;I}AMA9_!o7u}ChQ)Jyz)d>P`t{}S;_ z-(muitMqu+IS0^L7^Hjs0})WqSIIy7lGKKGCvxSg&UW4v=tae{S$7}^!Rx9(A`EZ` zUg!z6SvU+N%Dg1xOx#N#>x-&wofcXEjYY8t3OMv}V?5DL8d=eC3nZC==-zfh*E91k z3O$~$4_4%?7C&Bklpv9Js)!EA+fQ?!Aj}ft191{S#rGzVP~$HAV-|spPF8b^1Do9R zE_=%rP_A}_^oX2mo&2)TE^<)PO6L*s!5pg=y1lo?29(c$bbf2}$No|jMWWXKZhH)f zn7VM`AKfNXkZd0mYh_4>s*p zcL^!ws~|OcRk%{mFGao+Yk+aUCka;}2{JPQ5*x{-1x;S2_7i3s1FY0a&QE9+lGDCB z$XcYAZ7aAPv>Yv4(QtT7QZAYQtVPhmz-)SaP8#JQPVMwALm~tDxGN?52rlicMMZ*UtW)j^% z-t8Fy>R#`6t;;4``ycE1%hhy{^#UbdG|||?J~bLfZazM-;XO%=6h1OR9F^jU6X(u9 zsClpaXDatxX^RrF1?p)~jL|i@dnrZI04Kc`;(dtX^!!VGCQ(G`_vb@)3;K@$hHOAOwYk#pM)g>*2`~S9u1V$c*ZDMtT1r(RM#z_>SHGQJ4=oduCm}v48_iOK^jGZ~ zHBA>Y33JoU2^U{ja^BC(n?8>28xv~ixs(AP=U_1k7#DzrxHN+5MQ4Bi4k?yH`CaB9 z5zlYZDpp#7YMykS9-h^>@Fq$3h9r?N>aYxSC9idm3BnqifZlr`m0(oS43?f{Hs)rs zkRi{Iwo)84o`pmTX}wc@G>CnJW`e21m)5()THV&zTyn?-ix!}~uP4((y5{xUXyeacjd#>B@FNb#eb2@%jnd_0>!|yjBOzJ?GzBoON zY=PWXDZp%0g;XEC5Y9?g%_c=C7li(af}yMiPBsh|3#b_YV zxZL6P?{20If`ju#-(DHa3H$@@0|o66I5Cvd6VT&_+h?=5E4ivVt}ARw*zku_p>lD! zz4-t%j^zWbW`;xAeG!=6^7ipB$_%pE_mP{D;O`PVyuMO7C;~N=PT5f>p#nx+7cI`^ z=8Fm0iIMY$-T4q!-Rd-u#Q=?NxVwpF;Yo*^0#;wNq>rEql=0p%vM8vFR<3shyV>Ap z$pfWX>K@gncK1c698r#T zpi}t=in!oPBQ01`!ygXsB~R(Wt_p?*k+c)D@003nMVzo^O~)<-Q!*zgV-|^u#9?Mx zEEhUaD_P&^nHkjbntT!K6ga-agg;AHr%wkYN8Xy>d_#?a^DG4ag1c)Ev{P2gre3{J zPknL!8mMZY2#Wb&*46>ifdv)R{qvmhl;sS79xln|Oj?OcD?ku5QR(A~o*4OZy85MA zVh#vfWb*}R9jG5Fv!KS_i+_l05OW-_ej%Z|h1tWg(Ovu1B$YD;DiEO|XPrZ^aYO*a zkmDf-+G^(CwSU`GL*@)`u+-JS0po}l6OjK1EmH&i0D0gqCukGd2F$-#2F?9_`tWV3 zx0-jDB`|IWZ%9MBq~Mx1iG6U&^bc$Cg{0|V{|GiuhAh&X;h<2q{yv1nd=_xngb?Y{ zCqI!IsYcaf)dajC4MOgx1M>{i*d#6`;-1>%p97g zGGC<8yj+n-c#ZBDcKiLU1x5x4^<&A#yPyL#d{~q}pP~>)|r_OGnkce&{A{%ZeLV|kBjb7`)_65ht;q*vV+~H z$@k56WhY_Ow2U!K9X7uQ2XKcy&Z@a!OYr7a>QWG;apy}HKOcl1qC+MXdgucUt%v4_ zc2#I}zFc)w2>yV-5yn4}%SplV_k9vl2%|^6ogspS{y&zkIV_X!?LXOVZFXC`o3Yu} zX4`GHttZ#E)i-RmZQHhOYqNW&-}V08)tNc-%$#%IADzGYD$oa=uc~v%=`LGkW?k)^ zP8W|l6;|&0eOqxZ8D#}64WIoW85fs(*x(~niUIs#TF!>+UK4$V0Hj8u`%ejqw=+gV3(ut2rI zLrv*ndPprXRPY(dP|)Il9X@b{#q`(kvh(*+Pl&uNr_2C!Bf$HmQ&k5cuXNJ-LCdYc zkBD_|F8hdCHkeGtHvBG7bFbJH#?K#Jz+(Z7nHCu9LJzoJ?E8Anmhup|WD>b^fOwk4 zM=6@J(=cu`k;B|E)UGma;sm}`X%@B7jX{Htb|fF{9}ZSz%-p*(6t}VlS@Ac-Glwo| z19j9qSAg%yaxgg~C9ijy`Qh&d<%W!P?B_FyKLM3OuD>ySLBg5MCh(ih283;FgB-(D zgINYNunNZP$eGj-@A+F(!maI+Jxo!X*L_o<3}6~|G|cgT->pjlbE^gOh()u@R%i^M z?z(YYTwPIFdwzV1Zvcj4xL{`)Au&8R?F&&V2z9afi?50< zqRf({tyc=W>)ALBYua9Rz%~%qG+tqO_4=;|tt03o-3}>*@U)iV=gD9G zo5jroq(6ox;nA#*UaC;C;6<)~EIa#WrbjGh8vJYj(jZFES6F&7`oI@J0*ZAr8!((X z2w8J^qGWxA);ValF=yfTWF3Myu--{7pr{K4Lx8aY0r6zBRbXP8#WF47cx1X&TbG%5 zaF2-+{?VTccsHMW-N~LwEW!lNAeTTiyQauR6fJxZpxD3t$;O6va)IQZ-i%NJSr`X$h#Woxh{#@(C=6 zod%vEg91PEy5BC+Y-!Qf1wjx7M6G@&{6WL_JH34urgCjh;(|G*BN%vqFXhnE3FNr9`|CdOY zmgGNU=B{XwDax-)|E+}TRHi}LvAzCYgfm%^0!lYbpj=xZCaeOgU+ewzqAia~aG}|3ngv^+y1O828%0Gt|^8m-G#Kex`!>v}Z7orE~J<1bMI$ z7qFvyNhFK~TKfUr+tG4}PGvj<8XEI3nmBzz6Lij}K8xME&CuI|`KcAiiTa35`*y&4 zf4(?+KnP)NT6;72#m@A~r6Cq7%SCVt&IPu-^~ydCBJHh<1`cr0Smgtfwn@E{x7oT+-1-DiASoYhv|b;Fff*dnUaj$74?>1N zk^y#3u~D|)fFLHcAP*OvMXK)NtM!gNK=#a);67zLak6gzJ)b-+V3lXXQ>)AZ}X$_<7`s{dXw+2kYJjj;6 zg${Tl$hWwC)2~O3NxsnNBLQ=WOQ&V79;Ir%02;r>Q}2%LLB*)+dErEKxc=YsL>tsj zsdEXaoa#byBhq<5tM24Wqol0(^}R@HIj+(FxKUvVy#$289x@nWv?Q&5ChS1f%zi2GlLiP$pdS9_@%8&fsyUlexDzc4{$ChU`=7b>EL=se4?KuTF?M=#+&xl z6bVg1W?xLbR7(T*0PpUbTf=0vN80>!S?#r=N3}DDfsR7@ zUN#zI8N$N`Vhqso`4#qXzPG~-MS)P$Pzf3D@Sa_+qmCy<08)W!&l80^7`aF?hU$Xy zK7HzNvd-pVH2b>7XKLo5j_l)GZa=}jh73?5N!fQIaVLyn#_oY|^Z06__YF)37pd1~ zNSVsdig)rkVa`qiuEBB7ILJ+~3GP8yG~9Erm9-F{gY*H|@k90fsWA$6kuJO@nK+n)NEO7YVzaXZFAhszaKeG#Vs+* zbvBF(h*d3&n=Ira`c<4gJdH52tMdTGAtEC`+VfjmiXT8 z5M2&DkpCH~#~mLTgW-KZK=8p-TI?eO;OtJ?|NWQ0Fh$>*@s!di0O zfIT29#5thkWPC65Blw3PpaHDDkFo4WqinIpH7j@3@@q*%eR#w1hb}N5D7?e)Hb$uUAPb{%E2%|@-6Mf=kS)Sd z&UMVzgf@e#1aX#-R`h|-WA_wHHhD`^2nYMGoXS;F|Nv7YB6n& zn_rez?+0}v;dhv)zMQMG#n!-fzIZzx?ohnrMGUHjWV5Q~ z;c=g7a#4^3(oD9~{x#k!mPeDxB1+a-_1iq-`0%0lI z$z8WP4-tdwgNA~aINcmZb=II60?;8a-J>1$D4PT>pji^PKOpp6*df2W_ft-Y?uD0{ z05V(-%a^P+TTTIWg2X_?q`2>i!5!3;+~9%+F~HyFo7$!ihZroWOQF(b!|qrfEr%!l z0NPGNdLX_hegkVHSV*Y+*}!X_QXV--iWfO7bXNr^ z*x?5_YeeBmhvXLT%$;txusXywyr{lH0~VvUbB__IAw4ENM|#lw0)FML+KhKBmVpIO zax?+3gtB4Sl$pU@j8-)H`$Q?CvbwfJ3j>E5f_z?A5$#9n;fJ*f@bc*_x7<;L_WDEI zQ9j3bar@xZ!z(;0wC`UXGS;vXfN0Ya2sI^8DAXxfD~nM4`T^q?fQ|0C{zAyQzE@j} z(+KLN3n?d@t&6R`6h+V{k4s3BEcsI=&qYLq9QVUgJiMEmv0EAQJHn-SbVVDFPx2gK zPfU=d)DNov(tuNAXaThe6osO?&NLPP6i=4FrnYBMMj8XxIpk)`%&S)Q&F(g{@arM~ zBdxVPAobqAH(#25h)dR{k+J52B#<77hjXNU!QDaVA3+~%ts{A;f5-2fh3&kcfzz6Q zCV}mo7LJgfsw)0!$_!z$XF?SNKZpg`Y%v4QY^9#VYMZZkA3v8$bUxl$J>eOfzE(%S zDzryk2|z=MLsSk=0}}fGxqsP=#H1}jUi?LTA1JyHdx8Q$t0n^`%Ct$7TH1rXQV73j z?zvacup*Gb*$ku^F`~lJog6qF8IgDXCV_f?SR`Fesn&9vPx)Dn3$nWd95D1G;J)ku z1`-88QRigc|2uIWgj?vH35YuXZbTa2FkR#wnz;hy?K?&ZB&Q|FikE&Ymf;~LVZhLmhbh0 zZr1WyK$Bmxt}?LN-aC{-U81VO2;!tQbBZ^4jz&YmW?NJX?_kOI0dOgN8AEV~4O+gT zP*$0BlVI(&S@Oe>^ozr{>^Zg@5Sakcn90qvZ1tYeDDv$SSL%Qr6Oc^=~ho+UZ_k67&x@b*s)45Men=@CB+J*4wfUjYgb- zF_}1_qxS?D%TuI_D=fK9x}9JJuFW@Q0DavaFzlCmmeNtyfgE?X?cDHiq@&KgwvSPL zOW$--@UECDhyrc@XG=wigE4Gf0_k&pCJT$k5U}C@7zo65Mp2PIc+vfzkoPm-1q=*W z0CMcE_-lsIkn?)A3Q~uYL)z?V<7-MQR^(=aq5@W7hqD1=f;M8}UK}H_JIzx+PU0{~ zpn%VM<(HOw-^3!iB#=~#WiG8TegyCk%8pjXI?q%{>nC*~<-E|>qJ3TP{fp(k9q%cz%R)W<(PP)p}!KlZFJVN%-N~rV3ZFp)Z zqr8X(hE%JMBYp#qC1VbQ3~&UZO+z1$cglpXB2xHt%N=$or^ zrRlh+7T}M)e=GBX#~#U&CjcGQ?y==@$kxXdpONuXFHg-GEb*JAP3We*1-EKxMnt0@oABYy#^qW3RZj*p#$H`jK z8Ms5~s1%`!tJ5@i3P4;77}5VQLaUDt#a~tAWXaXe+2t}tf39Fb-89%Fn)=U-bfdZb1F#Am zeQi_#5Av9yVSEVC_zJsXHcD7itj8411sd113xRuYB~j%-n$Z`-$#)0-+J#n|s|oLH zpbLbRuu$roY zE?!|BB%pJpmI|i_FB*LuG@E<0QlMy$;Ho-K3AC&h82j3)or=-R7$b}8y3PR9AwH$A8M#J_!8R7YMx{+`AJejj@M+3WER*qi?J`u&N&mdEulbSL;fSr z|I1#sq>2K*?w9k*L8gYylg}TF31D;sI_heNtXdl1#0+Nu!Nv!?`mBIh{Q91~Lh*Ng<}q1lpKU8`!32c9=bFQi-qHd8>Xt47j6)|G z*pp7Rr>U^ymqCZ0m(oAH@BBt9h%2B$&5$lbz_y)=zlhM~Ch{LilQkz~028c5P7i&I zJsNDPS9`eMk4Y;@VGH)0_(Hy5+K@Li*q>JXU#QdojY#teZG>vpnAt?1&Q(@x@$26^ zMIctqX+aTtvHJ4%wyG5P0?Vf-u{w^HlZq*=L7mSe?rR$FSX>Qkns6^b8VQ*>!bnwR z#)TFA14Gy7WDBP2CjZo%9jo#X_ByJjlMJ{HG)| zjV0)Xr3M9vJXZFpug6L4X1wlU?xyZ^M@4V~wpZ?7P1m|xd7WmR{}Eq#6VNFat@xTp zG4AhPH$%P=|M?TzAm001fd>0XZidoK*v~H9$#`B*5>Q2mJQP<1#w{&6L|5eSSiq_5 zw7%2!1P%2A8Q@N5GNN$+F!8i-KIA(*)3jn*H(p2aKaS|%78z`cN)_XYm`d=oPsf-wUA^MfC0$(@bn=1V zhGhF1IlkJ3z->A5?2yCq9G=TFQboT$Ce4TZgYz(_5wkm1Nqi9e#*&LogjU*LqQIq7 zz(de`idnVA4uFz@pAm{ z0yl#|AS{s98%G%jXuX61O6?G6PQp7X1AzMZt6}256(DRk4+z<%XU^Zii>`9GJJ&FL z?!@VK2SO!;K2V49JX-4aTU#Iw?C)ZQan*0^@4o;YBDPrm>YcLiPbg4L77!$`docVI zHQ9NWcZ$whh%VV5=AVk83c;upw~|uh^yic`TanM5>KF0~a!HhUM4R{HYC2>F1TM^4 zHWxyVes6IWnF4Az(bj=qpjP>>F`7?-0tPC>YVq9vWDOFxhA zSDHSnxiUae`oqRNpz8wU(-;EotqP_x7APnhrS@=Xzb{^ZZ{c5Qs`F(pbr+C7g?t^W zUp1^Kr~vVy3R`&ln5@3%wCbAx5xmrt*0|N@8a5}3L4iI8TG|GqBLY-?hZaXz+6r>) zjTBfMrtmHX5NrR31+N7b%U_s!`zYgy(F&)XaWMPvbQtD0bSWl}_gRbOuO+n)8HEPZ zo=f59VLXtIi7Ofj+elS(I2I8ppuJD}zRL0aJy7Kj0Qm0Q5wrge3Dw!^j@U>MDkSY* zW;EmE?zv@cr02*bGdvIuySST0zxd=Egl|N1QeMQfxljq1*gqZ`jpSpLUeqe8iV!ET zP1w{pM2@wasC+3QgoStN4)qSkz~PbC_@e?SK%dN@{b0zt3^%tv8-)~-czHt+>vYO3~?7aiM&Opo%k2@BAs)^iOIaZI4-Z8ko4 z?s&JZndM?Ij_cng{;-3P)_lyi%)*U}ri_MI zUFZ{f7QVirIH9Nx`FYk_PQ4$qT=${1-S)9P8^a%j_xKsutzkxHSwKZKRcYA6?SUJowe{qMYVQ`4A&zm`@$lnvZ;-$vFsbbw!ed2Hb1HK#2Pl}mY zV(0tr_4V(T=<6H9vHH*inLKT@30#e*p$TNX*>DK6U@DU0>!w5H{p)7hIaiC$tE8YE zzIs)= z>NXo9NHQcjO?7XKhrwe!-*@C%B&d@YhioWErE+`8?tgs{=t@OF9j}sjwE7a)BJl!(cC7Bp%gFcjT(bY0 zwqKN7`1>po?XvkNdZhu4GJc*JRAw#v;W&K)4#6Ge)6gbV<5G0e5KPi8J~W9iKA3^& zKb5Q_=}H}I5>zT1soS{Udi=Xs>$@ZCN0y$2^pi~)HOfnuRLEy z&1FKNyHv+FxCZQLj}x8i8iOD!uMQE184?I}$OS&RKJe2`+Kw&{@?q2_L}%L7!%#6zda)ri<;??&0Nh z8~R!^$C64tFQ&AHcuZXMPskB9+`-EhAmG_+XAb?P`vE`P(;t;EI%4!!9tuwO1&xqc z|7_XYr$FQx$>tMk^OXrsnQWJ7Znn`{3`EMT8=U}iuRWp1PH!1 zzf*w!bxwHPYb4BZ-2=p7Y@8Igpfo7kEz;r?LT@wKDC>su9eu#sErQH%hL7wC-4Wc3 zc^7wHCprmX_s{?F=An=Lb{f2Sm&q=0z`8Ovd4_LWgb@6O6%M zu-a{^TXV#L6xU4zBz+l3f}m9^_2%Cs~_PGDZ_~A1n0%bo9&yD-QL+!8xM3lgy-DyX)mTP9gVfOb5=hdkTwgXbYu}^}m+?^yVirfD> zOf6|XIqlQz3hE+5%_!3+k>Fm5!#$T@($|8?zaUZd8u%7G)xM~r-Rv3jwt5pH?EZWQ z4}O&wlmV;4*g_sHiW&ECP}3`c%72n3&-gXniQ2cH6A5I7iG35Te%Gk{lo}Yb@jfPW zE#xC-)0G9;nzu{H zicm+fTi-mC(=!bnL*9Q2eLQdsCV7*0Gg_Ri=z9?<&+KmpE%;Xmgyrz!NMDx7%}Rcs z#v@wOHe0>~ka8e;Y$TrX)re|mgQVBCDEN4B{FHPktYaGL6%4lc?Qa_~5sRTHuMyaX z>t|BRV70lFE^Vfu5yK%g2^x~;1DBy$p2d^j$uHgs-sgopFIuOby-l38IixOEqmBK8 zRUM9TT8We`iQ0T}ZHPIvfUA%ut7X6IK)uM#vFi96!$#Wme%9ybDBE^Qb9#o1)OasB zUkX>p$yF{7)9UN`Gf0UBtq1xcVrr8*JqBkQ0X(?@2I7(lVLP%*q8_i(b{vToJUR^o zG%0~KH?7Cu_|mqYF>}SqM%=)uz~rPEjVcGUU##5l?|FZ+BB6^O!%dn#T!H|n{A zRZwdHxf-e?Y2s5l?KEp4`58}*%rP_vJf?19^UieCbW}<3VR}siBHLR9_D!aD(Nx^q zCL%AxunI{e=>sjii)e_n^E!u8YBA`g$^bJZ1Li*SR0eNsjk~4aDURO%%v^YzOQRJ9 zBxu9vokO0;t_7W`EV~Sxf?$F89h(DM91)xeSJYTolQsUuG)xK0kL3*W@}9fscuzJn z_hez2sw^Lp;ZV2k_z>}^(*&&$&WGmUdqG=JKfN&wa?|UvIPo zbi~uO;%FAI!~gsvo}I3TG`3k_GJzn?RXS-qeqaNXS_8M2+G9L9xDcS4#Rk*%gIn)+ zlK#~`ztCT;JW;5vDrK~_NS^qau(<4)Ejy8=qUv!;sGaL2g#QQDhm#;_zrJy)gIF?& zn8EbZTX+?!*a)SX)LBZY4dJp#X?iTfqzh}C)V9O=ms(XAzPtf0kKgc<|KB5J!UC0qytkkNUK-s&$bSSVvx zES~!ov~q9+2*7SA`L#wLb)i=!sQ}$JqXl-EoO;Py&8Apq@%*>S0GmiwtyEyW5Ri}# zPVcFzCBK<_Id8r*lQiA+z8>7qMZw!Z zL+&bkQPR;H!=AwcrGAYV`Fg(*>-Q5;(7z4bGM|X2kA4#-Znu~v1y}1zM-L%rhh>MC zHA6V<0!RNC92^X2PI2x_NHA8Bd(9fN+5L(TL5OLY$*G&}`T{_Ooz^_|s(7zk7PhBj zE3G|YVlIf58j)AMTB{S=R1Y^T8B;~>2J7DEKHCW#o+YgUk|z}Ll1m>c@nwSI8ZYML ztt`$&)E4Mf=5l@wd~ybpCW`p{Y5eg zYoywIue~T!k&B-*0QR80E3f$zYK|xX)iGfvISX2<4mJYYL>w`(nD(AjW^BBqa=uveJhJjFCS&(i*ofiPpr^M?*_#{&dE zX0y$Di*-5e7t7E|kjyU6Gmlyah1=FmPjg_>MUd>PSuvaOfC%3$J8*;@JMySZ*R|lK zvseF*wI}n0MBvRJYwanWKeoka^_y9co4!;LKGyyZdl*WVx={9e-52OCw#}1)B41xg z_;(yBrB)3AaDGIMnvrP6>KtXRG9ibG@V*LXQ`LMsjr>C!!tW^iI@QIY_!?q-okIpn=&9OQj|lEg!n;Gx zy^~zxQYMdu7Tt@0vfFGlJ~_VI#Ak#@FftN2=fiFTwyQ-jwr*nJoqtW!ycV|{Bp@sb zL*2#hhv&R6^<}PnN?Wk+|=ZAm-Mk0_owb0oel`q{R)bXCFz9T>PJ(JU#vq^!r zgijrP3L_hQUMpNb+c2~sDxi~*t;itt}!Aa~#<1t1q#~l{emJ&?nf=#Hn z4tJTRpb0b8-J2*{i^Uhfi#(>oWlaBo4Y=E~-V?207=eaFN?4>6v0c=}g$qUJ#~|Z3 z5Yoo|2%JNw!BPs-kS%sAYT^^kx~jZ^`4?**^zk1*R1QEEMVZL#)UQ9ieqZ>rR?t%| z+=XT#3V3(;&P*&H$y~~yRR$UgW<`}+0NKZ6Bno+-JPe2rDRmKg1IFJg6hdJj7#(u&s{71Qn^qW&2i@u| zC=Uh`yFrt^oo?D}`oQUVNT$jIfrW;tvlwg^hg%G~NnA)8&Kpjd2x#4_d|++WAH@@H55`1&t@10)~^txkWG1XD!BnU%@Fbbo@LrkZ zVPw&0Nz=zi_XUEf{?~sDD}(hRf+Qz@Uh-p)P+%!w*NN+n3Q+($EcG8{R5bZZv%#x4!nEo_GTNSTKj{j*vk3Ub zLcbt_MqBwyadX!jNEta$vSNmSV|JX32F$v^E8Xu%$GjZ`V}Fn{TPAYJ^YrNlWt-gC zgT7m*bjnOtRxRv4fmuOqjKm#oz2<&kc??|g!it zX@ls{LCyQe4?l$UnF5aLr1U+HJ02`G8*S6oVW5Sn=xO%#7i_U__q|^GW8ZQJLW*lM zmcp~?MW$!K6#uO-EJF7WZ5nUPo5>WH;AVp|dA~Vy!ep_941r7Pwe!DC z!7MIDzUV0?n;qPbo}xWL3R({g^#aPA)l z@je5X%qCx>at&JeJCLK-QveHooeeZ=t^E&HO*;o+>MxS0IbZ`Phq(GhoRS9W2hvcj zu&Mr@-ZI~*$w|T1uobNYtj863e7j?y6BD!xv=P+9Und+ifB{Xtn|~@Vyr+g|^Cvt4 zAOwY!qcJC=kt8tTnT;Tk`9m>DFEF5G_|d}u-Y7UAUy2WG(phVBGReU( zBwd;tbZ2ro^RGg&^^pZVe^DJ$45=oLi8}WJoKLRWB3OK`)t`)dD9>{F6WL#n9qz2r ze`S>NVM``0;brq620O5F$8wF?ln^-n2^B^JNgM7o{8qV8LOX_5ECPV|T_W}sr4TF|lMO|bC6KDc1zhXd>cabGKJ@e49zzhxp(BfSUPM7=1aB{iq*oi9VG(>;Y2|cH zkIq;Rj|T&zdwuHFV$i1GMJ&v8#erWsdE+iYuTNcW-=MqXC*|Q&_5poSk(wP& z3wiaLpxsko_ivW;43<1n{RL$g0)b3i(lulVzQ=Fw+T?JqO%Z-Y9PfG{$RZ>DPU|i<@f&l#@~fSiaj*Xg<1DA4a<*2 z%}4IjHA(UPcli9vR?5Gq+Nqo7WFG+&|Mg1Ibn z)bfVnU{7TQeq>O<$jxSu!pp*#&zzayVo>Us!ff87bo}%w*;Hm+pVUBmg^sQcBD8ka z_K4805&nWl$$d!6?$Sev_y=Ko5z;B8W-uX^z}$A4SoJf|Nr~fqnmIbJ;a+ux(Ah_O zJV(xhSa8d;-j|5?dfEPAYz|1>IBI)&-RWY07OIngNBS92O0l}s_pcPKrSDdKAIfjc0RF=;8XlXL3UYDkyCW&3s=tETn?X@=pB4?v5%O`OcK&ZJrba9h>XgzFuR|R-hIv(2aV? zkqX*sWQhAGbNAEFvK`FT>cOF|h=vmKEe2a}`X0Kp3a{V;zeVENE&JJ5^9IPvTX4G5 z!cFZ=hv(w)BdFy1P(aR29kjqeeA0;qkpVB{>j?_}5 zj`#piEDQJooKcPo2`=tJbr3=J!In;xft1FH_=#7 z=ErwoPTQKy`mmu&h|4!?+Mq#y(qO5i3gitb6_iD9;(?+SYD&``op zGjmeVIqS&EF%lnRuwj__n_0e_e3#fF?m7`$OnGuKIQ0!?xjR^ z<_5C?rAn{9Kj&^(vRDeryxCkLD407z0x$TV`fIclDE)u89PGUQxu_+x97>aRCrx^V zHHjuBTu%=zbVPL_<+8|Te36We21LnAi6MLW4UOg!z-^OTts&KLMRJm#d@{JSy@YdJ zx|nS??``RMf+fApvb>&$=$^EwqY=BsQ=5JMdtx}xNfE9Ol|ck$BMYO_%U96A^Fc{l zAJ&GjsQ*IKcPwUq)5}K2rF2VBx74>c=dZz{<83_5FyZ;^`W1feU;Y)!!hym;ES+I2 zzSPga=Bm_S8_qp zt8D4j*EMsN0M_I0P(9>IZ+GEV1;6+`)!m-A>5S|*`0U;b26r7HV=Uv=qY(okwBImr z(M80~~90m>Zo-O@BwY_iJq2iJk zh~SftRumvEogACL7b!saQGKcl?)yanKMwPqmlDoQ3(2x;23)Y5|C|V9_|5<;wG+cV z{hGT+iVbhmPk@3jvh1D{PjX`A?qTaPt?Ot4&hNE727H&QCx1nfBmeu3`3Gje5z64WO@rw64m-1*d|Bjszj1TyC6C;3Ym|gEzve~-K zcjPYu9iHzJ1dq!GHPlS&XD95Z7{f@eS`LR%-r|`-V3ecw}{xRAUTH^h6z7e z_4J9-?eL~UKLC$(eUEAiIE@S()WuFc8ODqza+#H5B?+yTXW4>_*J>5);xj)E>2!y0cR60pus%VwGW9^0oeL=o+qR zM@!B+J9p)vsIpWZIPeqB;dmKAm20j_U#3_Eq`!`(^xwh1y9@TzX6MKCZ$2S2t}3<<#dd|W0`#Ki8byNncGDkgDmh|af}P~{jSBI#OFkQc~q_m^8#yJDD@wm7lEC1a1r`0>%!Cr zVA*OMH$?c=5QlOH+NU?Q$wYVp{1te2>n~>FZv_nSvYeM0+q1!>qK$Q#$eEduq@aiHnRC6Z$G{)pMT*80 zo;5C$T6GT|z*L+e_xIWeY$mfR$>sG(x;8duh?BF)M=CIz;3QCj*a^^8-yl~|1TqPz zyL4JphIMAqegy`9g8J*#W8$U9y~a(ZK1qj!i(T<81lxX(8(I*t9R_?a-|yD*&)G{6 zzv+&`g0(En>d8g2rYN#-qC5R&VZVyScS9R7LT~KH=R+8O6Ls)OW4vR4j_NVDl4t>l zl*?1kqE;Ek5j~W;Ke*%M)aB3MK+*1BR{mweIV!^z(rC0Jb>MV?zitpDps_IeRX#b( z9aRMYv^{sv{DTnFjKt(O$qkA93mV^+v+1W2ysVOaQX_IIN`Et*4JZxra;^G}=O^@m zg8b8e|D9{=k07zVqFaVI+NIRpkfIyG6tojxbJcim3LNvHX2xL(UqHOc&q{=xz@4yIoVpN`pjm(1*bB zMjwUw&V)JF^^+vXsgf~kB;|=uB$XoVpthjN^FV(0QRk+WQjIKwm(c2k@h5rV#Y!|^ z^F7@*PnTFAKG}6PR9(P0cDNLOKg1~0z$nzwl-}4yxhIYa@T5+q!c;rN8R6Lk*SQAf zI^}Ed6zU!D?fK2mHxkLyr~cqHZGcVv8}VwzbliV&0%yp`-TR1Y(@>%XClkZB{^lX+ zibqCgC&fkL3XzRUEcSd;*&~Mm`5OZ|nYjyeLXbkg8#)~Qc@A9x+m!1vSb5_@X)OZH zp5NaRlp^ue!$Ms~siIf5X+r7)dltj~z5Jm4@bm1!8#vnt{%fG8iLV862S0)UJ8T3& zX1@Dn45Z&x{2>iJ2kNxp_!>`7kcj;KI#J0<*y8ulp-_$%VMU|0MAIO-rq#0tIwMvX ztx}70%20yNXW=1R^^Hs?sa8LuQ@(~+rQSJH>zF7^P~xxf+!x!l$7h|qdNke~$A)o> z0FLlxSeIyJUIuh!pA-V#5GLaMpI(gAv*?T_g{x+C~eO{%eI+eOiJHpina=#$i{+Tc7q%sS?PAxwU5^?!*ycEU`v%Qk+9P57Lb11U>H`rI(2EQGyOf zzkgFYgW^m0TcPWqzY)IpjZcGV76(QUY&Q1v_KOVZKN>%7*f#o2e$|brP}kwsLr$mT zCj<}GOXdAEB2pM7?*#0}Oi(5_rnEWOx|Nz?t9k3>^StC!`X@x$)WfFeq_nOjBCw-h z-C5W5vo<=0ar)dn+4dS(m*o0x{Nsd;)A9tC?q$JwJPW46iO*Mp5CYdEKi(HmVF?vg zquZ~5V!j#fm6(v>1j2dJ1^wj|*XwDpw0_ z5#I_nYub_Zg~6ZkegG;&rgd4ej?4C@JWj5_&x>)eeusQ61Ryu|{_vEH-lRyLiW{PB zjRlYF5$)MN5gpsvMA%)v!j~7`brahc|JT}izr*phVSKIC+lnB1OIXn&Y6OWci5ewD zTSV_IdRsM0B42{&eGx&Tuig?7CCY~AAy{3AZh4RY;oYBh=ep*a*_m_Aoab}j_j#`D zEb~}vSG$W4QxX@I_N>|HD6CF#bH3lIa??A4=ZitN zv09~}moCn)OvDWosDh1@bv7XPS^$o+wYe+Hm7w`Y=i1!7-TgMV6lvGH#RMYmbKY@E zu+ynVtZAg`9R}wM(&}E~5_w;3dgr22lD*!d0Mk&SCG>h$^gF`kvZb*%BgM^S73UoX zf5A92$xIM_MztYgYD`pR?zl0-lk+`o$6|4)F!Ne>6X61l0P&|i-2-{is{A~pIgez? zr^CRc=vRcVYAPt&V=ntEr8{Lv4PUpwEhXpys#$9;*;C~jr^LiCwK{Me9pH)QV+ln? z-y-L__}kDW>G;xYzl9ysDwk4pRc=96+C6p_NW__HRQ+;{l0BFjR`}JAg`idV@0|XU zF{w@xnc)yriJ*k7%uWi#97mSRyJ=rbaB+T}zLY*KZBzJ-k$DxFe5Bt?D?Adk1qt)6 z$+oK>$&wRmY_}2hD@NCNlvRV;-mLrMQUV%bUl07a3Vd#Tj_(J zfZV7Pp*$ZY(&Xvuyx*5ktB9|E)N=op=rQlvy3wXD=pYbWJUO26p2ifRCMT)d z6K#-+Yt`I&7xEez(kkNE6arZ>lbo7nGK{3~q?Gn)Aq(WXs&cTBu6}M!Glk@NbV%;s ztgo_PzcCry8nK41I9iaJce_PGV;(o_oaP=Npaj3q29FI)#I^ZLpQT zS5B`CwS~5C-zYq&{S+qwiNj|!zRTjj*@?wQ98nVezSwWRlEvd5{rWzb`Nf4*Np8jG zcb&eO#fQg7d0M914DUFj))n5$jEm@g-Q>HYRvdR};co5j`S24Xf^Z%3@>zJO_-+2D zcz#qOCuTZX>p~iyr><{$N_&7NMVW-DhORf_U%1TN4$$Rh8-^XB3qGR)4LB!9=w=83 zCK%O{SjFaYjVoFjb7c--SJz(e0r#l)-t)yX%z@P}x|tglDtLxa{KzpLc8lRwl;ZC7 z>b@?i1a;h)l-5YB!~@L7K!^vY`XFjrtvb6cKETVp;#784CCS5!(}$xWE_&Wr;~d48 zWb!W|mGx9Q3Qq@Wpd9jokneLT9{KTMvHY$M^c`|ssS1*>roug?EWb-jqCX6eV4*gy zeR1I_fNN@>O&%}zi1=NT#_ecM?T<8G++4E6oZeH|MZld&;vv3K_Z#~h+dc>o8B-xF zZjTSFZSdXG95Oyxs_!3|9m6@k1?RAS^qP=|G>1u9OT&wqT#X8W3k zbdM+fu)Q<%s4;8wFBcG%Av#En3PlJCEuajy7l*=^ZTITQOp(MAtG5c3c8LLXJkkvs zI&2`r8xLw_Ui>P6Um3y zmB@*=FOSBKEtAs)4O z3-2zy+(fY=Y%Oo?sv>MQIJI~#mD5koUefp9W5HpftD_O|wF7J+u0EYG;;uf67`R^4 z%r!dmi~>fVMz2c|x|(y5@0YLE4Io0XP0hks9s%b{Bm#ocCTw?mYQri1j12L;>fLH zT6Q2MrQ&Apo-_h|b(n&-?Mn=}D#%1M zk@i&quk}q&O+3ilBPW2u{Of+f>c7C_D$KlFEoZt{!pm&$dnXi%b+Q3(g|1HUD_B+P z%E(ZXMxz?=Jh~+DS%8PN@C%bXRxi5b4dJ}Tu_XHWk@o;5n!sWC6aq}y7*RA^3Rf8bY0S|kKnLraD&`U;IT1Zr-rF741sO=qDo(Ymy4zTGD?iFVXiSES$neWCf!C55Hpra-%mkSbbe z!vGEIKi|i#6=NN$x%+#fW{p0t>vN`*fizc0|LHrfm)g{G6Q;)ItMO zQbb_M{e68gsQ~I{o;8ApOH!8#e3k5Ia2L&fD)SxgajiF3qg{FKD&`sGDP=bt6vxn` z0XxCWJ4Jd^Z9WwJrYpQr5u5r2On$QN#!V46@`(prnOg(Wj~?P19a36^e}788xRXdk z{)%%v8UbycRW7t5E&%e z#2(dKRdScn`%vk3$yw8p{aSwSxTWF7o9z!v6_Tdglg>!0Zkf05vlW_$w~8!&o~r|U zbdeiFu*%z2p7A%C)cvxBS(4i%8w}SuUww0TQzmfNgBIEbkOgF%RF~z49>D#s5-icbG;0Ddl1HS z4NvtB$+1rY1SgBM(*In>c>1lGmj^r=>nytjJOXqU=0vj_w(=oL;}1!k$Q9q8+C_(- z&X(b^P02<4w?xJth@}vnt^9S+ zZk0c8w_l=&kF3!SKdbGne1asImAiLaTCQtrQRv|#%Gliryp|df!wp3K5UhVY8O0|q z;3*@KJEiWceZYGj|8?nOR_1yxv#Kt5$)9?#IUlae1+k3AEQ-A@3$-P$MlIm4EMD|` zu*8nTG;%G7ZOu7jBy$9Ru`atlp*3_!^ICqqP% zo$skKmc+A;=W6XVtI0T8Jtu1)y%}T2^p){GixUJKcWHL`Dn4U z=s}CsDfhOZ@SLVZOUm7KQR$5oilhuC4_6Yv9ZQ=T!H%Oz)=C2q*OdF(Zew7ni;uTC z;2?(lmLSFCg{vq(jQD|H2mt?5C?WRsU}Ollz*0qu)gdVuw_c;QdSCnMx<6zePo|6< zQ1b5j?hH(egPMAz)TbR+i!X;>uKqLA)7)@wSebl0hr@0>haE-RMImq6!8E!Uo45Wk zluMbn!)3V7^a``LNAD1}M1Ys}T1YMr%D)1a{S08^hW?}-M|pO78_vYc=PguP>XzuD0g zQjMTCmki@s8q|l@XHRJWPP$QPVZf|isYch3DAg+wPDJ__f^C9wN{0v4lu8vpQ1|NR zKi$JW5x=|1HybR?eP^G0a%`;Afz}~;eaA}~LlXQ5U74#RVk51yP5zzSVRw%};*FH% znsNf=Q?|sF9p86XbQ!dH)$pAyM)Xj@h}CT;sA-)}8^UJ957i_thFTd-e{P)I`*7Ug8`6miT$&x_v)P=o9gwslHB>g}O?UQnP%rZQMTf zp*J!{4*vCCXpsC&04LwNX|AawVl54FHoDD@&@KI6o}S74QJF}Unp-cSDrGvX-vwWn z^m?+v4>08k^RlV&QDiw$;0D50b9XW-GK0#IzdO2|qB&Uk%#Ta&50}l+*wY?=ArW4) zI}YynT)nYLZ59kQSfGB0xuL~ti#h{7Vl`hphJ2ePN?$e`AP$c5B_=L_2BmuI^yyQs zyVCurm_q8sKkQUFk6|h7! z6CPJ{BbdgZDQM>!mB~r#)unOMfeg*bnr)nYGJyYyuAmTmgbB|1s9Hbc)fXO>;-q-C z@=h()_@lq2X4j&jml;Hi^fk5Bk&?@B0kBY+Li*lE#lV)}L>=87WTZ`B>W}$oQ^Xwc zqEOWT0FyvXb)kDS9>>+Lz=#5Qj zYUO@erg;>eelx)zv4pbYCJ!S)`9WYgmYq-emcQ;jxN3ELIu<#|7|$^PjIQHzH%KVR zwCK+CtjDW7NCiW7@1MHhpZIDTR97$gvo(1d;hB}fIHb9FD!+)U1pfJVpjVRFJorVO z2x2{mqhefWRd}y5qvFECUKFUq_jr7}w~tN)NF+>?+-&lRsz4zGb)3Gl( z(zRLWChpfAry^wwAbtqMMGconwyRY+>Qw@ni)zu`RifOPqUI8bPE>ftg=&P#?#y7# zzwWpxian!k0*W@K4%T?yJL^6h+oMp~EV_J7RpG+qc zv?AgYiTJIbI2p#q7=VR0DxYO<$~MpL+M4Pp>WogO9+Qx+6ChhMEyLkiF<|IWK8`53 z>FC05e|)q(#cfAypYU1s8|jJZ)U!$XKOm_`CV?ldK!^o(+Gb-k=ZJVCGEw0ZMgJ;C6; z!BN~GF+*JP#5E^DHy7IYCYFPp#I`q!pRF7p)V$ly_z6=e@nd+mlfc%1m?2D4bRi)l z#UQONGDLl0t32dkF%d*$Di2YGgg(gz^vCGI-NMD|grRPiNmV`*wY<^}3q6+NMtKvr zDn`tPGdhI!pcI(SI8yR_WwXhT%{i^3!LEFmQ-mHq;0=MiONy&uXj<#tXL{p5?ECG> z&0GJh$gLk#ki}X2W=~)jz-5MKuwDILBuq+nt34w?4um*$&#*De#7>K3iuw{waFT~H zLR`-4Up~*$eKKv3&Xmiz%p4L&U^HO$Utw)jc^P7*o5c4q@ane0bDEu!wE zC)#m8_H8|3&F2w5CW@%ysLgn_Y-qZnhg>@ShYW^KxW)EW6_Sim`Se-tsWm#?rQNR_ zZQe;JYv`|?H}XV{Rm5}`gB0vf%La^oEHvlV*hO!jOmn+q^IV~Kd1dc)F_&3_Qtd}Y zA0+eklrde+ZEnT2Zrf;4NVrM)r}}S)>C>-xU08Z3+dsIVmY%wdB1ss z)fk&LV!1@-2L9`k!WRwp|k2CH$;pbVMc8=XMM@x1T( zHU;X^?u%RtR`Nzl#Yb1&I$ryH^5hec4e(p~7v0Oi!k6+iy^%_0PFsXdo{`r*s3<67)KIL8|cR2t-P>&uGD9J)3k7K>=AA3g) z(x5*UO*4W%j;y(+$oxN?tE{Qi-p-NV*rGoEn74ETE0I2MGl+(DW?z;1SrCnW+st=z wY!>=p)4Qv}70CJWv7o>P-xebN9~;g?HDDq6~wib&M|06S!8qW}N^ literal 0 HcmV?d00001 diff --git a/composeTasksApp/src/webMain/resources/index.html b/composeTasksApp/src/webMain/resources/index.html index 7c910c90..68c94617 100644 --- a/composeTasksApp/src/webMain/resources/index.html +++ b/composeTasksApp/src/webMain/resources/index.html @@ -6,6 +6,9 @@ spectacled Tasks + + + @@ -17,5 +20,16 @@ + \ No newline at end of file diff --git a/composeTasksApp/src/webMain/resources/manifest.webmanifest b/composeTasksApp/src/webMain/resources/manifest.webmanifest new file mode 100644 index 00000000..e4e9b408 --- /dev/null +++ b/composeTasksApp/src/webMain/resources/manifest.webmanifest @@ -0,0 +1,20 @@ +{ + "name": "spectacled Tasks", + "short_name": "Tasks", + "description": "Keep private, standards-based tasks synced over CalDAV.", + "lang": "en", + "start_url": ".", + "scope": ".", + "display": "standalone", + "orientation": "any", + "background_color": "#ffffff", + "theme_color": "#296f23", + "icons": [ + { + "src": "icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + } + ] +} diff --git a/composeTasksApp/src/webMain/resources/service-worker.js b/composeTasksApp/src/webMain/resources/service-worker.js new file mode 100644 index 00000000..8c864ce1 --- /dev/null +++ b/composeTasksApp/src/webMain/resources/service-worker.js @@ -0,0 +1,63 @@ +// Spectacled offline service worker. +// +// Strategy: network-first for same-origin GET requests to static app-shell assets, falling back +// to the cache only when the network fails (i.e. offline). Network-first is deliberate: +// - It never serves a stale bundle or a stale sql.js worker while online, so app updates and +// the DAT-6 IndexedDB persistence mechanism always load the freshest files. The cache is +// purely an offline fallback. +// - Dynamic requests are never cached: non-GET methods (CalDAV PROPFIND/REPORT/PUT/POST), any +// cross-origin request (the CalDAV server / proxy), and same-origin requests that aren't a +// navigation or a known static asset extension all pass straight through, untouched. +// +// Bump CACHE_VERSION whenever this file changes to drop the previous cache on activation. + +const CACHE_VERSION = 'spectacled-shell-v1'; + +// Only these same-origin responses are treated as cacheable app-shell assets. +const SHELL_ASSET = /\.(?:js|mjs|css|wasm|html|ico|png|svg|webmanifest|json|woff2?)$/i; + +self.addEventListener('install', () => { + // Take over as soon as installed instead of waiting for existing tabs to close. + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil((async () => { + const keys = await caches.keys(); + await Promise.all(keys.filter((k) => k !== CACHE_VERSION).map((k) => caches.delete(k))); + await self.clients.claim(); + })()); +}); + +self.addEventListener('fetch', (event) => { + const request = event.request; + const url = new URL(request.url); + + // Leave everything that isn't a same-origin GET for a static shell asset (or a navigation) to + // the browser's default handling — crucially, all CalDAV/proxy traffic. + if (request.method !== 'GET' || url.origin !== self.location.origin) return; + const isNavigation = request.mode === 'navigate'; + if (!isNavigation && !SHELL_ASSET.test(url.pathname)) return; + + event.respondWith((async () => { + const cache = await caches.open(CACHE_VERSION); + try { + const response = await fetch(request); + // Cache a copy of successful same-origin responses for offline use. + if (response && response.ok && response.type === 'basic') { + cache.put(request, response.clone()); + } + return response; + } catch (error) { + // Offline: serve the cached copy if we have one. + const cached = await cache.match(request); + if (cached) return cached; + // For a page load with no cached match, fall back to the cached app shell. + if (isNavigation) { + const shell = (await cache.match('index.html')) || (await cache.match('./')); + if (shell) return shell; + } + throw error; + } + })()); +});