From 5ade80a334d105815654fca96d4386230171b577 Mon Sep 17 00:00:00 2001 From: Amir Khodak Date: Mon, 25 May 2026 11:51:59 +0000 Subject: [PATCH] v1.0.29: fix sync loop, stale-state auto-heal, icon redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SyncEngine: self-healing stale folder state detection (isRetry) wipes orphaned SyncFileStateEntity records when localPath changes without a pair re-save — prevents repeated DELETE_REMOTE on 32 old files - SyncEngine: second-precision mtime comparison (/ 1000 / .epochSecond) eliminates phantom localChanged=true from FAT32/WebDAV precision mismatch - FileWatchService: syncCooldownUntil map suppresses FileObserver events for 120s after sync starts and 60s after it finishes, breaking the download→FileObserver→sync→download feedback loop - Icon: three bold teardrop shapes (teal/red/amber) rotated 0/120/240° on dark charcoal background with white cloud at intersection Co-Authored-By: Claude Sonnet 4.6 --- .../com/syncflow/domain/sync/SyncEngine.kt | 19 ++- .../com/syncflow/worker/FileWatchService.kt | 21 ++- .../res/drawable/ic_launcher_foreground.xml | 129 +++++++----------- version.properties | 4 +- 4 files changed, 84 insertions(+), 89 deletions(-) diff --git a/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt b/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt index 0158ba7..0f9c128 100644 --- a/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt +++ b/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt @@ -62,13 +62,28 @@ class SyncEngine @Inject constructor( else LocalAccessor.JavaFile(File(localPath)) - private suspend fun performSync(pair: SyncPair, provider: CloudProvider): SyncResult { + private suspend fun performSync( + pair: SyncPair, + provider: CloudProvider, + isRetry: Boolean = false, + ): SyncResult { val accessor = makeAccessor(pair.localPath) - val knownStates = fileStateDao.getForPair(pair.id).associateBy { it.relativePath } + var knownStates = fileStateDao.getForPair(pair.id).associateBy { it.relativePath } val remoteFiles = provider.listFiles(pair.remotePath).getOrThrow() .associateBy { it.path.removePrefix(pair.remotePath).trimStart('/') } val localFiles = accessor.walkFiles(pair) + // Self-healing: if every known-state path is absent from the current local scan but + // the local folder does have files, the localPath was changed without clearing state. + // The stale records would cause every old file to look like "DELETE_REMOTE" and every + // new file to re-upload indefinitely. Wipe and retry once as a fresh initial sync. + if (!isRetry && knownStates.isNotEmpty() && localFiles.isNotEmpty() && + knownStates.keys.none { it in localFiles }) { + Timber.w("SyncEngine: stale folder states detected for pair ${pair.id} — resetting") + fileStateDao.deleteForPair(pair.id) + return performSync(pair, provider, isRetry = true) + } + val allPaths = (localFiles.keys + remoteFiles.keys + knownStates.keys).toSet() val hasPriorSyncState = knownStates.isNotEmpty() val semaphore = Semaphore(4) diff --git a/app/src/main/kotlin/com/syncflow/worker/FileWatchService.kt b/app/src/main/kotlin/com/syncflow/worker/FileWatchService.kt index 922afc8..95ef462 100644 --- a/app/src/main/kotlin/com/syncflow/worker/FileWatchService.kt +++ b/app/src/main/kotlin/com/syncflow/worker/FileWatchService.kt @@ -40,6 +40,9 @@ class FileWatchService : Service() { private val fileObservers = mutableMapOf>() private val contentObservers = mutableMapOf() private val debounceJobs = mutableMapOf() + // After a watcher-triggered sync completes, suppress FileObserver events for this long + // to stop the feedback loop: sync writes files → FileObserver fires → another sync → repeat. + private val syncCooldownUntil = mutableMapOf() companion object { const val CHANNEL_WATCH = "sync_watching" @@ -210,6 +213,13 @@ class FileWatchService : Service() { } private fun onChangeDetected(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean) { + // Ignore events fired by our own sync writing files — prevents the feedback loop + // where downloaded/uploaded files trigger another sync indefinitely. + if (System.currentTimeMillis() < (syncCooldownUntil[pairId] ?: 0L)) { + Timber.d("FileWatchService: suppressing change event for pair $pairId (sync cooldown)") + return + } + debounceJobs[pairId]?.cancel() debounceJobs[pairId] = scope.launch { delay(5_000) @@ -217,19 +227,22 @@ class FileWatchService : Service() { if (pair == null || !pair.isEnabled) return@launch Timber.d("FileWatchService: triggering sync for pair $pairId after debounce") + // Block new triggers from this point until 60s after sync completes + syncCooldownUntil[pairId] = System.currentTimeMillis() + 120_000 + val req = SyncWorker.buildOneTimeRequest(pairId, wifiOnly, chargingOnly, silent = true) WorkManager.getInstance(applicationContext) .enqueueUniqueWork("onchange_$pairId", ExistingWorkPolicy.KEEP, req) - // Update notification while sync is in progress updateNotificationDynamic("Syncing: ${pair.name}…") - // Wait for completion and show result in the persistent notification scope.launch { try { val info = WorkManager.getInstance(applicationContext) .getWorkInfoByIdFlow(req.id) .first { it?.state?.isFinished == true } + // Extend cooldown: 60s after sync finishes to let filesystem settle + syncCooldownUntil[pairId] = System.currentTimeMillis() + 60_000 val summary = info?.outputData?.getString(SyncWorker.KEY_RESULT_SUMMARY) val watchCount = fileObservers.keys.size + contentObservers.size val watching = "Watching $watchCount folder${if (watchCount != 1) "s" else ""}" @@ -239,8 +252,9 @@ class FileWatchService : Service() { updateNotificationDynamic("$watching") } delay(12_000) - updateNotificationDynamic(null) // revert to default watching text + updateNotificationDynamic(null) } catch (_: Exception) { + syncCooldownUntil[pairId] = 0L updateNotificationDynamic(null) } } @@ -254,6 +268,7 @@ class FileWatchService : Service() { contentObservers.clear() debounceJobs.values.forEach { it.cancel() } debounceJobs.clear() + syncCooldownUntil.clear() } private fun ensureChannel() { diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index e3a3f0b..0c3c190 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,102 +1,67 @@ - - + + + + - - - - - - - + + + + - - - - - - + + + + - - - - - - - - + + M 42,62 + A 8,8 0 0,1 42,46 + A 8,8 0 0,1 51,39 + A 11,11 0 0,1 67,42 + A 7,7 0 0,1 68,56 + A 7,7 0 0,1 66,62 + Z"/> - + - - - + android:strokeColor="#18000000" + android:strokeWidth="1.5" + android:pathData=" + M 42,62 + A 8,8 0 0,1 42,46 + A 8,8 0 0,1 51,39 + A 11,11 0 0,1 67,42 + A 7,7 0 0,1 68,56 + A 7,7 0 0,1 66,62 + Z"/> diff --git a/version.properties b/version.properties index 51e12db..b30ae06 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=1.0.28 -VERSION_CODE=29 +VERSION_NAME=1.0.29 +VERSION_CODE=30