From 34fb06a673a0f6570728c7702e9b97b30f2b4195 Mon Sep 17 00:00:00 2001 From: Amir Khodak Date: Mon, 25 May 2026 04:18:13 +0000 Subject: [PATCH] v1.0.28: fix sync rewrite/delete loop, Avast-inspired icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../com/syncflow/domain/sync/SyncEngine.kt | 17 ++- .../syncflow/ui/addpair/AddPairViewModel.kt | 17 ++- .../res/drawable/ic_launcher_background.xml | 17 ++- .../res/drawable/ic_launcher_foreground.xml | 139 +++++++++--------- version.properties | 4 +- 5 files changed, 113 insertions(+), 81 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 788cbfb..0158ba7 100644 --- a/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt +++ b/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt @@ -126,7 +126,9 @@ class SyncEngine @Inject constructor( logEvent(pair.id, SyncEventType.FILE_DOWNLOADED, rel, null, bytes) FileOutcome(downloaded = 1, bytesTransferred = bytes, newState = buildState(pair.id, rel, - LocalFileInfo(rel, remote!!.sizeBytes, localMtime), remoteAfterTransfer = remote)) + LocalFileInfo(rel, remote!!.sizeBytes, localMtime), + remoteAfterTransfer = remote, + storeLocalMtime = false)) } SyncDecision.DELETE_LOCAL -> { accessor.delete(rel) @@ -203,10 +205,13 @@ class SyncEngine @Inject constructor( rel: String, local: LocalFileInfo?, remoteAfterTransfer: RemoteFile?, + storeLocalMtime: Boolean = true, ) = SyncFileStateEntity( syncPairId = pairId, 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, localHash = null, remoteModifiedAt = remoteAfterTransfer?.modifiedAt, @@ -236,12 +241,16 @@ internal fun syncDecide( // 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. + // 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 || (localExists && known.localModifiedAt != null && - local!!.lastModifiedMs != known.localModifiedAt.toEpochMilli()) + local!!.lastModifiedMs / 1000 != known.localModifiedAt.epochSecond) val remoteChanged = known == 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 { !localExists && !remoteExists -> SyncDecision.SKIP diff --git a/app/src/main/kotlin/com/syncflow/ui/addpair/AddPairViewModel.kt b/app/src/main/kotlin/com/syncflow/ui/addpair/AddPairViewModel.kt index 7558316..29f88b3 100644 --- a/app/src/main/kotlin/com/syncflow/ui/addpair/AddPairViewModel.kt +++ b/app/src/main/kotlin/com/syncflow/ui/addpair/AddPairViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.syncflow.data.db.CloudAccountDao +import com.syncflow.data.db.SyncFileStateDao import com.syncflow.data.db.SyncPairDao import com.syncflow.data.db.entities.CloudAccountEntity import com.syncflow.data.db.entities.SyncPairEntity @@ -58,6 +59,7 @@ data class AddPairUiState( @HiltViewModel class AddPairViewModel @Inject constructor( private val syncPairDao: SyncPairDao, + private val fileStateDao: SyncFileStateDao, private val accountDao: CloudAccountDao, @ApplicationContext private val context: Context, savedState: SavedStateHandle, @@ -148,7 +150,20 @@ class AddPairViewModel @Inject constructor( notifyOnComplete = s.notifyOnComplete, notifyOnError = s.notifyOnError, 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 { if (s.scheduleType == ScheduleType.ON_CHANGE) FileWatchService.start(context) diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 5de05e6..1efe9e4 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -6,14 +6,19 @@ android:viewportWidth="108" android:viewportHeight="108"> - - + + + + + + android:gradientRadius="60" + android:centerX="54" android:centerY="50" + android:startColor="#3D3A50" + android:endColor="#00000000"/> diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 4a63486..e3a3f0b 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,23 +1,12 @@ - + + 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 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"> + + android:startX="22" android:startY="22" + android:endX="43" android:endY="42" + android:startColor="#00BFA5" + android:endColor="#26D6C0"/> - + - + 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"> + + android:startX="86" android:startY="22" + android:endX="65" android:endY="42" + android:startColor="#E53935" + android:endColor="#EF6558"/> - + + 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"> + + + + - + + + + + android:strokeColor="#4000BFA5"/> - - - - - + + diff --git a/version.properties b/version.properties index e6dc9f9..51e12db 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=1.0.27 -VERSION_CODE=28 +VERSION_NAME=1.0.28 +VERSION_CODE=29