diff --git a/README.md b/README.md index ec00e72..9da8e30 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,11 @@ and `@zennotes/shared-domain` packages. The exact archives are vendored under `vendor/zennotes/` with their source identity and checksums (`manifest.json`), and `package-lock.json` pins the complete install. No source checkout is used. The vendored set is the published desktop release -[core-2.51.0-core.h49d73b531d346192](https://github.com/ZenNotes/zennotes/releases/tag/core-2.51.0-core.h49d73b531d346192) -(desktop commit `8ff2cb86`, tag v2.51.0, clean tree). Run `npm run +[core-2.53.0-core.h598c8d004c9228a3](https://github.com/ZenNotes/zennotes/releases/tag/core-2.53.0-core.h598c8d004c9228a3) +(desktop commit `3a622639`, tag v2.53.0, clean tree). Run `npm run boundaries:check` to verify archives, installed versions, singleton -editor/React peers, and imports. +editor/React peers, and imports; it refuses an archive built from a dirty +upstream tree unless `ZEN_ALLOW_DIRTY_CORE=1` is set for a local try-out. ## Architecture diff --git a/android/app/build.gradle b/android/app/build.gradle index 9806296..5898230 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -15,8 +15,8 @@ android { applicationId "md.zennotes" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 26 - versionName "1.1.23" + versionCode 27 + versionName "1.1.24" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/android/app/src/main/java/md/zennotes/SafFsPlugin.java b/android/app/src/main/java/md/zennotes/SafFsPlugin.java index 2dbabf5..fa820ba 100644 --- a/android/app/src/main/java/md/zennotes/SafFsPlugin.java +++ b/android/app/src/main/java/md/zennotes/SafFsPlugin.java @@ -382,40 +382,17 @@ public void rename(PluginCall call) { int ti = to.lastIndexOf('/'); String fromDir = fi == -1 ? "" : from.substring(0, fi); String toDir = ti == -1 ? "" : to.substring(0, ti); + String fromName = baseName(from); + String targetName = baseName(to); if (fromDir.equals(toDir)) { - Uri renamed = DocumentsContract.renameDocument( - resolver(), docUri(tree, src.docId), baseName(to) - ); - if (renamed == null) throw new Exception("Rename refused"); + renameExactly(tree, src.docId, fromName, targetName); } else { Entry fromParent = resolve(tree, fromDir); String toParentId = ensureParentDirs(tree, to); - Uri moved = DocumentsContract.moveDocument( - resolver(), - docUri(tree, src.docId), - docUri(tree, fromParent.docId), - docUri(tree, toParentId) - ); - if (moved == null) throw new Exception("Move refused"); - String movedId = DocumentsContract.getDocumentId(moved); - String targetName = baseName(to); - if (!baseName(from).equals(targetName)) { - try { - if (DocumentsContract.renameDocument(resolver(), docUri(tree, movedId), targetName) == null) { - throw new Exception("Rename after move refused"); - } - } catch (Exception renameError) { - // A rename promise must not reject after silently changing parents. - try { - Uri restored = DocumentsContract.moveDocument(resolver(), docUri(tree, movedId), - docUri(tree, toParentId), docUri(tree, fromParent.docId)); - if (restored == null) throw new Exception("Rollback move refused"); - } catch (Exception rollbackError) { - throw new Exception("FOLDER_STATE_UNCERTAIN: Rename failed and could not be restored: " - + rollbackError.getMessage(), renameError); - } - throw renameError; - } + if (fromName.equals(targetName)) { + moveDocument(tree, src.docId, fromParent.docId, toParentId); + } else { + moveRenamed(tree, src.docId, fromParent.docId, toParentId, fromName, targetName); } } invalidateParent(tree, from); @@ -428,6 +405,111 @@ public void rename(PluginCall call) { } } + /** One provider move. The document keeps its display name. */ + private String moveDocument(Uri tree, String docId, String fromParentId, String toParentId) throws Exception { + Uri moved = DocumentsContract.moveDocument( + resolver(), docUri(tree, docId), docUri(tree, fromParentId), docUri(tree, toParentId) + ); + if (moved == null) throw new Exception("Move refused"); + return DocumentsContract.getDocumentId(moved); + } + + /** + * Rename and insist on the exact name. The platform file provider does not + * refuse a taken name: it quietly lands on a "name (1)" variant, so the + * caller would believe the rename succeeded while the file sits at a path + * nobody asked for. Such a rename is undone and reported instead. + */ + private String renameExactly(Uri tree, String docId, String currentName, String name) throws Exception { + Uri renamed = DocumentsContract.renameDocument(resolver(), docUri(tree, docId), name); + if (renamed == null) throw new Exception("Rename refused"); + String renamedId = DocumentsContract.getDocumentId(renamed); + String actual = displayName(renamed); + if (actual == null || actual.equals(name)) return renamedId; + try { + if (DocumentsContract.renameDocument(resolver(), docUri(tree, renamedId), currentName) == null) { + throw new Exception("Rollback rename refused"); + } + } catch (Exception rollbackError) { + throw new Exception("FOLDER_STATE_UNCERTAIN: \"" + name + "\" is taken and the file is now named \"" + + actual + "\": " + rollbackError.getMessage()); + } + throw new Exception("\"" + name + "\" already exists"); + } + + /** + * A move that also changes the name. moveDocument keeps the display name, + * so moving first parks the file at targetDir/fromName and fails with + * "Already exists" whenever an unrelated file holds that name there, even + * though the destination itself is free. Cloud sync hit this on every + * retry when the desktop trashed Untitled.md as "trash/Untitled 2.md" and + * the phone's trash still held an older Untitled.md (desktop #813). Take + * the name first, inside the source directory, then cross directories: the + * only path that has to be free is the one the caller asked for. When the + * source directory already holds the target name (in any letter case, the + * storage may fold case), travel under a hidden temporary name and take the + * final name after the move. + */ + private void moveRenamed(Uri tree, String docId, String fromParentId, String toParentId, + String fromName, String targetName) throws Exception { + Map siblings = listings.get(cacheKey(tree, fromParentId)); + if (siblings == null) siblings = listChildren(tree, fromParentId); + boolean targetNameTaken = false; + for (String sibling : siblings.keySet()) { + if (sibling.equalsIgnoreCase(targetName)) { + targetNameTaken = true; + break; + } + } + String travelName = targetNameTaken ? temporaryName(targetName) : targetName; + String travelId = renameExactly(tree, docId, fromName, travelName); + String movedId; + try { + movedId = moveDocument(tree, travelId, fromParentId, toParentId); + } catch (Exception moveError) { + // A rename promise must not reject after silently changing the name. + try { + if (DocumentsContract.renameDocument(resolver(), docUri(tree, travelId), fromName) == null) { + throw new Exception("Rollback rename refused"); + } + } catch (Exception rollbackError) { + throw new Exception("FOLDER_STATE_UNCERTAIN: Move failed and the name could not be restored: " + + rollbackError.getMessage(), moveError); + } + throw moveError; + } + if (travelName.equals(targetName)) return; + try { + renameExactly(tree, movedId, travelName, targetName); + } catch (Exception renameError) { + // A rename promise must not reject after silently changing parents. + try { + String restoredId = moveDocument(tree, movedId, toParentId, fromParentId); + if (DocumentsContract.renameDocument(resolver(), docUri(tree, restoredId), fromName) == null) { + throw new Exception("Rollback rename refused"); + } + } catch (Exception rollbackError) { + throw new Exception("FOLDER_STATE_UNCERTAIN: Rename failed and could not be restored: " + + rollbackError.getMessage(), renameError); + } + throw renameError; + } + } + + private static String temporaryName(String name) { + return ".zn-move-" + Long.toHexString(System.nanoTime()) + "-" + name; + } + + /** The provider's current display name for a document, or null when it cannot be read. */ + private String displayName(Uri doc) { + try (Cursor c = resolver().query(doc, new String[] { Document.COLUMN_DISPLAY_NAME }, null, null, null)) { + if (c != null && c.moveToFirst() && !c.isNull(0)) return c.getString(0); + } catch (Exception ignored) { + // An unverifiable rename is taken at its word rather than failed. + } + return null; + } + @PluginMethod public void copy(PluginCall call) { Uri tree = requireTree(call); diff --git a/package-lock.json b/package-lock.json index e164e89..fc5eb77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-android", - "version": "1.1.23", + "version": "1.1.24", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-android", - "version": "1.1.23", + "version": "1.1.24", "dependencies": { "@aparajita/capacitor-secure-storage": "^8.0.0", "@capacitor/android": "^8.5.2", @@ -32,9 +32,9 @@ "@lezer/highlight": "^1.2.1", "@replit/codemirror-vim": "^6.3.0", "@xyflow/react": "^12.11.6", - "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.51.0-core.h49d73b531d346192.tgz", - "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.51.0-boundaries.h18d39d9887df8897.tgz", - "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.51.0-boundaries.h18d39d9887df8897.tgz", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.53.0-core.h598c8d004c9228a3.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.53.0-boundaries.h193dbe157c4e64d2.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.53.0-boundaries.h193dbe157c4e64d2.tgz", "codemirror": "^6.0.1", "dompurify": "^3.4.15", "function-plot": "^1.25.3", @@ -3669,9 +3669,9 @@ } }, "node_modules/@zennotes/app-core": { - "version": "2.51.0-core.h49d73b531d346192", - "resolved": "file:vendor/zennotes/zennotes-app-core-2.51.0-core.h49d73b531d346192.tgz", - "integrity": "sha512-WaJLtt9Z0+0KNhMqwL+LAX9fG6JJVeZfHkJjUiL9WyHFBLLPGi0MumfSiGeKpWHabNE/pIdiP1RoU19kNyH7JA==", + "version": "2.53.0-core.h598c8d004c9228a3", + "resolved": "file:vendor/zennotes/zennotes-app-core-2.53.0-core.h598c8d004c9228a3.tgz", + "integrity": "sha512-OdDukylM9XIVAQZfjEvxqAbLDTBUd0HpIHggRQsyegFpHTbR5cOL3pTUdmv3k56f3S22SegMRqQCs1BQdoUJSg==", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -3699,8 +3699,8 @@ "@myriaddreamin/typst.ts": "^0.7.0", "@replit/codemirror-vim": "^6.3.0", "@xyflow/react": "^12.11.2", - "@zennotes/bridge-contract": "2.51.0-boundaries.h18d39d9887df8897", - "@zennotes/shared-domain": "2.51.0-boundaries.h18d39d9887df8897", + "@zennotes/bridge-contract": "2.53.0-boundaries.h193dbe157c4e64d2", + "@zennotes/shared-domain": "2.53.0-boundaries.h193dbe157c4e64d2", "dompurify": "^3.3.4", "function-plot": "^1.25.3", "gray-matter": "^4.0.3", @@ -3744,18 +3744,18 @@ } }, "node_modules/@zennotes/bridge-contract": { - "version": "2.51.0-boundaries.h18d39d9887df8897", - "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.51.0-boundaries.h18d39d9887df8897.tgz", - "integrity": "sha512-ib31p4wDQvcLj+DcoEi6mq0T+1aHXL7b1MOkWpBCtQ9DkiXkHLQj2zvB3U41zc9znWe2JIXgdM6IzHVhvtd7Vw==", + "version": "2.53.0-boundaries.h193dbe157c4e64d2", + "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.53.0-boundaries.h193dbe157c4e64d2.tgz", + "integrity": "sha512-HO/4BploqBPtJ4Lu4/Wg+YfXuUxHF8RIzqPaA6jAnh20p6zporqtiJbfjQAtu/nCPqeCXgXc3yk9fr/s0ehvKw==", "license": "MIT" }, "node_modules/@zennotes/shared-domain": { - "version": "2.51.0-boundaries.h18d39d9887df8897", - "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.51.0-boundaries.h18d39d9887df8897.tgz", - "integrity": "sha512-uocbc9KXy/7XB7MtBJWEG4/8Sod5MJ27MyInQJbT52OJl0RSuvJRcXf4cfnfNvIwVg3pCX8CFFPdtVAoujg4xg==", + "version": "2.53.0-boundaries.h193dbe157c4e64d2", + "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.53.0-boundaries.h193dbe157c4e64d2.tgz", + "integrity": "sha512-Uk53Icfq7IFasrxNrJN+tfGFFb5FaOgbb99IehRAmJZgkXG3IDW+RXsO4Sh3E4EqwTJk/sq68DCVmc1SNgbYtQ==", "license": "MIT", "dependencies": { - "@zennotes/bridge-contract": "2.51.0-boundaries.h18d39d9887df8897", + "@zennotes/bridge-contract": "2.53.0-boundaries.h193dbe157c4e64d2", "lz-string": "^1.5.0" } }, diff --git a/package.json b/package.json index d4ae5e5..4a69dd1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-android", "private": true, - "version": "1.1.23", + "version": "1.1.24", "type": "module", "description": "ZenNotes for Android — Capacitor shell over the ZenNotes app core", "homepage": "https://zennotes.org", @@ -66,9 +66,9 @@ "vscode-oniguruma": "^2.0.1", "vscode-textmate": "^9.3.2", "zustand": "^5.0.2", - "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.51.0-core.h49d73b531d346192.tgz", - "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.51.0-boundaries.h18d39d9887df8897.tgz", - "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.51.0-boundaries.h18d39d9887df8897.tgz", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.53.0-core.h598c8d004c9228a3.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.53.0-boundaries.h193dbe157c4e64d2.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.53.0-boundaries.h193dbe157c4e64d2.tgz", "@lezer/common": "^1.5.2" }, "devDependencies": { diff --git a/src/bridge/mobile-bridge.ts b/src/bridge/mobile-bridge.ts index 260a664..2ea1309 100644 --- a/src/bridge/mobile-bridge.ts +++ b/src/bridge/mobile-bridge.ts @@ -132,7 +132,7 @@ import { import { folderForRelativePath, posixNormalize, sanitizeNoteTitle } from './vault-core' import { isPhoneViewport } from '../viewport' -let appVersion = '1.1.23' +let appVersion = '1.1.24' export async function loadNativeAppVersion(): Promise { try { @@ -380,7 +380,13 @@ function mobileAppInfo(): ZenAppInfo { description: 'ZenNotes for Android', homepage: 'https://zennotes.org', runtime: 'web', - hostKind: 'android' + hostKind: 'android', + // The WebView's user agent names the Android version, the device model + // and the Chrome build, the lines a bug report from a phone needs beside + // the app version (#814); nothing else here is guessed. + ...(typeof navigator !== 'undefined' && navigator.userAgent + ? { engine: navigator.userAgent } + : {}) } } diff --git a/src/bridge/mobile-cloud-sync.ts b/src/bridge/mobile-cloud-sync.ts index 15d2687..2e1cf71 100644 --- a/src/bridge/mobile-cloud-sync.ts +++ b/src/bridge/mobile-cloud-sync.ts @@ -173,9 +173,18 @@ export async function getMobileCloudSettingsConflict( ): Promise { const parked = await vault.fs.statOrNull(CLOUD_SYNC_SETTINGS_CONFLICT_PATH) if (parked?.type !== 'file') return null + const raw = await vault.fs.readTextOrNull(CLOUD_SYNC_SETTINGS_CONFLICT_PATH) + if (raw === null) return null + // The parsed copy lets the app show what differs and offer a per-section + // answer (desktop parity, #816). A copy that does not parse is still a + // pending question (the file is there, and sync will not touch vault.json + // until it is gone), so it is reported without the contents and the app + // asks whole-file. + const cloudSettings = parseParkedSettings(raw) return { path: CLOUD_SYNC_VAULT_SETTINGS_PATH, - cloud_path: CLOUD_SYNC_SETTINGS_CONFLICT_PATH + cloud_path: CLOUD_SYNC_SETTINGS_CONFLICT_PATH, + ...(cloudSettings ? { cloud_settings: cloudSettings } : {}) } } @@ -189,22 +198,28 @@ export async function resolveMobileCloudSettingsConflict( ): Promise { if (choice === 'cloud') { const raw = await vault.fs.readTextOrNull(CLOUD_SYNC_SETTINGS_CONFLICT_PATH) - let parsed: unknown = null - if (raw !== null) { - try { - parsed = JSON.parse(raw) - } catch { - parsed = null - } - } - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + const parsed = raw === null ? null : parseParkedSettings(raw) + if (!parsed) { throw new Error('The settings from the cloud could not be read, so nothing was changed.') } - await vault.setVaultSettings(parsed as Parameters[0]) + await vault.setVaultSettings( + parsed as unknown as Parameters[0] + ) } await vault.fs.deleteFile(CLOUD_SYNC_SETTINGS_CONFLICT_PATH).catch(() => {}) } +function parseParkedSettings(raw: string): Record | null { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return null + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null + return parsed as Record +} + export async function listMobileCloudBackups(vault: MobileVault): Promise { return service.listBackups(hostVault(vault)) } diff --git a/src/ui-mobile/MobileDrawer.tsx b/src/ui-mobile/MobileDrawer.tsx index 23c542d..b9fdbe6 100644 --- a/src/ui-mobile/MobileDrawer.tsx +++ b/src/ui-mobile/MobileDrawer.tsx @@ -10,7 +10,8 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' import ReactDOM from 'react-dom/client' import { getShellSnapshot, useShellSnapshot, setNoteSortOrder, type NoteSortOrder } from '@zennotes/app-core/shell' import { getBrowseSnapshot, useBrowseSnapshot, getBrowseDirectory, requestCreateBrowseFolder, - requestRenameBrowseFolder, requestRenameBrowseDatabase, requestDeleteBrowseDirectory } from '@zennotes/app-core/browse' + requestRenameBrowseFolder, requestRenameBrowseDatabase, requestMoveBrowseDirectory, + requestDeleteBrowseDirectory } from '@zennotes/app-core/browse' import { useWorkspaceSnapshot, openLocalVault, pickLocalVault, refreshRemoteProfiles, connectRemoteWorkspace, connectRemoteProfile, changeRemoteVaultPath, deleteRemoteProfile } from '@zennotes/app-core/workspace' import { openNote, openAppPage } from '@zennotes/app-core/navigation' @@ -889,8 +890,8 @@ function MobileDrawerBody(props: { const [sortOpen, setSortOpen] = useState(false) // Long-pressing a row opens its action sheet — the phone's right-click // (Discord folder feedback). Notes open the shell-wide note sheet - // (note-actions.tsx, shared with app-core's lists); folders get - // Rename/Delete here. Prompts overlay the open drawer (Modal layers above + // (note-actions.tsx, shared with app-core's lists); folders and databases + // get Rename/Move/Delete here. Prompts overlay the open drawer (Modal layers above // z-49), so the drawer stays put and its list refreshes in place via the // vault change events. const [folderMenu, setFolderMenu] = useState<{ kind: 'folder' | 'database'; subpath: string; name: string; host: ReturnType } | null>(null) @@ -980,6 +981,15 @@ function MobileDrawerBody(props: { const newFolderHere = (): void => { void requestCreateBrowseFolder(captureMobileWorkspace(), path).catch(reportActionError) } + // Core keeps the leaf name (a database keeps its .base suffix) and carries + // tabs, folder icons, favorites and manual order to the new path. Folder + // pins are keyed by subpath and are left alone, as Rename leaves them: an + // orphaned pin never matches a row and is pruned on the next toggle. + const moveFolderFromDrawer = (subpath: string): void => { + const host = folderMenu?.host ?? captureMobileWorkspace() + setFolderMenu(null) + void requestMoveBrowseDirectory(host, subpath).catch(reportActionError) + } const deleteFolder = (subpath: string, _name: string): void => { const host = folderMenu?.host ?? captureMobileWorkspace() void requestDeleteBrowseDirectory(host, subpath).catch(reportActionError) @@ -1246,6 +1256,14 @@ function MobileDrawerBody(props: { Rename + +