Compare commits

...

2 Commits

Author SHA1 Message Date
amir ec478531da v1.0.30: fix sync loop root causes + icon redesign
Three root causes found via live logcat on device:

1. concurrent refresh() race: onStartCommand received twice causes two
   refresh() coroutines to run in parallel, doubling FileObserver and
   catchupScan registrations. Fixed with Mutex.withLock on refresh().

2. catchupScan no cooldown: catchup syncs write files but never set
   syncCooldownUntil, so every written file immediately re-triggers
   onChangeDetected. Fixed by setting cooldown before enqueue and
   watching work completion same as onChangeDetected does.

3. CancellationException caught silently: exception handler
   catch(_: Exception) was catching CancellationException and resetting
   cooldown to 0L, re-opening the loop. Fixed by rethrowing
   CancellationException and setting 60s cooldown on other errors.

Icon: interlocked rings (blue/red/green/orange) with sync arrow at
center, pure black background — matches reference image.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 13:48:18 +00:00
amir 5ade80a334 v1.0.29: fix sync loop, stale-state auto-heal, icon redesign
- 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 <noreply@anthropic.com>
2026-05-25 11:51:59 +00:00
5 changed files with 122 additions and 114 deletions
@@ -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)
@@ -23,6 +23,8 @@ import com.syncflow.data.db.SyncPairDao
import com.syncflow.domain.model.ScheduleType
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import java.io.File
import javax.inject.Inject
@@ -35,11 +37,16 @@ class FileWatchService : Service() {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val mainHandler = Handler(Looper.getMainLooper())
// Prevents concurrent refresh() calls from doubling watchers + catchup scans
private val refreshMutex = Mutex()
// Multiple FileObserver instances per pair: one per directory (recursive)
private val fileObservers = mutableMapOf<Long, MutableList<FileObserver>>()
private val contentObservers = mutableMapOf<Long, ContentObserver>()
private val debounceJobs = mutableMapOf<Long, Job>()
// 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<Long, Long>()
companion object {
const val CHANNEL_WATCH = "sync_watching"
@@ -78,7 +85,7 @@ class FileWatchService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
private suspend fun refresh() {
private suspend fun refresh() = refreshMutex.withLock {
clearWatchers()
val pairs = syncPairDao.getEnabled().filter { it.scheduleType == ScheduleType.ON_CHANGE }
@@ -200,16 +207,34 @@ class FileWatchService : Service() {
if (hasNew || hasModified || hasDeleted) {
Timber.d("FileWatchService: catchup detected changes for pair $pairId, scheduling sync")
val pair = syncPairDao.getById(pairId) ?: return
// Set cooldown so file writes during this sync don't immediately re-trigger
syncCooldownUntil[pairId] = System.currentTimeMillis() + 120_000
val req = SyncWorker.buildOneTimeRequest(pairId, wifiOnly, chargingOnly)
WorkManager.getInstance(applicationContext)
.enqueueUniqueWork(
"catchup_$pairId",
ExistingWorkPolicy.KEEP,
SyncWorker.buildOneTimeRequest(pairId, wifiOnly, chargingOnly),
)
.enqueueUniqueWork("catchup_$pairId", ExistingWorkPolicy.KEEP, req)
scope.launch {
try {
WorkManager.getInstance(applicationContext)
.getWorkInfoByIdFlow(req.id)
.first { it?.state?.isFinished == true }
syncCooldownUntil[pairId] = System.currentTimeMillis() + 60_000
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
syncCooldownUntil[pairId] = System.currentTimeMillis() + 60_000
}
}
}
}
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 +242,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 +267,11 @@ class FileWatchService : Service() {
updateNotificationDynamic("$watching")
}
delay(12_000)
updateNotificationDynamic(null) // revert to default watching text
updateNotificationDynamic(null)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
syncCooldownUntil[pairId] = System.currentTimeMillis() + 60_000
updateNotificationDynamic(null)
}
}
@@ -254,6 +285,7 @@ class FileWatchService : Service() {
contentObservers.clear()
debounceJobs.values.forEach { it.cancel() }
debounceJobs.clear()
syncCooldownUntil.clear()
}
private fun ensureChannel() {
@@ -1,25 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Dark charcoal background, matching Avast-style dark icon bg -->
<!-- Pure black background -->
<path android:pathData="M0,0 H108 V108 H0 Z"
android:fillColor="#1F1F2E"/>
<!-- Very subtle inner glow -->
<path android:pathData="M0,0 H108 V108 H0 Z"
android:fillAlpha="0.25">
<aapt:attr name="android:fillColor">
<gradient android:type="radial"
android:gradientRadius="60"
android:centerX="54" android:centerY="50"
android:startColor="#3D3A50"
android:endColor="#00000000"/>
</aapt:attr>
</path>
android:fillColor="#000000"/>
</vector>
@@ -1,102 +1,76 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SyncFlow icon foreground.
Design: three bold teardrop "speed streak" shapes in Avast color palette
(teal, red, yellow) converging on a white cloud in the centre.
Each teardrop has a pointed tail (far from cloud) and a wide rounded head
(near the cloud), like motion streaks flying into the sync point.
Safe zone: 18-90dp band. Cloud centred at (54, 55).
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- SHADOW layer under teardrops for depth -->
<!--
Four interlocked ring arcs (blue/red/green/orange), each a thick rounded band
arranged in a 2x2 offset so they interweave. A white sync-arrow circle sits
at the center. Designed to match the braided-knot reference icon.
-->
<!-- Blue ring - top-left -->
<path
android:strokeColor="#2979FF"
android:strokeWidth="7"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 38,36
A 16,16 0 1,1 54,52
A 16,16 0 1,1 38,36 Z"/>
<!-- Red ring - top-right -->
<path
android:strokeColor="#F44336"
android:strokeWidth="7"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 54,36
A 16,16 0 1,1 70,52
A 16,16 0 1,1 54,36 Z"/>
<!-- Green ring - bottom-left -->
<path
android:strokeColor="#00C853"
android:strokeWidth="7"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 38,52
A 16,16 0 1,1 54,68
A 16,16 0 1,1 38,52 Z"/>
<!-- Orange ring - bottom-right -->
<path
android:strokeColor="#FF6D00"
android:strokeWidth="7"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 54,52
A 16,16 0 1,1 70,68
A 16,16 0 1,1 54,52 Z"/>
<!-- White filled circle at center to create interlock illusion -->
<path
android:pathData="M 54,28 C 42,28 30,36 32,50 C 22,55 22,70 34,72 L 74,72 C 84,72 88,62 82,55 C 86,43 76,33 66,35 C 62,30 58,28 54,28 Z"
android:fillColor="#000000"
android:fillAlpha="0.20"
android:translateY="2.5"/>
android:pathData="M 46,52 A 8,8 0 1,0 62,52 A 8,8 0 1,0 46,52 Z"/>
<!-- TEAL teardrop: enters from upper-left, tail at (22,22), head near cloud top-left -->
<!-- Teardrop shape: pointed at tail, fat elliptical head, rotated ~45 deg into centre -->
<!-- Sync arrow ring (outer white circle) -->
<path
android:pathData="M 35.5,26.5
C 30,21 22,22 22,22
C 22,22 27,30 32.5,35.5
C 36,38 40,40 43,42
C 40,39 36,32 35.5,26.5 Z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear"
android:startX="22" android:startY="22"
android:endX="43" android:endY="42"
android:startColor="#00BFA5"
android:endColor="#26D6C0"/>
</aapt:attr>
</path>
<!-- RED teardrop: enters from upper-right, tail at (86,22), head near cloud top-right -->
<path
android:pathData="M 72.5,26.5
C 78,21 86,22 86,22
C 86,22 81,30 75.5,35.5
C 72,38 68,40 65,42
C 68,39 72,32 72.5,26.5 Z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear"
android:startX="86" android:startY="22"
android:endX="65" android:endY="42"
android:startColor="#E53935"
android:endColor="#EF6558"/>
</aapt:attr>
</path>
<!-- YELLOW teardrop: enters from bottom-centre, tail at (54,88), head near cloud base -->
<path
android:pathData="M 48,75
C 45,82 47,88 54,88
C 61,88 63,82 60,75
C 58,71 56,68 54,66
C 52,68 50,71 48,75 Z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear"
android:startX="54" android:startY="88"
android:endX="54" android:endY="66"
android:startColor="#F9A825"
android:endColor="#FFD740"/>
</aapt:attr>
</path>
<!-- CLOUD body (white, centred at 54,50) -->
<path
android:pathData="
M 36,62
A 9,9 0 0,1 36,44
A 9,9 0 0,1 45,36
A 12,12 0 0,1 66,37
A 8,8 0 0,1 74,48
A 8,8 0 0,1 68,62
Z"
android:fillColor="#FFFFFF"/>
<!-- Teal highlight on cloud top-left edge -->
<path
android:pathData="M 36,53 A 9,9 0 0,1 41,38"
android:strokeColor="#FFFFFF"
android:strokeWidth="3.5"
android:fillColor="#00000000"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeColor="#4000BFA5"/>
android:pathData="M 47,52 A 7,7 0 1,0 61,52 A 7,7 0 1,0 47,52 Z"/>
<!-- Red highlight on cloud top-right edge -->
<!-- Sync arrow head top -->
<path
android:pathData="M 63,37 A 8,8 0 0,1 73,48"
android:fillColor="#00000000"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeColor="#40E53935"/>
android:fillColor="#FFFFFF"
android:pathData="M 54,45 L 57,49 L 51,49 Z"/>
<!-- Sync arrow head bottom -->
<path
android:fillColor="#FFFFFF"
android:pathData="M 54,59 L 51,55 L 57,55 Z"/>
</vector>
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.28
VERSION_CODE=29
VERSION_NAME=1.0.30
VERSION_CODE=31