fix(sync): make the SAF mirror recursive and structure-preserving - #222
fix(sync): make the SAF mirror recursive and structure-preserving#222Lorite wants to merge 2 commits into
Conversation
copySyncFilesToSafDir() lists only top-level regular files:
File(syncDir).listFiles()?.filter { it.isFile }
but aw-sync never writes a regular file at the root of the sync directory.
setup_local_remote() in aw-server-rust (aw-sync/src/sync.rs) does
path.join(device_id) and writes test.db inside it, so the shallowest possible
layout is <syncDir>/<device_id>/test.db. On Android the observed tree is deeper
still: <syncDir>/<hostname>/<device_id>/test.db.
The filter therefore matches zero entries and the mirror is a no-op - it logs
"SAF mirror: copied=0 skipped=0" and the chosen directory stays empty.
Flattening would not help either: find_remotes() (aw-sync/src/util.rs) keeps
only directories and looks for *.db one level inside them, so a copy that lost
the nesting would be ignored by the receiving side.
Replaces the loop with mirrorDirectory(), which recurses and calls
createDirectory() so the structure is reproduced verbatim. Existing behaviour is
kept: cancellation is checked per entry, IOException/SecurityException are caught
and counted, and nothing propagates out of the mirror.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Greptile SummaryThe PR replaces the ineffective flat SAF copy with recursive, structure-preserving traversal.
Confidence Score: 4/5The recursive mirror should not merge until same-named destination directories are handled in the source-file branch. An already-populated SAF tree can contain a directory matching a source filename, causing the new recursive file branch to open a directory URI and skip the database instead of producing a complete mirror. Files Needing Attention: mobile/src/main/java/net/activitywatch/android/SyncInterface.kt Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Successful native sync] --> B[Resolve selected SAF tree]
B --> C[mirrorDirectory]
C --> D{Source entry type}
D -->|Directory| E[Find or create destination directory]
E --> C
D -->|File| F[Find or create destination file]
F --> G[Truncate and copy contents]
G --> H[Update copied or skipped counts]
Reviews (1): Last reviewed commit: "fix(sync): make the SAF mirror recursive..." | Re-trigger Greptile |
| val dest = destDir.findFile(entry.name) | ||
| ?: destDir.createFile("application/octet-stream", entry.name) |
There was a problem hiding this comment.
Destination directory treated as file
When an already-populated SAF tree contains a directory with the same name as a source file, findFile(entry.name) returns that directory and the code passes its URI to openOutputStream, causing the source database to be skipped and leaving the mirror stale or incomplete.
Knowledge Base Used: Background service and periodic sync
Review catch. The directory branch already refuses to mirror into a same-named non-directory, but the file branch used whatever findFile() returned - including a directory. openOutputStream() on a directory URI fails, so an already-populated SAF tree containing a directory named like a source file would silently leave that file uncopied, and the file in question is test.db. Makes the two branches symmetric: a type clash is counted as a skip and logged, never written into. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Good catch on the asymmetry — fixed in f6bd9f0. The directory branch already refused to mirror into a same-named non-directory, but the file branch used whatever Both branches now treat a type clash the same way: logged and counted as a skip, never written into. |
Problem
The SAF mirror added in #209 copies nothing, so the directory the user picks in Sync Settings stays empty.
copySyncFilesToSafDir()lists only top-level regular files:But
aw-syncnever writes a regular file at the root of the sync directory.setup_local_remote()in aw-server-rust (aw-sync/src/sync.rs) does:so the shallowest possible layout is
<syncDir>/<device_id>/test.db. On Android the observed tree is one level deeper still —<syncDir>/<hostname>/<device_id>/test.db:The
isFilefilter therefore matches zero entries, the function returns early, and the log line readsSAF mirror: copied=0 skipped=0.Flattening the copy would not fix it either. The receiving side requires the same nesting —
find_remotes()inaw-sync/src/util.rs:so a
test.dbsitting flat in the SAF directory would be silently ignored by whatever imports it.Change
Replaces the flat loop with
mirrorDirectory(), which walks recursively and callscreateDirectory()so the<device_id>/structure is reproduced verbatim in the SAF tree.Existing behaviour is preserved deliberately: cancellation is still checked before each entry,
IOException/SecurityExceptionare still caught and counted, and nothing propagates out of the mirror — a copy failure must never fail the sync.Two small additions: an existing same-named subdirectory is reused rather than duplicated, and a non-directory blocking a directory name is counted as a skip instead of throwing.
Testing
Compiled and installed on a physical device (Android 16, arm64). Verified the flat version's
copied=0beforehand, and that the recursive version reproduces the nested tree.Note: on that device the sync itself then aborts natively (#220), so end-to-end validation of the copy contents came from driving the same mirror code against a datastore published by other means. The directory-creation and recursion behaviour is what this PR changes, and that is exercised.