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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ android {
applicationId "md.zennotes"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 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.
Expand Down
142 changes: 112 additions & 30 deletions android/app/src/main/java/md/zennotes/SafFsPlugin.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<String, Entry> 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);
Expand Down
34 changes: 17 additions & 17 deletions package-lock.json

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

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "zennotes-android",
"private": true,
"version": "1.1.23",
"version": "1.1.24",
"type": "module",
"description": "ZenNotes for Android — Capacitor shell over the ZenNotes app core",
"homepage": "https://zennotes.org",
Expand Down Expand Up @@ -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": {
Expand Down
10 changes: 8 additions & 2 deletions src/bridge/mobile-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
try {
Expand Down Expand Up @@ -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 }
: {})
}
}

Expand Down
37 changes: 26 additions & 11 deletions src/bridge/mobile-cloud-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,18 @@ export async function getMobileCloudSettingsConflict(
): Promise<CloudSyncSettingsConflict | null> {
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 } : {})
}
}

Expand All @@ -189,22 +198,28 @@ export async function resolveMobileCloudSettingsConflict(
): Promise<void> {
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<MobileVault['setVaultSettings']>[0])
await vault.setVaultSettings(
parsed as unknown as Parameters<MobileVault['setVaultSettings']>[0]
)
}
await vault.fs.deleteFile(CLOUD_SYNC_SETTINGS_CONFLICT_PATH).catch(() => {})
}

function parseParkedSettings(raw: string): Record<string, unknown> | 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<string, unknown>
}

export async function listMobileCloudBackups(vault: MobileVault): Promise<CloudBackupSnapshot[]> {
return service.listBackups(hostVault(vault))
}
Expand Down
Loading
Loading