Files
SyncFlow/app/src/main/kotlin/com/syncflow/ui/addpair/AddPairViewModel.kt
T
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

176 lines
9.3 KiB
Kotlin

package com.syncflow.ui.addpair
import android.content.Context
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
import com.syncflow.domain.model.*
import com.syncflow.worker.FileWatchService
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
data class AddPairUiState(
// ── Identity ─────────────────────────────────────────────────────────────
val name: String = "",
// ── Folders ──────────────────────────────────────────────────────────────
val localPath: String = "",
val remotePath: String = "",
val selectedAccountId: Long = -1L,
val accounts: List<CloudAccountEntity> = emptyList(),
// ── Sync type ────────────────────────────────────────────────────────────
val syncDirection: SyncDirection = SyncDirection.TWO_WAY,
val conflictStrategy: ConflictStrategy = ConflictStrategy.KEEP_NEWEST,
val deleteBehavior: DeleteBehavior = DeleteBehavior.MIRROR,
val recursive: Boolean = true,
// ── Schedule ─────────────────────────────────────────────────────────────
val scheduleType: ScheduleType = ScheduleType.INTERVAL,
val intervalMinutes: Int = 30,
val dailyTime: String = "02:00",
val weekdays: Int = 0b1111111,
// ── Constraints ──────────────────────────────────────────────────────────
val wifiOnly: Boolean = true,
val wifiSsid: String = "",
val chargingOnly: Boolean = false,
val minBatteryPct: Int = 0,
// ── File filters ─────────────────────────────────────────────────────────
val excludePatterns: String = ".DS_Store\n*.tmp\n.nomedia\nThumbs.db",
val includeExtensions: String = "",
val excludeExtensions: String = "",
val skipHiddenFiles: Boolean = true,
val minFileSizeKb: Long = 0L,
val maxFileSizeKb: Long = 0L,
// ── Notifications ────────────────────────────────────────────────────────
val notifyOnComplete: Boolean = false,
val notifyOnError: Boolean = true,
// ── Form state ───────────────────────────────────────────────────────────
val isSaving: Boolean = false,
val error: String? = null,
val done: Boolean = false,
)
@HiltViewModel
class AddPairViewModel @Inject constructor(
private val syncPairDao: SyncPairDao,
private val fileStateDao: SyncFileStateDao,
private val accountDao: CloudAccountDao,
@ApplicationContext private val context: Context,
savedState: SavedStateHandle,
) : ViewModel() {
private val editPairId = savedState.get<Long>("pairId").takeIf { it != -1L }
private val _state = MutableStateFlow(AddPairUiState())
val state = _state.asStateFlow()
init {
viewModelScope.launch {
accountDao.observeAll().collect { accounts ->
_state.update { s ->
s.copy(
accounts = accounts,
selectedAccountId = if (s.selectedAccountId == -1L) accounts.firstOrNull()?.id ?: -1L else s.selectedAccountId,
)
}
}
}
editPairId?.let { id ->
viewModelScope.launch {
syncPairDao.getById(id)?.let { pair ->
_state.update { _ ->
AddPairUiState(
name = pair.name,
localPath = pair.localPath,
remotePath = pair.remotePath,
selectedAccountId = pair.accountId,
syncDirection = pair.syncDirection,
conflictStrategy = pair.conflictStrategy,
deleteBehavior = pair.deleteBehavior,
recursive = pair.recursive,
scheduleType = pair.scheduleType,
intervalMinutes = pair.scheduleIntervalMinutes,
dailyTime = pair.scheduleDailyTime ?: "02:00",
weekdays = pair.scheduleWeekdays,
wifiOnly = pair.wifiOnly,
wifiSsid = pair.wifiSsid,
chargingOnly = pair.chargingOnly,
minBatteryPct = pair.minBatteryPct,
excludePatterns = pair.excludePatterns,
includeExtensions = pair.includeExtensions,
excludeExtensions = pair.excludeExtensions,
skipHiddenFiles = pair.skipHiddenFiles,
minFileSizeKb = pair.minFileSizeKb,
maxFileSizeKb = pair.maxFileSizeKb,
notifyOnComplete = pair.notifyOnComplete,
notifyOnError = pair.notifyOnError,
)
}
}
}
}
}
fun update(transform: AddPairUiState.() -> AddPairUiState) = _state.update(transform)
fun save() {
val s = _state.value
val errors = buildList {
if (s.name.isBlank()) add("Name is required")
if (s.localPath.isBlank()) add("Local folder is required")
if (s.remotePath.isBlank()) add("Remote folder is required")
if (s.selectedAccountId == -1L) add("Select a cloud account")
if (s.scheduleType == ScheduleType.INTERVAL && s.intervalMinutes < 15) add("Minimum interval is 15 minutes")
}
if (errors.isNotEmpty()) { _state.update { it.copy(error = errors.first()) }; return }
viewModelScope.launch {
_state.update { it.copy(isSaving = true, error = null) }
runCatching {
val entity = SyncPairEntity(
id = editPairId ?: 0L,
name = s.name, localPath = s.localPath, remotePath = s.remotePath,
accountId = s.selectedAccountId,
syncDirection = s.syncDirection, conflictStrategy = s.conflictStrategy,
deleteBehavior = s.deleteBehavior, recursive = s.recursive,
scheduleType = s.scheduleType, scheduleIntervalMinutes = s.intervalMinutes,
scheduleDailyTime = if (s.scheduleType == ScheduleType.DAILY || s.scheduleType == ScheduleType.WEEKLY) s.dailyTime else null,
scheduleWeekdays = s.weekdays,
wifiOnly = s.wifiOnly, wifiSsid = s.wifiSsid,
chargingOnly = s.chargingOnly, minBatteryPct = s.minBatteryPct,
excludePatterns = s.excludePatterns, includeExtensions = s.includeExtensions,
excludeExtensions = s.excludeExtensions, skipHiddenFiles = s.skipHiddenFiles,
minFileSizeKb = s.minFileSizeKb, maxFileSizeKb = s.maxFileSizeKb,
notifyOnComplete = s.notifyOnComplete, notifyOnError = s.notifyOnError,
isEnabled = true, lastSyncAt = null, lastSyncResult = SyncStatus.IDLE, pendingConflicts = 0,
)
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)
_state.update { it.copy(done = true) }
}
.onFailure { e -> _state.update { it.copy(isSaving = false, error = e.message) } }
}
}
}