From c60eb8d27bf50fc9d4b58294a1c325cb533abba9 Mon Sep 17 00:00:00 2001 From: Amir Khodak Date: Wed, 27 May 2026 20:07:25 +0000 Subject: [PATCH] v1.0.63: live sync progress counters, pause/resume, .gitignore fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 1 + .../com/syncflow/data/db/SyncDatabase.kt | 13 +++-- .../com/syncflow/data/db/SyncPairDao.kt | 4 +- .../data/db/entities/SyncPairEntity.kt | 5 ++ .../main/kotlin/com/syncflow/di/AppModule.kt | 2 +- .../com/syncflow/domain/sync/SyncEngine.kt | 31 +++++++++-- .../com/syncflow/ui/files/FilesScreen.kt | 46 ++++++++++------ .../kotlin/com/syncflow/ui/home/HomeScreen.kt | 54 ++++++++++++++++++- .../com/syncflow/ui/home/HomeViewModel.kt | 21 ++++++++ .../ui/pairdetail/PairDetailScreen.kt | 50 +++++++++++++++-- .../ui/pairdetail/PairDetailViewModel.kt | 16 ++++++ .../com/syncflow/ui/shared/SyncProgress.kt | 3 ++ .../kotlin/com/syncflow/worker/SyncWorker.kt | 13 ++++- version.properties | 4 +- 14 files changed, 227 insertions(+), 36 deletions(-) create mode 100644 app/src/main/kotlin/com/syncflow/ui/shared/SyncProgress.kt diff --git a/.gitignore b/.gitignore index e22edec..43646e3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.iml .gradle/ +.kotlin/ local.properties .idea/ .DS_Store diff --git a/app/src/main/kotlin/com/syncflow/data/db/SyncDatabase.kt b/app/src/main/kotlin/com/syncflow/data/db/SyncDatabase.kt index 0f40a2c..5c1cc30 100644 --- a/app/src/main/kotlin/com/syncflow/data/db/SyncDatabase.kt +++ b/app/src/main/kotlin/com/syncflow/data/db/SyncDatabase.kt @@ -15,20 +15,27 @@ import com.syncflow.data.db.entities.* SyncConflictEntity::class, SyncEventEntity::class, ], - version = 3, + version = 4, exportSchema = true, ) @TypeConverters(DbConverters::class) abstract class SyncDatabase : RoomDatabase() { companion object { - // Wipe file states: timestamps were stored as epoch-seconds, now epoch-millis. - // All previously saved states are wrong so we drop and re-learn on next sync. val MIGRATION_2_3 = object : Migration(2, 3) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("DELETE FROM sync_file_states") } } + + val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE sync_pairs ADD COLUMN lastSyncUploaded INTEGER NOT NULL DEFAULT 0") + db.execSQL("ALTER TABLE sync_pairs ADD COLUMN lastSyncDownloaded INTEGER NOT NULL DEFAULT 0") + db.execSQL("ALTER TABLE sync_pairs ADD COLUMN lastSyncDeleted INTEGER NOT NULL DEFAULT 0") + db.execSQL("ALTER TABLE sync_pairs ADD COLUMN lastSyncBytesTransferred INTEGER NOT NULL DEFAULT 0") + } + } } abstract fun cloudAccountDao(): CloudAccountDao abstract fun syncPairDao(): SyncPairDao diff --git a/app/src/main/kotlin/com/syncflow/data/db/SyncPairDao.kt b/app/src/main/kotlin/com/syncflow/data/db/SyncPairDao.kt index 45f64ac..504d497 100644 --- a/app/src/main/kotlin/com/syncflow/data/db/SyncPairDao.kt +++ b/app/src/main/kotlin/com/syncflow/data/db/SyncPairDao.kt @@ -29,8 +29,8 @@ interface SyncPairDao { @Delete suspend fun delete(entity: SyncPairEntity) - @Query("UPDATE sync_pairs SET lastSyncAt = :at, lastSyncResult = :result, pendingConflicts = :conflicts WHERE id = :id") - suspend fun updateSyncResult(id: Long, at: Instant, result: SyncStatus, conflicts: Int) + @Query("UPDATE sync_pairs SET lastSyncAt = :at, lastSyncResult = :result, pendingConflicts = :conflicts, lastSyncUploaded = :uploaded, lastSyncDownloaded = :downloaded, lastSyncDeleted = :deleted, lastSyncBytesTransferred = :bytes WHERE id = :id") + suspend fun updateSyncResult(id: Long, at: Instant, result: SyncStatus, conflicts: Int, uploaded: Int, downloaded: Int, deleted: Int, bytes: Long) @Query("UPDATE sync_pairs SET lastSyncResult = :status WHERE id = :id") suspend fun updateStatus(id: Long, status: SyncStatus) diff --git a/app/src/main/kotlin/com/syncflow/data/db/entities/SyncPairEntity.kt b/app/src/main/kotlin/com/syncflow/data/db/entities/SyncPairEntity.kt index b751a43..267d927 100644 --- a/app/src/main/kotlin/com/syncflow/data/db/entities/SyncPairEntity.kt +++ b/app/src/main/kotlin/com/syncflow/data/db/entities/SyncPairEntity.kt @@ -53,6 +53,11 @@ data class SyncPairEntity( val lastSyncAt: Instant?, val lastSyncResult: SyncStatus, val pendingConflicts: Int, + // Last sync outcome counters (persist across pause/resume) + val lastSyncUploaded: Int = 0, + val lastSyncDownloaded: Int = 0, + val lastSyncDeleted: Int = 0, + val lastSyncBytesTransferred: Long = 0L, ) fun SyncPairEntity.toDomain() = SyncPair( diff --git a/app/src/main/kotlin/com/syncflow/di/AppModule.kt b/app/src/main/kotlin/com/syncflow/di/AppModule.kt index 919516f..07b293e 100644 --- a/app/src/main/kotlin/com/syncflow/di/AppModule.kt +++ b/app/src/main/kotlin/com/syncflow/di/AppModule.kt @@ -22,7 +22,7 @@ object AppModule { fun provideDatabase(@ApplicationContext ctx: Context): SyncDatabase = Room.databaseBuilder(ctx, SyncDatabase::class.java, "syncflow.db") .fallbackToDestructiveMigrationFrom(1) - .addMigrations(SyncDatabase.MIGRATION_2_3) + .addMigrations(SyncDatabase.MIGRATION_2_3, SyncDatabase.MIGRATION_3_4) .build() @Provides fun provideCloudAccountDao(db: SyncDatabase): CloudAccountDao = db.cloudAccountDao() 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 d27b2cc..339f382 100644 --- a/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt +++ b/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt @@ -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 -> { diff --git a/app/src/main/kotlin/com/syncflow/ui/files/FilesScreen.kt b/app/src/main/kotlin/com/syncflow/ui/files/FilesScreen.kt index 1ce6910..431364c 100644 --- a/app/src/main/kotlin/com/syncflow/ui/files/FilesScreen.kt +++ b/app/src/main/kotlin/com/syncflow/ui/files/FilesScreen.kt @@ -51,6 +51,16 @@ fun FilesScreen( val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() + val accounts by vm.accounts.collectAsState() + var selectedAccountId by remember { mutableStateOf(-1L) } + + LaunchedEffect(accounts) { + if (selectedAccountId == -1L && accounts.isNotEmpty()) selectedAccountId = accounts.first().id + } + + val cloudLabel = accounts.find { it.id == selectedAccountId }?.displayName + ?: accounts.firstOrNull()?.displayName + ?: "Cloud" LaunchedEffect(Unit) { vm.fileAction.collect { action -> @@ -108,26 +118,29 @@ fun FilesScreen( selected = activeTab == 0, onClick = { activeTab = 0 }, shape = SegmentedButtonDefaults.itemShape(0, 2), + icon = { SegmentedButtonDefaults.Icon(active = activeTab == 0, activeContent = { Icon(Icons.Default.PhoneAndroid, null, Modifier.size(16.dp)) }) }, ) { - Icon(Icons.Default.PhoneAndroid, null, Modifier.size(16.dp)) - Spacer(Modifier.width(6.dp)) Text("Phone") } SegmentedButton( selected = activeTab == 1, onClick = { activeTab = 1 }, shape = SegmentedButtonDefaults.itemShape(1, 2), + icon = { SegmentedButtonDefaults.Icon(active = activeTab == 1, activeContent = { Icon(Icons.Default.Cloud, null, Modifier.size(16.dp)) }) }, ) { - Icon(Icons.Default.Cloud, null, Modifier.size(16.dp)) - Spacer(Modifier.width(6.dp)) - Text("Cloud") + Text(cloudLabel, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis) } } HorizontalDivider() when (activeTab) { 0 -> LocalExplorer(modifier = Modifier.weight(1f)) - 1 -> CloudExplorer(vm = vm, modifier = Modifier.weight(1f)) + 1 -> CloudExplorer( + vm = vm, + selectedAccountId = selectedAccountId, + onAccountSelect = { selectedAccountId = it }, + modifier = Modifier.weight(1f), + ) } } @@ -372,9 +385,13 @@ private fun LocalExplorer(modifier: Modifier = Modifier) { // ── Cloud file explorer ─────────────────────────────────────────────────────── @Composable -private fun CloudExplorer(vm: FilesViewModel, modifier: Modifier = Modifier) { +private fun CloudExplorer( + vm: FilesViewModel, + selectedAccountId: Long, + onAccountSelect: (Long) -> Unit, + modifier: Modifier = Modifier, +) { val accounts by vm.accounts.collectAsState() - var selectedId by remember { mutableStateOf(-1L) } val cloudVm: RemoteBrowserViewModel = hiltViewModel(key = "cloud_explorer") val state by cloudVm.state.collectAsState() val breadcrumbState = rememberLazyListState() @@ -382,11 +399,8 @@ private fun CloudExplorer(vm: FilesViewModel, modifier: Modifier = Modifier) { var searchQuery by remember { mutableStateOf("") } var searchActive by remember { mutableStateOf(false) } - LaunchedEffect(accounts) { - if (selectedId == -1L && accounts.isNotEmpty()) { - selectedId = accounts.first().id - cloudVm.init(selectedId, "/") - } + LaunchedEffect(selectedAccountId) { + if (selectedAccountId != -1L) cloudVm.init(selectedAccountId, "/") } LaunchedEffect(state.currentPath) { @@ -426,8 +440,8 @@ private fun CloudExplorer(vm: FilesViewModel, modifier: Modifier = Modifier) { ) { items(accounts) { acct -> FilterChip( - selected = acct.id == selectedId, - onClick = { selectedId = acct.id; cloudVm.init(acct.id, "/") }, + selected = acct.id == selectedAccountId, + onClick = { onAccountSelect(acct.id); cloudVm.init(acct.id, "/") }, label = { Text(acct.displayName, maxLines = 1) }, leadingIcon = { Icon(Icons.Default.Cloud, null, Modifier.size(14.dp)) }, ) @@ -520,7 +534,7 @@ private fun CloudExplorer(vm: FilesViewModel, modifier: Modifier = Modifier) { file = entry, onClick = { if (entry.isDirectory) cloudVm.navigateTo(entry.path) - else vm.openCloudFile(selectedId, entry.path) + else vm.openCloudFile(selectedAccountId, entry.path) }, ) } diff --git a/app/src/main/kotlin/com/syncflow/ui/home/HomeScreen.kt b/app/src/main/kotlin/com/syncflow/ui/home/HomeScreen.kt index 97f00b7..5aeae22 100644 --- a/app/src/main/kotlin/com/syncflow/ui/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/syncflow/ui/home/HomeScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.syncflow.data.db.entities.SyncPairEntity import com.syncflow.domain.model.SyncStatus +import com.syncflow.ui.shared.SyncProgress import java.time.Duration import java.time.Instant import java.time.ZoneId @@ -38,6 +39,7 @@ fun HomeScreen( vm: HomeViewModel = hiltViewModel(), ) { val pairs by vm.syncPairs.collectAsState() + val progressMap by vm.syncProgressMap.collectAsState() if (pairs.isEmpty()) { EmptyState(modifier = modifier.fillMaxSize(), onAdd = onAddPair) @@ -50,6 +52,7 @@ fun HomeScreen( items(pairs, key = { it.id }) { pair -> SyncPairCard( pair = pair, + progress = progressMap[pair.id], onClick = { onPairClick(pair.id) }, onSync = { vm.triggerSync(pair) }, onToggle = { vm.toggleEnabled(pair) }, @@ -64,6 +67,7 @@ fun HomeScreen( @Composable private fun SyncPairCard( pair: SyncPairEntity, + progress: SyncProgress? = null, onClick: () -> Unit, onSync: () -> Unit, onToggle: () -> Unit, @@ -176,7 +180,7 @@ private fun SyncPairCard( SyncStatus.SYNCING -> FilledTonalIconButton(onClick = onPause, modifier = Modifier.size(36.dp)) { Icon(Icons.Default.Pause, "Pause sync", modifier = Modifier.size(18.dp)) } - SyncStatus.PAUSED -> FilledTonalIconButton(onClick = onSync, modifier = Modifier.size(36.dp)) { + SyncStatus.PAUSED -> FilledTonalIconButton(onClick = onSync, modifier = Modifier.size(36.dp), enabled = pair.isEnabled) { Icon(Icons.Default.PlayArrow, "Resume sync", modifier = Modifier.size(18.dp)) } else -> FilledTonalIconButton(onClick = onSync, modifier = Modifier.size(36.dp)) { @@ -184,6 +188,47 @@ private fun SyncPairCard( } } } + + val displayProgress = when { + pair.lastSyncResult == SyncStatus.SYNCING -> progress + pair.lastSyncUploaded > 0 || pair.lastSyncDownloaded > 0 || pair.lastSyncDeleted > 0 -> + SyncProgress(pair.lastSyncUploaded, pair.lastSyncDownloaded, pair.lastSyncDeleted, pair.lastSyncBytesTransferred) + else -> null + } + if (pair.lastSyncResult == SyncStatus.SYNCING && displayProgress == null) { + Text( + "Starting…", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } else if (displayProgress != null) { + Row( + modifier = Modifier.padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (displayProgress.uploaded > 0) { + Icon(Icons.Default.ArrowUpward, null, Modifier.size(11.dp), tint = MaterialTheme.colorScheme.primary) + Text("${displayProgress.uploaded}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary) + } + if (displayProgress.downloaded > 0) { + Icon(Icons.Default.ArrowDownward, null, Modifier.size(11.dp), tint = MaterialTheme.colorScheme.secondary) + Text("${displayProgress.downloaded}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.secondary) + } + if (displayProgress.deleted > 0) { + Icon(Icons.Default.DeleteOutline, null, Modifier.size(11.dp), tint = MaterialTheme.colorScheme.error) + Text("${displayProgress.deleted}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error) + } + if (displayProgress.bytesTransferred > 0) { + Text( + "· ${displayProgress.bytesTransferred.toDisplaySize()}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } } } } @@ -251,6 +296,13 @@ private fun EmptyState(modifier: Modifier = Modifier, onAdd: () -> Unit) { } } +private fun Long.toDisplaySize(): String = when { + this < 1_024 -> "$this B" + this < 1_048_576 -> "${"%.1f".format(this / 1_024.0)} KB" + this < 1_073_741_824 -> "${"%.1f".format(this / 1_048_576.0)} MB" + else -> "${"%.1f".format(this / 1_073_741_824.0)} GB" +} + private val SyncStatus.accentColor: Color @Composable get() = when (this) { SyncStatus.SUCCESS -> Color(0xFF2E7D32) diff --git a/app/src/main/kotlin/com/syncflow/ui/home/HomeViewModel.kt b/app/src/main/kotlin/com/syncflow/ui/home/HomeViewModel.kt index 7dd822c..5166f84 100644 --- a/app/src/main/kotlin/com/syncflow/ui/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/syncflow/ui/home/HomeViewModel.kt @@ -4,16 +4,20 @@ import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.WorkInfo import androidx.work.WorkManager +import androidx.work.WorkQuery import com.syncflow.data.db.SyncPairDao import com.syncflow.data.db.entities.SyncPairEntity import com.syncflow.domain.model.ScheduleType import com.syncflow.domain.model.SyncStatus +import com.syncflow.ui.shared.SyncProgress import com.syncflow.worker.FileWatchService import com.syncflow.worker.SyncWorker import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -28,6 +32,23 @@ class HomeViewModel @Inject constructor( val syncPairs = syncPairDao.observeAll() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + val syncProgressMap: kotlinx.coroutines.flow.StateFlow> = + workManager.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.RUNNING)) + .map { infos -> + infos + .mapNotNull { info -> + val tag = info.tags.firstOrNull { it.startsWith("sync_") } ?: return@mapNotNull null + val pairId = tag.removePrefix("sync_").toLongOrNull() ?: return@mapNotNull null + val up = info.progress.getInt(SyncWorker.KEY_PROGRESS_UPLOADED, 0) + val down = info.progress.getInt(SyncWorker.KEY_PROGRESS_DOWNLOADED, 0) + val del = info.progress.getInt(SyncWorker.KEY_PROGRESS_DELETED, 0) + val bytes = info.progress.getLong(SyncWorker.KEY_PROGRESS_BYTES, 0L) + if (up > 0 || down > 0 || del > 0) pairId to SyncProgress(up, down, del, bytes) else null + } + .toMap() + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) + fun triggerSync(pair: SyncPairEntity) { val req = SyncWorker.buildOneTimeRequest(pair.id, wifiOnly = false, chargingOnly = false) workManager.enqueue(req) diff --git a/app/src/main/kotlin/com/syncflow/ui/pairdetail/PairDetailScreen.kt b/app/src/main/kotlin/com/syncflow/ui/pairdetail/PairDetailScreen.kt index 6132570..463051b 100644 --- a/app/src/main/kotlin/com/syncflow/ui/pairdetail/PairDetailScreen.kt +++ b/app/src/main/kotlin/com/syncflow/ui/pairdetail/PairDetailScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.syncflow.data.db.entities.SyncPairEntity import com.syncflow.domain.model.SyncStatus +import com.syncflow.ui.shared.SyncProgress import com.syncflow.ui.shared.SyncEventRow import java.time.Duration import java.time.Instant @@ -40,6 +41,7 @@ fun PairDetailScreen( val pair by vm.pair.collectAsState() val events by vm.events.collectAsState() val conflictCount by vm.unresolvedConflicts.collectAsState() + val syncProgress by vm.syncProgress.collectAsState() var showDelete by remember { mutableStateOf(false) } if (showDelete) { @@ -70,7 +72,7 @@ fun PairDetailScreen( SyncStatus.SYNCING -> IconButton(onClick = { vm.pauseSync() }) { Icon(Icons.Default.Pause, "Pause sync") } - SyncStatus.PAUSED -> IconButton(onClick = { vm.syncNow() }) { + SyncStatus.PAUSED -> IconButton(onClick = { vm.syncNow() }, enabled = pair?.isEnabled == true) { Icon(Icons.Default.PlayArrow, "Resume sync") } else -> IconButton(onClick = { vm.syncNow() }) { @@ -88,7 +90,7 @@ fun PairDetailScreen( verticalArrangement = Arrangement.spacedBy(12.dp), ) { item { - pair?.let { p -> StatusBanner(p) } + pair?.let { p -> StatusBanner(p, syncProgress) } } item { @@ -148,7 +150,7 @@ fun PairDetailScreen( } @Composable -private fun StatusBanner(pair: SyncPairEntity) { +private fun StatusBanner(pair: SyncPairEntity, progress: SyncProgress? = null) { val (icon, label, containerColor) = when (pair.lastSyncResult) { SyncStatus.SUCCESS -> Triple(Icons.Default.CheckCircle, "Synced", MaterialTheme.colorScheme.primaryContainer) SyncStatus.SYNCING -> Triple(Icons.Default.Sync, "Syncing…", MaterialTheme.colorScheme.secondaryContainer) @@ -181,8 +183,39 @@ private fun StatusBanner(pair: SyncPairEntity) { Spacer(Modifier.width(16.dp)) Column { Text(label, style = MaterialTheme.typography.titleMedium) - pair.lastSyncAt?.let { - Text(it.toRelativeString(), style = MaterialTheme.typography.bodySmall) + val displayProgress = when { + pair.lastSyncResult == SyncStatus.SYNCING -> progress + pair.lastSyncUploaded > 0 || pair.lastSyncDownloaded > 0 || pair.lastSyncDeleted > 0 -> + SyncProgress(pair.lastSyncUploaded, pair.lastSyncDownloaded, pair.lastSyncDeleted, pair.lastSyncBytesTransferred) + else -> null + } + if (pair.lastSyncResult == SyncStatus.SYNCING && displayProgress == null) { + Text("Starting…", style = MaterialTheme.typography.bodySmall) + } else if (displayProgress != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (displayProgress.uploaded > 0) { + Icon(Icons.Default.ArrowUpward, null, Modifier.size(12.dp)) + Text("${displayProgress.uploaded} up", style = MaterialTheme.typography.bodySmall) + } + if (displayProgress.downloaded > 0) { + Icon(Icons.Default.ArrowDownward, null, Modifier.size(12.dp)) + Text("${displayProgress.downloaded} down", style = MaterialTheme.typography.bodySmall) + } + if (displayProgress.deleted > 0) { + Icon(Icons.Default.DeleteOutline, null, Modifier.size(12.dp)) + Text("${displayProgress.deleted} del", style = MaterialTheme.typography.bodySmall) + } + if (displayProgress.bytesTransferred > 0) { + Text("· ${displayProgress.bytesTransferred.toDisplaySize()}", style = MaterialTheme.typography.bodySmall) + } + } + } else { + pair.lastSyncAt?.let { + Text(it.toRelativeString(), style = MaterialTheme.typography.bodySmall) + } } } } @@ -240,6 +273,13 @@ private fun InfoRow( } } +private fun Long.toDisplaySize(): String = when { + this < 1_024 -> "$this B" + this < 1_048_576 -> "${"%.1f".format(this / 1_024.0)} KB" + this < 1_073_741_824 -> "${"%.1f".format(this / 1_048_576.0)} MB" + else -> "${"%.1f".format(this / 1_073_741_824.0)} GB" +} + private fun String.toDisplayPath(): String { val decoded = java.net.URLDecoder.decode(this, "UTF-8") return decoded.trimEnd('/').substringAfterLast('/').substringAfterLast(':').ifEmpty { decoded } diff --git a/app/src/main/kotlin/com/syncflow/ui/pairdetail/PairDetailViewModel.kt b/app/src/main/kotlin/com/syncflow/ui/pairdetail/PairDetailViewModel.kt index 917d796..ff89717 100644 --- a/app/src/main/kotlin/com/syncflow/ui/pairdetail/PairDetailViewModel.kt +++ b/app/src/main/kotlin/com/syncflow/ui/pairdetail/PairDetailViewModel.kt @@ -3,6 +3,7 @@ package com.syncflow.ui.pairdetail import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.work.WorkInfo import androidx.work.WorkManager import com.syncflow.data.db.SyncConflictDao import com.syncflow.data.db.SyncEventDao @@ -10,7 +11,9 @@ import com.syncflow.data.db.SyncPairDao import com.syncflow.domain.model.SyncStatus import com.syncflow.worker.SyncWorker import dagger.hilt.android.lifecycle.HiltViewModel +import com.syncflow.ui.shared.SyncProgress import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -35,6 +38,19 @@ class PairDetailViewModel @Inject constructor( val unresolvedConflicts = conflictDao.observeUnresolvedCount(pairId) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) + val syncProgress = workManager.getWorkInfosByTagFlow("sync_$pairId") + .map { infos -> + infos.firstOrNull { it.state == WorkInfo.State.RUNNING }?.progress?.let { data -> + SyncProgress( + uploaded = data.getInt(SyncWorker.KEY_PROGRESS_UPLOADED, 0), + downloaded = data.getInt(SyncWorker.KEY_PROGRESS_DOWNLOADED, 0), + deleted = data.getInt(SyncWorker.KEY_PROGRESS_DELETED, 0), + bytesTransferred = data.getLong(SyncWorker.KEY_PROGRESS_BYTES, 0L), + ).takeIf { it.uploaded > 0 || it.downloaded > 0 || it.deleted > 0 } + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + fun syncNow() { val p = pair.value ?: return workManager.enqueue(SyncWorker.buildOneTimeRequest(p.id, wifiOnly = false, chargingOnly = false)) diff --git a/app/src/main/kotlin/com/syncflow/ui/shared/SyncProgress.kt b/app/src/main/kotlin/com/syncflow/ui/shared/SyncProgress.kt new file mode 100644 index 0000000..b636068 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/ui/shared/SyncProgress.kt @@ -0,0 +1,3 @@ +package com.syncflow.ui.shared + +data class SyncProgress(val uploaded: Int, val downloaded: Int, val deleted: Int, val bytesTransferred: Long) diff --git a/app/src/main/kotlin/com/syncflow/worker/SyncWorker.kt b/app/src/main/kotlin/com/syncflow/worker/SyncWorker.kt index 70278ff..592ddc3 100644 --- a/app/src/main/kotlin/com/syncflow/worker/SyncWorker.kt +++ b/app/src/main/kotlin/com/syncflow/worker/SyncWorker.kt @@ -47,7 +47,14 @@ class SyncWorker @AssistedInject constructor( return try { val domainPair = pair.toDomain() val provider = providerFactory.create(account) - val result = syncEngine.sync(domainPair, provider) + val result = syncEngine.sync(domainPair, provider) { up, down, del, bytes -> + setProgress(workDataOf( + KEY_PROGRESS_UPLOADED to up, + KEY_PROGRESS_DOWNLOADED to down, + KEY_PROGRESS_DELETED to del, + KEY_PROGRESS_BYTES to bytes, + )) + } val lines = buildList { if (result.uploaded > 0) add("↑${result.uploaded}") @@ -158,6 +165,10 @@ class SyncWorker @AssistedInject constructor( const val KEY_PAIR_ID = "pair_id" const val KEY_SILENT = "silent" const val KEY_RESULT_SUMMARY = "result_summary" + const val KEY_PROGRESS_UPLOADED = "prog_up" + const val KEY_PROGRESS_DOWNLOADED = "prog_down" + const val KEY_PROGRESS_DELETED = "prog_del" + const val KEY_PROGRESS_BYTES = "prog_bytes" private const val NOTIFICATION_ID = 1001 private const val RESULT_ID_OFFSET = 2000 private const val CHANNEL_PROGRESS = "sync_progress" diff --git a/version.properties b/version.properties index 9027291..9ea2c86 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=1.0.59 -VERSION_CODE=60 +VERSION_NAME=1.0.63 +VERSION_CODE=64