v1.0.63: live sync progress counters, pause/resume, .gitignore fix
Build & Release APK / build (push) Has been cancelled
Build & Release APK / build (push) Has been cancelled
- SyncEngine: accepts onProgress callback — emits uploaded/downloaded/ deleted/bytes counts atomically as each file completes - SyncWorker: streams progress to WorkManager data so the UI can poll it live; reports per-run counters in the completion notification; adds pause/resume support - HomeViewModel/PairDetailViewModel: subscribe to live WorkManager progress and surface it via SyncProgress state - SyncPairEntity/SyncPairDao/SyncDatabase: persist last-run counters (uploaded, downloaded, deleted, bytesTransferred) in the DB with a Room migration (v3→v4) - AppModule: provides WorkManager as an injectable singleton - .gitignore: add .kotlin/ to exclude compiler session files Security: no new issues — all logging via Timber (debug-only), DB queries use Room parameterized API, file sharing via FileProvider. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,8 @@ import kotlinx.coroutines.sync.withPermit
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import javax.inject.Inject
|
||||
|
||||
class SyncEngine @Inject constructor(
|
||||
@@ -33,24 +35,28 @@ class SyncEngine @Inject constructor(
|
||||
private val eventDao: SyncEventDao,
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
suspend fun sync(pair: SyncPair, provider: CloudProvider): SyncResult {
|
||||
suspend fun sync(
|
||||
pair: SyncPair,
|
||||
provider: CloudProvider,
|
||||
onProgress: (suspend (uploaded: Int, downloaded: Int, deleted: Int, bytesTransferred: Long) -> Unit)? = null,
|
||||
): SyncResult {
|
||||
syncPairDao.updateStatus(pair.id, SyncStatus.SYNCING)
|
||||
logEvent(pair.id, SyncEventType.SYNC_STARTED, null, null, 0)
|
||||
|
||||
return try {
|
||||
val result = performSync(pair, provider)
|
||||
val result = performSync(pair, provider, onProgress = onProgress)
|
||||
val finalStatus = when {
|
||||
result.failedFiles > 0 && result.conflicts > 0 -> SyncStatus.CONFLICT
|
||||
result.failedFiles > 0 -> SyncStatus.PARTIAL
|
||||
result.conflicts > 0 -> SyncStatus.CONFLICT
|
||||
else -> SyncStatus.SUCCESS
|
||||
}
|
||||
syncPairDao.updateSyncResult(pair.id, Instant.now(), finalStatus, result.conflicts)
|
||||
syncPairDao.updateSyncResult(pair.id, Instant.now(), finalStatus, result.conflicts, result.uploaded, result.downloaded, result.deleted, result.bytesTransferred)
|
||||
logEvent(pair.id, SyncEventType.SYNC_COMPLETED, null, "↑${result.uploaded} ↓${result.downloaded} ✕${result.deleted} ✗${result.failedFiles}", result.bytesTransferred)
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Sync failed for pair ${pair.id}")
|
||||
syncPairDao.updateSyncResult(pair.id, Instant.now(), SyncStatus.FAILED, 0)
|
||||
syncPairDao.updateSyncResult(pair.id, Instant.now(), SyncStatus.FAILED, 0, 0, 0, 0, 0L)
|
||||
logEvent(pair.id, SyncEventType.SYNC_FAILED, null, e.message, 0)
|
||||
SyncResult(failedFiles = 1, error = e)
|
||||
}
|
||||
@@ -66,6 +72,7 @@ class SyncEngine @Inject constructor(
|
||||
pair: SyncPair,
|
||||
provider: CloudProvider,
|
||||
isRetry: Boolean = false,
|
||||
onProgress: (suspend (Int, Int, Int, Long) -> Unit)? = null,
|
||||
): SyncResult {
|
||||
val accessor = makeAccessor(pair.localPath)
|
||||
var knownStates = fileStateDao.getForPair(pair.id).associateBy { it.relativePath }
|
||||
@@ -81,12 +88,16 @@ class SyncEngine @Inject constructor(
|
||||
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)
|
||||
return performSync(pair, provider, isRetry = true, onProgress = onProgress)
|
||||
}
|
||||
|
||||
val allPaths = (localFiles.keys + remoteFiles.keys + knownStates.keys).toSet()
|
||||
val hasPriorSyncState = knownStates.isNotEmpty()
|
||||
val semaphore = Semaphore(4)
|
||||
val uploadedAtomic = AtomicInteger(0)
|
||||
val downloadedAtomic = AtomicInteger(0)
|
||||
val deletedAtomic = AtomicInteger(0)
|
||||
val bytesAtomic = AtomicLong(0L)
|
||||
|
||||
// Each async block returns its outcome; no shared mutable state across coroutines.
|
||||
data class FileOutcome(
|
||||
@@ -119,6 +130,9 @@ class SyncEngine @Inject constructor(
|
||||
return@withPermit FileOutcome(failed = 1)
|
||||
}
|
||||
logEvent(pair.id, SyncEventType.FILE_UPLOADED, rel, null, bytes)
|
||||
val up = uploadedAtomic.incrementAndGet()
|
||||
bytesAtomic.addAndGet(bytes)
|
||||
onProgress?.invoke(up, downloadedAtomic.get(), deletedAtomic.get(), bytesAtomic.get())
|
||||
// Don't store remote metadata from upload response — the server (Nextcloud etc.)
|
||||
// may change mtime/etag during post-upload processing. Leaving remoteModifiedAt
|
||||
// null forces the SKIP reconciliation on the next sync to fill it in from the
|
||||
@@ -142,6 +156,9 @@ class SyncEngine @Inject constructor(
|
||||
.getOrDefault(System.currentTimeMillis()).takeIf { it > 0L }
|
||||
?: System.currentTimeMillis()
|
||||
logEvent(pair.id, SyncEventType.FILE_DOWNLOADED, rel, null, bytes)
|
||||
val down = downloadedAtomic.incrementAndGet()
|
||||
bytesAtomic.addAndGet(bytes)
|
||||
onProgress?.invoke(uploadedAtomic.get(), down, deletedAtomic.get(), bytesAtomic.get())
|
||||
FileOutcome(downloaded = 1, bytesTransferred = bytes,
|
||||
newState = buildState(pair.id, rel,
|
||||
LocalFileInfo(rel, remote!!.sizeBytes, localMtime),
|
||||
@@ -153,6 +170,8 @@ class SyncEngine @Inject constructor(
|
||||
if (!deleted) Timber.w("SyncEngine: DELETE_LOCAL failed (silent) for $rel")
|
||||
fileStateDao.delete(pair.id, rel)
|
||||
logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "local", 0)
|
||||
val del = deletedAtomic.incrementAndGet()
|
||||
onProgress?.invoke(uploadedAtomic.get(), downloadedAtomic.get(), del, bytesAtomic.get())
|
||||
FileOutcome(deleted = 1)
|
||||
}
|
||||
SyncDecision.DELETE_REMOTE -> {
|
||||
@@ -170,6 +189,8 @@ class SyncEngine @Inject constructor(
|
||||
fileStateDao.delete(pair.id, rel)
|
||||
logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "remote", 0)
|
||||
}
|
||||
val del = deletedAtomic.incrementAndGet()
|
||||
onProgress?.invoke(uploadedAtomic.get(), downloadedAtomic.get(), del, bytesAtomic.get())
|
||||
FileOutcome(deleted = 1)
|
||||
}
|
||||
SyncDecision.CONFLICT -> {
|
||||
|
||||
Reference in New Issue
Block a user