PROPFIND href segments were decoded with java.net.URLDecoder, which applies the
form-encoding rule '+' -> space. A file named '6103+.pdf' was read as '6103 .pdf',
so every GET/DELETE requested '6103%20.pdf' and 404'd forever — that one file could
never sync. Replaced with decodeWebDavSegment(): decodes %XX only, leaves '+' literal.
Covered by WebDavDecodeTest (7 tests).
Adds an opt-in 'Back up multiple folders' mode to Add Sync: pick N folders +
one remote base, and the app creates one normal pair per folder at
base/<folderName>. Each source keeps its own subfolder so same-named files
across folders never collide/overwrite — the safe many-to-one.
- AddPairViewModel: multiFolder + localPaths state, batch save via buildEntity;
folderLeafName() + uniqueSubName() derive collision-free remote subfolders
- AddPairScreen: mode toggle (new pairs only), folder list w/ add/remove,
remote-base labelling
- SyncEngine: first-sync against a not-yet-created remote folder no longer fails
— treat 404 as empty remote ONLY when no prior state exists (no DELETE_* can
fire), and MKCOL the base dir so first uploads have a parent. An established
pair still fails loudly on 404 (never mass-deletes a vanished remote).
- Tests: MultiFolderTest, isRemoteNotFound cases in ExcludePathTest
Scoped storage (Android 11+) lets a SAF grant LIST another app's
Android/media|data|obb dir but not OPEN the files, so every transfer there
failed and the pair was stuck reporting Partial forever (65 failures under
Android/media/com.whatsapp/ on Zahra). These hold app-managed data, not user
content, so exclude the whole subtree on both sides via path-prefix match in
isExcludedPath. ExcludePathTest covers the new prefixes + case-insensitivity.
The .thumbnails cache was synced then DELETE_REMOTE'd every cycle: exclude
patterns were applied only to the local walk (filename-only), never to the
remote listing, so a previously-uploaded thumbnail looked local-missing and
got mirror-deleted endlessly as Android regenerates the cache.
- isExcludedPath(): path-segment-aware, hardcoded always-ignored set protects
all existing pairs without a DB migration
- applied symmetrically to remote listing + merged path set (never upload,
download, or delete an excluded path on either side)
- add .thumbnails to new-pair default excludePatterns
- ExcludePathTest covers cache/trash/pending/sfpart + user patterns
WebDAV already sanitizes server-supplied names, but SFTP passed entry.name
through unfiltered, and the engine had no central guard — a malicious or
compromised remote could return '../../x' and (on the JavaFile backend) write
outside the sync root.
- SyncEngine: isUnsafeSyncPath() rejects empty, absolute, and any '..'-segment
path; every file is checked before any read/write/delete (covers all providers).
- SftpProvider.listFiles: drop '.'/'..' and names containing path separators.
- PathSafetyTest covers traversal, backslash, absolute, and empty cases.
The Add-Pair screen defaulted deleteBehavior to MIRROR for every direction,
so an Upload-only backup would delete cloud files when you deleted them on
the phone. Now the default follows the direction:
- Upload-only / Download-only -> KEEP (deleting locally leaves the cloud copy)
- Two-way -> MIRROR
All three options remain selectable; once the user explicitly picks one,
changing direction no longer overrides it, and editing a saved pair keeps
its stored choice. Adds RecommendedDeleteBehaviorTest.
Characterizes the 'back up phone -> delete locally -> must stay in cloud'
scenario across the real multi-cycle engine state (upload saves null remote
metadata; next sync reconciles), asserting per delete behavior:
- KEEP -> SKIP (cloud copy retained) — correct backup behavior
- ARCHIVE -> DELETE_REMOTE decision (engine moves to _Deleted/, preserved)
- MIRROR -> DELETE_REMOTE (cloud copy wiped) — footgun, and the current default
Also: upload-only never pulls a new remote file down; local edits still upload.
These contradicted deliberate later safety fixes in syncDecide:
- sub-second mtime delta is now SKIP (second-precision comparison was the
fix for the FAT32/WebDAV phantom-change sync loops), not UPLOAD. Added a
full-second-delta case to keep change-detection coverage.
- remote file with no state record now DOWNLOADs instead of DELETE_REMOTE:
known==null can't be distinguished from a brand-new remote file, so the
engine never deletes on ambiguity. Genuinely-deleted local files still
have a state record and route to DELETE_REMOTE.
All 25 unit tests pass; assembleRelease builds and signs cleanly (compileSdk 35).
When a file was uploaded before state-tracking worked (getFileMetadata was
broken), its SyncFileStateEntity was never saved. On next sync the engine
saw !local + remote + known=null and downloaded it back instead of deleting
it remotely, creating an infinite re-download loop.
Fix: syncDecide() now accepts hasPriorSyncState (derived from whether the
pair has any known states at all). On initial sync (no prior state) unknown
remote files are downloaded as before. Once the pair has been synced, unknown
remote-only files are treated as mirror-eligible deletions — same as if known
state existed — so locally-deleted files propagate to the remote correctly.
Verified live: 3 remote-only orphan files deleted from Nextcloud on sync.
Bump version to 1.0.12 (code 13).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- LocalAccessor.Saf.delete() now uses docIdCache (same as openInputStream)
and catches IllegalStateException from DocumentsContract.deleteDocument
instead of propagating it through awaitAll() and crashing the whole sync
- WebDavProvider.getFileMetadata() passes dropFirst=false to parsePropfind
since Depth:0 returns exactly 1 result (the file); drop(1) was discarding it
- SyncEngine.performSync() calls ensureRemoteDirs() before each upload so
MKCOL is issued for any missing parent directories (405=exists is success)
- Bump version to 1.0.11 (code 12)
Verified against live Nextcloud: baseline ↑0 ↓0 ✗0, upload detection ↑1 ↓0 ✗0,
download detection ↑0 ↓1 ✗0.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sync change detection (3rd attempt — now correct):
- After UPLOAD: save null remote metadata (server mtime unknown until
next listing); decide() treats null remoteModifiedAt as "not changed"
- After DOWNLOAD: read actual local mtime via accessor.lastModifiedMs()
so the stored value matches what walkFiles() sees on next scan
- SKIP reconciliation: if known state has null timestamps and both sides
exist, fill them in — stabilises state within 2 syncs after first transfer
- Extract syncDecide() as internal top-level function for testability
Unit tests (14 cases covering all key scenarios):
- First sync decisions (upload/download/conflict)
- Second sync after upload with null remote metadata → SKIP
- Second sync after download with recorded local mtime → SKIP
- Epoch-millis precision: same ms = SKIP, +1ms = change detected
- Regression: epoch-second stored value would have differed → now correct
- Delete behaviour (MIRROR vs KEEP)
- Direction filters (UPLOAD_ONLY, DOWNLOAD_ONLY)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>