Compare commits

...

5 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
amir 34fb06a673 v1.0.28: fix sync rewrite/delete loop, Avast-inspired icon
Sync loop root-cause fixes (three independent bugs):

1. Folder change clears stale file states (AddPairViewModel): when
   localPath or remotePath changes on an existing pair, all
   SyncFileStateEntity records are wiped. Previously those stale records
   caused every sync to attempt DELETE_REMOTE on the old folder's files
   and to treat all new-folder files as changed — causing both the
   "deleting 32 files" loop and rewrites on every run.

2. Download stores null localModifiedAt (SyncEngine): SAF document
   cursors can return a stale mtime immediately after a write. Storing
   null forces the SKIP reconciliation pass on the next sync to read
   the actual walkFiles cursor value, breaking the download->changed->
   download loop caused by mtime inconsistency.

3. Second-precision mtime comparison (syncDecide): WebDAV RFC-1123 has
   1-second precision; FAT32 has 2-second precision. Comparing at
   millisecond level caused phantom "changed" detections after syncing
   to/from these systems. Now uses epochSecond for both local and remote.

Icon: three bold teal/red/yellow teardrop streaks (Avast palette) flying
into a white cloud centre, on dark charcoal background.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 04:18:13 +00:00
amir dc2a0b2c68 v1.0.27: knot-inspired icon, fix media-not-found on photo open
Icon: two thick tube-style arcs with 3D glossy highlights.
  Arc 1 (left side): coral #E8665A to orange #E8A040
  Arc 2 (right side): steel blue #4A7FD4 to deep purple #7B5EA7
  Arrowheads: orange and purple. Background: dark purple-black.
  Inspired by the braided knot color palette.

Fix "media not found" when opening photos:
  - Intent now sets ClipData alongside FLAG_GRANT_READ_URI_PERMISSION
    so the permission correctly propagates through the system chooser
    to whichever app the user picks.
  - openFile() and downloadToCache() both call MediaScannerConnection
    so newly synced/downloaded files appear in gallery MediaStore index
    before the viewer launches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 03:08:44 +00:00
amir 742f634084 v1.0.26: fix multi-selection reactivity, redesign icon, security review
Fix multi-selection: selectedKeys exposed as StateFlow, collected in
FilesScreen so checkboxes and highlights update correctly on every tap.
fileKey() made public so UI can check membership without ViewModel calls.

Icon: white cloud body with two cyan/teal circular sync arcs (AutoSync
style), deep blue-to-teal gradient background.

Security review clean: no hardcoded credentials, cleartext blocked by
network_security_config, allowBackup=false, path traversal guards in
place on both server responses and local resolution.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 02:45:43 +00:00
8 changed files with 181 additions and 72 deletions
@@ -62,13 +62,28 @@ class SyncEngine @Inject constructor(
else else
LocalAccessor.JavaFile(File(localPath)) 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 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() val remoteFiles = provider.listFiles(pair.remotePath).getOrThrow()
.associateBy { it.path.removePrefix(pair.remotePath).trimStart('/') } .associateBy { it.path.removePrefix(pair.remotePath).trimStart('/') }
val localFiles = accessor.walkFiles(pair) 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 allPaths = (localFiles.keys + remoteFiles.keys + knownStates.keys).toSet()
val hasPriorSyncState = knownStates.isNotEmpty() val hasPriorSyncState = knownStates.isNotEmpty()
val semaphore = Semaphore(4) val semaphore = Semaphore(4)
@@ -126,7 +141,9 @@ class SyncEngine @Inject constructor(
logEvent(pair.id, SyncEventType.FILE_DOWNLOADED, rel, null, bytes) logEvent(pair.id, SyncEventType.FILE_DOWNLOADED, rel, null, bytes)
FileOutcome(downloaded = 1, bytesTransferred = bytes, FileOutcome(downloaded = 1, bytesTransferred = bytes,
newState = buildState(pair.id, rel, newState = buildState(pair.id, rel,
LocalFileInfo(rel, remote!!.sizeBytes, localMtime), remoteAfterTransfer = remote)) LocalFileInfo(rel, remote!!.sizeBytes, localMtime),
remoteAfterTransfer = remote,
storeLocalMtime = false))
} }
SyncDecision.DELETE_LOCAL -> { SyncDecision.DELETE_LOCAL -> {
accessor.delete(rel) accessor.delete(rel)
@@ -203,10 +220,13 @@ class SyncEngine @Inject constructor(
rel: String, rel: String,
local: LocalFileInfo?, local: LocalFileInfo?,
remoteAfterTransfer: RemoteFile?, remoteAfterTransfer: RemoteFile?,
storeLocalMtime: Boolean = true,
) = SyncFileStateEntity( ) = SyncFileStateEntity(
syncPairId = pairId, syncPairId = pairId,
relativePath = rel, relativePath = rel,
localModifiedAt = local?.lastModifiedMs?.let { Instant.ofEpochMilli(it) }, // When storeLocalMtime=false, leave localModifiedAt null so the SKIP reconciliation
// pass on the next sync reads it from the walkFiles cursor (avoids SAF stale-mtime loops).
localModifiedAt = if (storeLocalMtime) local?.lastModifiedMs?.let { Instant.ofEpochMilli(it) } else null,
localSizeBytes = local?.sizeBytes ?: 0L, localSizeBytes = local?.sizeBytes ?: 0L,
localHash = null, localHash = null,
remoteModifiedAt = remoteAfterTransfer?.modifiedAt, remoteModifiedAt = remoteAfterTransfer?.modifiedAt,
@@ -236,12 +256,16 @@ internal fun syncDecide(
// Treat null known timestamps as "not yet recorded" — don't treat as changed. // Treat null known timestamps as "not yet recorded" — don't treat as changed.
// The SKIP reconciliation pass will fill them in on the next sync. // The SKIP reconciliation pass will fill them in on the next sync.
// Use second-precision for both sides: FAT32 has 2-second mtime resolution, WebDAV
// RFC-1123 has 1-second resolution, so millisecond comparison causes phantom "changed"
// detections and rewrite loops after a fresh download/upload.
val localChanged = known == null || val localChanged = known == null ||
(localExists && known.localModifiedAt != null && (localExists && known.localModifiedAt != null &&
local!!.lastModifiedMs != known.localModifiedAt.toEpochMilli()) local!!.lastModifiedMs / 1000 != known.localModifiedAt.epochSecond)
val remoteChanged = known == null || val remoteChanged = known == null ||
(remoteExists && known.remoteModifiedAt != null && (remoteExists && known.remoteModifiedAt != null &&
(remote!!.etag != known.remoteEtag || remote.modifiedAt != known.remoteModifiedAt)) (remote!!.etag != known.remoteEtag ||
remote.modifiedAt.epochSecond != known.remoteModifiedAt.epochSecond))
return when { return when {
!localExists && !remoteExists -> SyncDecision.SKIP !localExists && !remoteExists -> SyncDecision.SKIP
@@ -5,6 +5,7 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.syncflow.data.db.CloudAccountDao import com.syncflow.data.db.CloudAccountDao
import com.syncflow.data.db.SyncFileStateDao
import com.syncflow.data.db.SyncPairDao import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.CloudAccountEntity import com.syncflow.data.db.entities.CloudAccountEntity
import com.syncflow.data.db.entities.SyncPairEntity import com.syncflow.data.db.entities.SyncPairEntity
@@ -58,6 +59,7 @@ data class AddPairUiState(
@HiltViewModel @HiltViewModel
class AddPairViewModel @Inject constructor( class AddPairViewModel @Inject constructor(
private val syncPairDao: SyncPairDao, private val syncPairDao: SyncPairDao,
private val fileStateDao: SyncFileStateDao,
private val accountDao: CloudAccountDao, private val accountDao: CloudAccountDao,
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
savedState: SavedStateHandle, savedState: SavedStateHandle,
@@ -148,7 +150,20 @@ class AddPairViewModel @Inject constructor(
notifyOnComplete = s.notifyOnComplete, notifyOnError = s.notifyOnError, notifyOnComplete = s.notifyOnComplete, notifyOnError = s.notifyOnError,
isEnabled = true, lastSyncAt = null, lastSyncResult = SyncStatus.IDLE, pendingConflicts = 0, isEnabled = true, lastSyncAt = null, lastSyncResult = SyncStatus.IDLE, pendingConflicts = 0,
) )
if (editPairId == null) syncPairDao.insert(entity) else syncPairDao.update(entity) if (editPairId == null) {
syncPairDao.insert(entity)
} else {
val existing = syncPairDao.getById(editPairId)
syncPairDao.update(entity)
// If local or remote folder changed, old file-state records no longer
// correspond to any real path — wipe them so the next sync starts fresh
// instead of trying to delete/re-upload stale paths.
if (existing != null &&
(existing.localPath != entity.localPath || existing.remotePath != entity.remotePath)
) {
fileStateDao.deleteForPair(editPairId)
}
}
} }
.onSuccess { .onSuccess {
if (s.scheduleType == ScheduleType.ON_CHANGE) FileWatchService.start(context) if (s.scheduleType == ScheduleType.ON_CHANGE) FileWatchService.start(context)
@@ -1,5 +1,6 @@
package com.syncflow.ui.files package com.syncflow.ui.files
import android.content.ClipData
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
@@ -39,8 +40,9 @@ fun FilesScreen(
val selectedPair by vm.selectedPair.collectAsState() val selectedPair by vm.selectedPair.collectAsState()
val files by vm.files.collectAsState() val files by vm.files.collectAsState()
val isDownloading by vm.isDownloading.collectAsState() val isDownloading by vm.isDownloading.collectAsState()
val isSelectionMode by vm.isSelectionMode.collectAsState() val selectedKeys by vm.selectedKeys.collectAsState()
val selectedCount by vm.selectedCount.collectAsState() val isSelectionMode = selectedKeys.isNotEmpty()
val selectedCount = selectedKeys.size
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -56,15 +58,18 @@ fun FilesScreen(
val uri = FileProvider.getUriForFile( val uri = FileProvider.getUriForFile(
context, "${context.packageName}.fileprovider", action.file context, "${context.packageName}.fileprovider", action.file
) )
val mimeType = action.file.name.mimeType()
val intent = Intent(Intent.ACTION_VIEW).apply { val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, action.file.name.mimeType()) setDataAndType(uri, mimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) // ClipData is required so FLAG_GRANT_READ_URI_PERMISSION
} // propagates to whichever app the system chooser picks.
context.startActivity( clipData = ClipData.newRawUri("", uri)
Intent.createChooser(intent, "Open with").apply { addFlags(
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) Intent.FLAG_GRANT_READ_URI_PERMISSION or
} Intent.FLAG_ACTIVITY_NEW_TASK
) )
}
context.startActivity(intent)
} catch (e: Exception) { } catch (e: Exception) {
snackbarHostState.showSnackbar("Cannot open file: ${e.message}") snackbarHostState.showSnackbar("Cannot open file: ${e.message}")
} }
@@ -77,6 +82,7 @@ fun FilesScreen(
val intent = Intent(Intent.ACTION_SEND).apply { val intent = Intent(Intent.ACTION_SEND).apply {
type = action.file.name.mimeType() type = action.file.name.mimeType()
putExtra(Intent.EXTRA_STREAM, uri) putExtra(Intent.EXTRA_STREAM, uri)
clipData = ClipData.newRawUri("", uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
} }
context.startActivity( context.startActivity(
@@ -252,12 +258,11 @@ fun FilesScreen(
} }
} }
items(dirFiles, key = { "${it.syncPairId}_${it.relativePath}" }) { file -> items(dirFiles, key = { "${it.syncPairId}_${it.relativePath}" }) { file ->
val selected = vm.isSelected(file)
FileRow( FileRow(
file = file, file = file,
isInSubDir = dir.isNotEmpty() && !isSelectionMode, isInSubDir = dir.isNotEmpty() && !isSelectionMode,
isSelectionMode = isSelectionMode, isSelectionMode = isSelectionMode,
isSelected = selected, isSelected = vm.fileKey(file) in selectedKeys,
vm = vm, vm = vm,
) )
HorizontalDivider( HorizontalDivider(
@@ -1,6 +1,7 @@
package com.syncflow.ui.files package com.syncflow.ui.files
import android.content.Context import android.content.Context
import android.media.MediaScannerConnection
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.syncflow.data.db.SyncFileStateDao import com.syncflow.data.db.SyncFileStateDao
@@ -60,6 +61,7 @@ class FilesViewModel @Inject constructor(
val isDownloading: StateFlow<Boolean> = _isDownloading val isDownloading: StateFlow<Boolean> = _isDownloading
private val _selectedKeys = MutableStateFlow<Set<String>>(emptySet()) private val _selectedKeys = MutableStateFlow<Set<String>>(emptySet())
val selectedKeys: StateFlow<Set<String>> = _selectedKeys
val isSelectionMode: StateFlow<Boolean> = _selectedKeys.map { it.isNotEmpty() } val isSelectionMode: StateFlow<Boolean> = _selectedKeys.map { it.isNotEmpty() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
val selectedCount: StateFlow<Int> = _selectedKeys.map { it.size } val selectedCount: StateFlow<Int> = _selectedKeys.map { it.size }
@@ -70,6 +72,8 @@ class FilesViewModel @Inject constructor(
fun openFile(file: SyncFileStateEntity) { fun openFile(file: SyncFileStateEntity) {
val resolved = resolveFile(file, emitErrorIfMissing = false) val resolved = resolveFile(file, emitErrorIfMissing = false)
if (resolved != null) { if (resolved != null) {
// Ensure MediaStore knows about this file so gallery apps can open it
MediaScannerConnection.scanFile(context, arrayOf(resolved.absolutePath), null, null)
viewModelScope.launch { _fileAction.emit(FileAction.Open(resolved)) } viewModelScope.launch { _fileAction.emit(FileAction.Open(resolved)) }
} else { } else {
downloadAndOpen(file) downloadAndOpen(file)
@@ -152,7 +156,7 @@ class FilesViewModel @Inject constructor(
} }
} }
private fun fileKey(file: SyncFileStateEntity) = "${file.syncPairId}:${file.relativePath}" fun fileKey(file: SyncFileStateEntity) = "${file.syncPairId}:${file.relativePath}"
// ── Download-then-open/share ────────────────────────────────────────────── // ── Download-then-open/share ──────────────────────────────────────────────
@@ -191,6 +195,7 @@ class FilesViewModel @Inject constructor(
cacheFile.outputStream().use { out -> cacheFile.outputStream().use { out ->
provider.downloadFile("${pair.remotePath}/${file.relativePath}", out) { }.getOrThrow() provider.downloadFile("${pair.remotePath}/${file.relativePath}", out) { }.getOrThrow()
} }
MediaScannerConnection.scanFile(context, arrayOf(cacheFile.absolutePath), null, null)
cacheFile cacheFile
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Download for preview failed: ${file.relativePath}") Timber.e(e, "Download for preview failed: ${file.relativePath}")
@@ -23,6 +23,8 @@ import com.syncflow.data.db.SyncPairDao
import com.syncflow.domain.model.ScheduleType import com.syncflow.domain.model.ScheduleType
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
@@ -35,11 +37,16 @@ class FileWatchService : Service() {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val mainHandler = Handler(Looper.getMainLooper()) 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) // Multiple FileObserver instances per pair: one per directory (recursive)
private val fileObservers = mutableMapOf<Long, MutableList<FileObserver>>() private val fileObservers = mutableMapOf<Long, MutableList<FileObserver>>()
private val contentObservers = mutableMapOf<Long, ContentObserver>() private val contentObservers = mutableMapOf<Long, ContentObserver>()
private val debounceJobs = mutableMapOf<Long, Job>() 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 { companion object {
const val CHANNEL_WATCH = "sync_watching" const val CHANNEL_WATCH = "sync_watching"
@@ -78,7 +85,7 @@ class FileWatchService : Service() {
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
private suspend fun refresh() { private suspend fun refresh() = refreshMutex.withLock {
clearWatchers() clearWatchers()
val pairs = syncPairDao.getEnabled().filter { it.scheduleType == ScheduleType.ON_CHANGE } val pairs = syncPairDao.getEnabled().filter { it.scheduleType == ScheduleType.ON_CHANGE }
@@ -200,16 +207,34 @@ class FileWatchService : Service() {
if (hasNew || hasModified || hasDeleted) { if (hasNew || hasModified || hasDeleted) {
Timber.d("FileWatchService: catchup detected changes for pair $pairId, scheduling sync") Timber.d("FileWatchService: catchup detected changes for pair $pairId, scheduling sync")
val pair = syncPairDao.getById(pairId) ?: return 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) WorkManager.getInstance(applicationContext)
.enqueueUniqueWork( .enqueueUniqueWork("catchup_$pairId", ExistingWorkPolicy.KEEP, req)
"catchup_$pairId", scope.launch {
ExistingWorkPolicy.KEEP, try {
SyncWorker.buildOneTimeRequest(pairId, wifiOnly, chargingOnly), 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) { 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]?.cancel()
debounceJobs[pairId] = scope.launch { debounceJobs[pairId] = scope.launch {
delay(5_000) delay(5_000)
@@ -217,19 +242,22 @@ class FileWatchService : Service() {
if (pair == null || !pair.isEnabled) return@launch if (pair == null || !pair.isEnabled) return@launch
Timber.d("FileWatchService: triggering sync for pair $pairId after debounce") 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) val req = SyncWorker.buildOneTimeRequest(pairId, wifiOnly, chargingOnly, silent = true)
WorkManager.getInstance(applicationContext) WorkManager.getInstance(applicationContext)
.enqueueUniqueWork("onchange_$pairId", ExistingWorkPolicy.KEEP, req) .enqueueUniqueWork("onchange_$pairId", ExistingWorkPolicy.KEEP, req)
// Update notification while sync is in progress
updateNotificationDynamic("Syncing: ${pair.name}") updateNotificationDynamic("Syncing: ${pair.name}")
// Wait for completion and show result in the persistent notification
scope.launch { scope.launch {
try { try {
val info = WorkManager.getInstance(applicationContext) val info = WorkManager.getInstance(applicationContext)
.getWorkInfoByIdFlow(req.id) .getWorkInfoByIdFlow(req.id)
.first { it?.state?.isFinished == true } .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 summary = info?.outputData?.getString(SyncWorker.KEY_RESULT_SUMMARY)
val watchCount = fileObservers.keys.size + contentObservers.size val watchCount = fileObservers.keys.size + contentObservers.size
val watching = "Watching $watchCount folder${if (watchCount != 1) "s" else ""}" val watching = "Watching $watchCount folder${if (watchCount != 1) "s" else ""}"
@@ -239,8 +267,11 @@ class FileWatchService : Service() {
updateNotificationDynamic("$watching") updateNotificationDynamic("$watching")
} }
delay(12_000) delay(12_000)
updateNotificationDynamic(null) // revert to default watching text updateNotificationDynamic(null)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) { } catch (_: Exception) {
syncCooldownUntil[pairId] = System.currentTimeMillis() + 60_000
updateNotificationDynamic(null) updateNotificationDynamic(null)
} }
} }
@@ -254,6 +285,7 @@ class FileWatchService : Service() {
contentObservers.clear() contentObservers.clear()
debounceJobs.values.forEach { it.cancel() } debounceJobs.values.forEach { it.cancel() }
debounceJobs.clear() debounceJobs.clear()
syncCooldownUntil.clear()
} }
private fun ensureChannel() { private fun ensureChannel() {
@@ -1,10 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"> <vector xmlns:android="http://schemas.android.com/apk/res/android"
<gradient android:width="108dp"
android:type="radial" android:height="108dp"
android:gradientRadius="80%" android:viewportWidth="108"
android:centerX="0.5" android:viewportHeight="108">
android:centerY="0.4"
android:startColor="#1B2A3B" <!-- Pure black background -->
android:endColor="#06090E"/> <path android:pathData="M0,0 H108 V108 H0 Z"
</shape> android:fillColor="#000000"/>
</vector>
@@ -1,50 +1,76 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp" android:width="108dp"
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<!-- <!--
Circular sync icon (AutoSync-style): two 170° arcs forming a clockwise Four interlocked ring arcs (blue/red/green/orange), each a thick rounded band
rotation. Arc 1 sweeps over the bottom (right→bottom→left), Arc 2 over the arranged in a 2x2 offset so they interweave. A white sync-arrow circle sits
top (left→top→right). Arrowheads at the 9-o'clock and 3-o'clock gaps. at the center. Designed to match the braided-knot reference icon.
Circle radius 26, center (54,54).
--> -->
<!-- Arc 1: right → bottom → left (CW, small arc 170°) --> <!-- Blue ring - top-left -->
<path <path
android:pathData="M 79.9,56.3 A 26,26 0 0,1 28.1,56.3" android:strokeColor="#2979FF"
android:strokeWidth="7"
android:fillColor="#00000000" android:fillColor="#00000000"
android:strokeWidth="9" android:strokeLineCap="round"
android:strokeLineCap="round"> android:pathData="M 38,36
<aapt:attr name="android:strokeColor"> A 16,16 0 1,1 54,52
<gradient android:type="linear" A 16,16 0 1,1 38,36 Z"/>
android:startX="79.9" android:startY="56.3"
android:endX="28.1" android:endY="56.3"
android:startColor="#40C4FF"
android:endColor="#00E5FF"/>
</aapt:attr>
</path>
<!-- Arrowhead at left side (175°), pointing upward -->
<path android:pathData="M 23.9,65.7 L 28.1,56.3 L 33.9,64.8 Z" android:fillColor="#00E5FF"/>
<!-- Arc 2: left → topright (CW, small arc 170°) --> <!-- Red ring - top-right -->
<path <path
android:pathData="M 28.1,51.7 A 26,26 0 0,1 79.9,51.7" android:strokeColor="#F44336"
android:strokeWidth="7"
android:fillColor="#00000000" android:fillColor="#00000000"
android:strokeWidth="9" android:strokeLineCap="round"
android:strokeLineCap="round"> android:pathData="M 54,36
<aapt:attr name="android:strokeColor"> A 16,16 0 1,1 70,52
<gradient android:type="linear" A 16,16 0 1,1 54,36 Z"/>
android:startX="28.1" android:startY="51.7"
android:endX="79.9" android:endY="51.7" <!-- Green ring - bottom-left -->
android:startColor="#00E5FF" <path
android:endColor="#40C4FF"/> android:strokeColor="#00C853"
</aapt:attr> android:strokeWidth="7"
</path> android:fillColor="#00000000"
<!-- Arrowhead at right side (355°), pointing downward --> android:strokeLineCap="round"
<path android:pathData="M 84.1,42.3 L 79.9,51.7 L 74.1,43.2 Z" android:fillColor="#40C4FF"/> 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:fillColor="#000000"
android:pathData="M 46,52 A 8,8 0 1,0 62,52 A 8,8 0 1,0 46,52 Z"/>
<!-- Sync arrow ring (outer white circle) -->
<path
android:strokeColor="#FFFFFF"
android:strokeWidth="3.5"
android:fillColor="#00000000"
android:pathData="M 47,52 A 7,7 0 1,0 61,52 A 7,7 0 1,0 47,52 Z"/>
<!-- Sync arrow head top -->
<path
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> </vector>
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.25 VERSION_NAME=1.0.30
VERSION_CODE=26 VERSION_CODE=31