Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c60eb8d27b |
@@ -1,5 +1,6 @@
|
||||
*.iml
|
||||
.gradle/
|
||||
.kotlin/
|
||||
local.properties
|
||||
.idea/
|
||||
.DS_Store
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Map<Long, SyncProgress>> =
|
||||
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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.syncflow.ui.shared
|
||||
|
||||
data class SyncProgress(val uploaded: Int, val downloaded: Int, val deleted: Int, val bytesTransferred: Long)
|
||||
@@ -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"
|
||||
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
VERSION_NAME=1.0.59
|
||||
VERSION_CODE=60
|
||||
VERSION_NAME=1.0.63
|
||||
VERSION_CODE=64
|
||||
|
||||
Reference in New Issue
Block a user