From 08522b82974ae045c9133a83df43e2138dd1d371 Mon Sep 17 00:00:00 2001 From: Lorite Date: Tue, 11 Aug 2026 09:57:39 +0200 Subject: [PATCH 1/2] fix(sync): make the SAF mirror recursive and structure-preserving 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 //test.db. On Android the observed tree is deeper still: ///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 --- .../activitywatch/android/SyncInterface.kt | 94 +++++++++++++------ 1 file changed, 65 insertions(+), 29 deletions(-) diff --git a/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt b/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt index 82c25323..aade5f0c 100644 --- a/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt +++ b/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt @@ -217,8 +217,17 @@ class SyncInterface(context: Context) { * user-chosen SAF directory (if one has been configured via SyncSettingsActivity). * * aw-sync writes to the app-private [syncDir] which is invisible to Syncthing and other - * file-sync tools on Android 11+. This method copies every regular file from [syncDir] - * to the SAF-granted tree URI so that external sync tools can reach the data. + * file-sync tools on Android 11+. This method mirrors [syncDir] to the SAF-granted tree + * URI so that external sync tools can reach the data. + * + * The mirror is recursive and structure-preserving, which is required for the copy to + * contain anything at all and for the result to be usable. 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, and + * on Android the observed tree is one level deeper still, + * `///test.db`. The consuming side requires the same nesting: + * `find_remotes` (`aw-sync/src/util.rs`) keeps only directories and looks for `*.db` one + * level inside them, so a flattened copy would be ignored even if it were made. * * Errors are logged but do not propagate — a copy failure must never fail the sync itself. */ @@ -231,42 +240,69 @@ class SyncInterface(context: Context) { return } - val sourceFiles = File(syncDir).listFiles()?.filter { it.isFile } ?: return - if (sourceFiles.isEmpty()) return + val counts = intArrayOf(0, 0) // [copied, skipped] + mirrorDirectory(File(syncDir), safDir, counts) + Log.i(TAG, "SAF mirror: copied=${counts[0]} skipped=${counts[1]} → $uriStr") + } - var copied = 0 - var skipped = 0 - for (file in sourceFiles) { + /** + * Recursively mirror [sourceDir] into [destDir], creating subdirectories as needed so the + * `/` layout aw-sync produces is reproduced verbatim in the SAF tree. + */ + private fun mirrorDirectory(sourceDir: File, destDir: DocumentFile, counts: IntArray) { + val entries = sourceDir.listFiles() ?: return + + for (entry in entries) { if (cancelRequested) { - Log.i(TAG, "SAF mirror cancelled; stopping before ${file.name}") - break + Log.i(TAG, "SAF mirror cancelled; stopping before ${entry.name}") + return } try { - // Reuse an existing file if present; otherwise create a new one. - val dest = safDir.findFile(file.name) - ?: safDir.createFile("application/octet-stream", file.name) - if (dest == null) { - Log.w(TAG, "Could not create SAF file for ${file.name}") - skipped++ - continue - } - val out = appContext.contentResolver.openOutputStream(dest.uri, "wt") - if (out == null) { - Log.w(TAG, "Null output stream for ${file.name} in SAF dir") - skipped++ - continue + if (entry.isDirectory) { + // Reuse an existing subdirectory if present; otherwise create it. A + // non-directory of the same name cannot be mirrored into. + val existing = destDir.findFile(entry.name) + val subDir = when { + existing != null && existing.isDirectory -> existing + existing != null -> { + Log.w(TAG, "SAF entry ${entry.name} exists but is not a directory") + counts[1]++ + continue + } + else -> destDir.createDirectory(entry.name) + } + if (subDir == null) { + Log.w(TAG, "Could not create SAF directory for ${entry.name}") + counts[1]++ + continue + } + mirrorDirectory(entry, subDir, counts) + } else { + // Reuse an existing file if present; otherwise create a new one. + val dest = destDir.findFile(entry.name) + ?: destDir.createFile("application/octet-stream", entry.name) + if (dest == null) { + Log.w(TAG, "Could not create SAF file for ${entry.name}") + counts[1]++ + continue + } + val out = appContext.contentResolver.openOutputStream(dest.uri, "wt") + if (out == null) { + Log.w(TAG, "Null output stream for ${entry.name} in SAF dir") + counts[1]++ + continue + } + out.use { FileInputStream(entry).use { inp -> inp.copyTo(it) } } + counts[0]++ } - out.use { FileInputStream(file).use { inp -> inp.copyTo(it) } } - copied++ } catch (e: IOException) { - Log.w(TAG, "Failed to copy ${file.name} to SAF dir: ${e.message}") - skipped++ + Log.w(TAG, "Failed to copy ${entry.name} to SAF dir: ${e.message}") + counts[1]++ } catch (e: SecurityException) { - Log.w(TAG, "Permission denied copying ${file.name} to SAF dir: ${e.message}") - skipped++ + Log.w(TAG, "Permission denied copying ${entry.name} to SAF dir: ${e.message}") + counts[1]++ } } - Log.i(TAG, "SAF mirror: copied=$copied skipped=$skipped → $uriStr") } fun getSyncDirectory(): String = syncDir From f6bd9f097735c2ecf375fe83c1888ae459b08820 Mon Sep 17 00:00:00 2001 From: Lorite Date: Tue, 11 Aug 2026 20:22:28 +0200 Subject: [PATCH 2/2] fix(sync): reject a same-named destination directory in the file branch 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 --- .../net/activitywatch/android/SyncInterface.kt | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt b/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt index aade5f0c..0f914f07 100644 --- a/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt +++ b/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt @@ -278,8 +278,19 @@ class SyncInterface(context: Context) { } mirrorDirectory(entry, subDir, counts) } else { - // Reuse an existing file if present; otherwise create a new one. - val dest = destDir.findFile(entry.name) + // Reuse an existing file if present; otherwise create a new one. A + // same-named DIRECTORY must be rejected rather than written into: an + // already-populated tree can contain one, and openOutputStream() on a + // directory URI fails, which would silently leave the database + // uncopied. The directory branch above rejects the mirror case, so + // this keeps the two symmetric. + val existingFile = destDir.findFile(entry.name) + if (existingFile != null && existingFile.isDirectory) { + Log.w(TAG, "SAF entry ${entry.name} is a directory; cannot write a file there") + counts[1]++ + continue + } + val dest = existingFile ?: destDir.createFile("application/octet-stream", entry.name) if (dest == null) { Log.w(TAG, "Could not create SAF file for ${entry.name}")