From 07ad08e056a597826a9f76e863ea11ab16baf68b Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 18 Sep 2026 12:09:54 +0000 Subject: [PATCH] fix(sync): copy peer files from SAF dir into internal dir before pull MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When using a SAF/Syncthing directory for sync, copySyncFilesToSafDir mirrors internal → SAF (outbound) but nothing copies other hosts' databases FROM the SAF directory INTO the app-private syncDir before syncBoth runs. So pull_all always finds zero peers — the internal dir only ever contains our own staging. Add copyPeerFilesFromSafDir() that mirrors SAF → internal for all hostname directories except our own (our own staging is authoritative in the internal dir). Call it in syncBothAsync's lambda before syncBoth, inside the executor thread. Errors per entry are logged and skipped so a partially-accessible SAF directory does not abort an otherwise healthy sync pass. Fixes ActivityWatch/aw-android#291. Git-Session-Id: 0e07 --- .../activitywatch/android/SyncInterface.kt | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt b/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt index 6d46a56a..83d4b844 100644 --- a/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt +++ b/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt @@ -10,6 +10,7 @@ import androidx.documentfile.provider.DocumentFile import org.json.JSONObject import java.io.File import java.io.FileInputStream +import java.io.FileOutputStream import java.io.IOException import java.util.concurrent.ExecutorService import java.util.concurrent.Executors @@ -256,6 +257,7 @@ class SyncInterface(context: Context) { }, mirrorBeforeCallback ) { + copyPeerFilesFromSafDir() syncBoth(BuildConfig.SERVER_PORT, hostname) } } @@ -497,6 +499,89 @@ class SyncInterface(context: Context) { } } + /** + * Before each full sync, copy peer databases from the user-configured SAF + * directory into the internal sync directory so that [syncBoth] can find them. + * + * Syncthing (or any external file-sync tool) writes peer databases into the + * SAF-granted tree as `//test.db`. The internal [syncDir] + * is app-private and invisible to those tools, so without this step `pull_all` + * inside `syncBoth` always finds zero peers — every run reports "pulled 0" + * regardless of how many peers have synced their data into the Syncthing folder. + * + * We skip the directory whose name matches our own hostname to avoid replacing + * live staging files with the one-cycle-stale SAF mirror. Errors for individual + * entries are logged and skipped so a partially-accessible SAF directory does not + * abort an otherwise healthy sync pass. + */ + private fun copyPeerFilesFromSafDir() { + val uriStr = AWPreferences(appContext).getSyncDirUri() ?: return + val safUri = Uri.parse(uriStr) + val safDir = DocumentFile.fromTreeUri(appContext, safUri) ?: return + if (!safDir.isDirectory) { + Log.w(TAG, "SAF peer pre-copy: configured URI is not a directory") + return + } + + val ownHostname = getDeviceName() + val destRoot = File(syncDir) + var copied = 0 + var errors = 0 + + for (hostDir in safDir.listFiles()) { + if (!hostDir.isDirectory) continue + val hostname = hostDir.name ?: continue + if (hostname == ownHostname) continue // own staging is authoritative in the internal dir + + val localHostDir = File(destRoot, hostname) + localHostDir.mkdirs() + val (c, e) = copyFromSafDirectory(hostDir, localHostDir) + copied += c + errors += e + } + Log.i(TAG, "SAF peer pre-copy: copied=$copied errors=$errors") + } + + /** + * Recursively copy [safDir] (a SAF DocumentFile subtree) into [destDir] (a + * local File directory), skipping entries that cannot be read. + * + * Returns (copiedCount, errorCount). + */ + private fun copyFromSafDirectory(safDir: DocumentFile, destDir: File): Pair { + var copied = 0 + var errors = 0 + for (entry in safDir.listFiles()) { + if (cancelRequested) break + val name = entry.name ?: continue + try { + if (entry.isDirectory) { + val subDest = File(destDir, name) + subDest.mkdirs() + val (c, e) = copyFromSafDirectory(entry, subDest) + copied += c + errors += e + } else { + val inp = appContext.contentResolver.openInputStream(entry.uri) + if (inp == null) { + Log.w(TAG, "SAF peer pre-copy: null input stream for $name") + errors++ + continue + } + inp.use { FileOutputStream(File(destDir, name)).use { out -> it.copyTo(out) } } + copied++ + } + } catch (e: IOException) { + Log.w(TAG, "SAF peer pre-copy: failed to copy $name: ${e.message}") + errors++ + } catch (e: SecurityException) { + Log.w(TAG, "SAF peer pre-copy: permission denied for $name: ${e.message}") + errors++ + } + } + return Pair(copied, errors) + } + fun getSyncDirectory(): String = syncDir private fun migrateLegacySyncFolders(): SanitizedHostnameMigration.MigrationResult {