v1.0.63: live sync progress counters, pause/resume, .gitignore fix
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:
2026-05-27 20:07:25 +00:00
parent 21b7ffc7b3
commit c60eb8d27b
14 changed files with 227 additions and 36 deletions
@@ -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 -> {