160a3e5478
Build & Release APK / build (push) Successful in 12m54s
The Add-Pair screen defaulted deleteBehavior to MIRROR for every direction, so an Upload-only backup would delete cloud files when you deleted them on the phone. Now the default follows the direction: - Upload-only / Download-only -> KEEP (deleting locally leaves the cloud copy) - Two-way -> MIRROR All three options remain selectable; once the user explicitly picks one, changing direction no longer overrides it, and editing a saved pair keeps its stored choice. Adds RecommendedDeleteBehaviorTest.
202 lines
11 KiB
Kotlin
202 lines
11 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 = recommendedDeleteBehavior(SyncDirection.TWO_WAY),
|
|
// True once the user explicitly picks a deletion behaviour, so changing direction stops
|
|
// auto-overriding their choice.
|
|
val deleteBehaviorTouched: Boolean = false,
|
|
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,
|
|
)
|
|
|
|
/**
|
|
* Safe default deletion behaviour for a given direction. One-way backups must NOT propagate a
|
|
* local deletion to the cloud (the whole point of a backup), so they default to KEEP; two-way
|
|
* sync defaults to MIRROR. The user can always override — all three options stay selectable.
|
|
*/
|
|
internal fun recommendedDeleteBehavior(direction: SyncDirection): DeleteBehavior = when (direction) {
|
|
SyncDirection.UPLOAD_ONLY, SyncDirection.DOWNLOAD_ONLY -> DeleteBehavior.KEEP
|
|
SyncDirection.TWO_WAY -> DeleteBehavior.MIRROR
|
|
}
|
|
|
|
@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,
|
|
deleteBehaviorTouched = true, // preserve the saved choice when editing
|
|
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)
|
|
|
|
/** Changing direction re-applies the safe deletion default unless the user already chose one. */
|
|
fun setDirection(direction: SyncDirection) = _state.update { s ->
|
|
s.copy(
|
|
syncDirection = direction,
|
|
deleteBehavior = if (s.deleteBehaviorTouched) s.deleteBehavior else recommendedDeleteBehavior(direction),
|
|
)
|
|
}
|
|
|
|
fun setDeleteBehavior(behavior: DeleteBehavior) = _state.update {
|
|
it.copy(deleteBehavior = behavior, deleteBehaviorTouched = true)
|
|
}
|
|
|
|
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) } }
|
|
}
|
|
}
|
|
}
|