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>
This commit is contained in:
@@ -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,6 +37,8 @@ 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>>()
|
||||
@@ -81,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 }
|
||||
|
||||
@@ -203,12 +207,23 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,8 +268,10 @@ class FileWatchService : Service() {
|
||||
}
|
||||
delay(12_000)
|
||||
updateNotificationDynamic(null)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
syncCooldownUntil[pairId] = 0L
|
||||
syncCooldownUntil[pairId] = System.currentTimeMillis() + 60_000
|
||||
updateNotificationDynamic(null)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user