Skip to content

fix(sync): make the SAF mirror recursive and structure-preserving - #222

Open
Lorite wants to merge 2 commits into
ActivityWatch:masterfrom
Lorite:fix/saf-mirror-recursive
Open

fix(sync): make the SAF mirror recursive and structure-preserving#222
Lorite wants to merge 2 commits into
ActivityWatch:masterfrom
Lorite:fix/saf-mirror-recursive

Conversation

@Lorite

@Lorite Lorite commented Aug 11, 2026

Copy link
Copy Markdown

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:

val sourceFiles = File(syncDir).listFiles()?.filter { it.isFile } ?: return
if (sourceFiles.isEmpty()) return

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:

let remotedir = path.join(device_id);
fs::create_dir_all(&remotedir)?;
let dbfile = remotedir.join("test.db");

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:

files/sync/<hostname>/<device-id>/test.db

The isFile filter therefore matches zero entries, the function returns early, and the log line reads SAF mirror: copied=0 skipped=0.

Flattening the copy would not fix it either. The receiving side requires the same nesting — find_remotes() in aw-sync/src/util.rs:

let dbs = fs::read_dir(sync_directory)?
    .map(|res| res.ok().unwrap().path())
    .filter(|p| p.is_dir())          // only descends into directories
    .flat_map(|d| fs::read_dir(d).unwrap())
    ...
    .filter(|path| path.extension()... == "db")

so a test.db sitting flat in the SAF directory would be silently ignored by whatever imports it.

Change

Replaces the flat loop with mirrorDirectory(), which walks recursively and calls createDirectory() 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 / SecurityException are 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=0 beforehand, 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.

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-apps

greptile-apps Bot commented Aug 11, 2026

Copy link
Copy Markdown

Greptile Summary

The PR replaces the ineffective flat SAF copy with recursive, structure-preserving traversal.

  • Creates and reuses corresponding destination directories.
  • Copies nested sync files while retaining non-fatal error handling and per-entry cancellation checks.
  • Adds counters for copied and skipped entries.

Confidence Score: 4/5

The 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

Filename Overview
mobile/src/main/java/net/activitywatch/android/SyncInterface.kt Adds recursive SAF mirroring successfully, but the file branch does not reject a same-named destination directory and can leave that source file uncopied.

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]
Loading

Reviews (1): Last reviewed commit: "fix(sync): make the SAF mirror recursive..." | Re-trigger Greptile

Comment on lines +282 to +283
val dest = destDir.findFile(entry.name)
?: destDir.createFile("application/octet-stream", entry.name)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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>
@Lorite

Lorite commented Aug 11, 2026

Copy link
Copy Markdown
Author

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 findFile() returned, including a directory. openOutputStream() on a directory URI fails, so an already-populated tree containing a directory named like a source file would have silently left that file uncopied — and the file in question is test.db, which is the whole point of the mirror.

Both branches now treat a type clash the same way: logged and counted as a skip, never written into.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant