From 08dc4f5bd4a8986a4f35f313b3ec10f8c5bdaf4f Mon Sep 17 00:00:00 2001 From: Amir Khodak Date: Sun, 24 May 2026 23:25:58 +0000 Subject: [PATCH] v1.0.23: functional Files tab, background service persistence, startup indexer, curved icon - FilesScreen: per-file context menu (Open, Share, Rename, Delete), rename dialog, delete confirmation, FileProvider-based open/share intents, Snackbar error feedback - FilesViewModel: FileAction sealed class + SharedFlow; openFile, shareFile, deleteFile, renameFile with DB cleanup; resolveFile handles SAF primary: URIs - FileWatchService: stopWithTask=false keeps watcher alive after app swipe-away; catchupScan on startup detects changes missed while service was not running; SyncFileStateDao injected; FileObserver used for real-path SAF URIs - BootReceiver: handles MY_PACKAGE_REPLACED to restart service after app update - file_paths.xml: added external-path so FileProvider can serve /storage/emulated/0 files - ic_launcher_foreground: three curved stroke-based arrows (quadratic bezier, round caps) Co-Authored-By: Claude Sonnet 4.6 --- app/src/main/AndroidManifest.xml | 3 + .../com/syncflow/ui/files/FilesScreen.kt | 430 ++++++++++++------ .../com/syncflow/ui/files/FilesViewModel.kt | 80 +++- .../com/syncflow/worker/BootReceiver.kt | 3 +- .../com/syncflow/worker/FileWatchService.kt | 32 ++ .../res/drawable/ic_launcher_foreground.xml | 127 +++--- app/src/main/res/xml/file_paths.xml | 1 + version.properties | 4 +- 8 files changed, 472 insertions(+), 208 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e503d70..07f4295 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -66,13 +66,16 @@ android:exported="true"> + + 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 0a84541..b2970f1 100644 --- a/app/src/main/kotlin/com/syncflow/ui/files/FilesScreen.kt +++ b/app/src/main/kotlin/com/syncflow/ui/files/FilesScreen.kt @@ -1,5 +1,7 @@ package com.syncflow.ui.files +import android.content.Intent +import android.webkit.MimeTypeMap import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -10,10 +12,14 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider import androidx.hilt.navigation.compose.hiltViewModel import com.syncflow.data.db.entities.SyncFileStateEntity +import kotlinx.coroutines.launch import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @@ -27,156 +33,222 @@ fun FilesScreen( val pairs by vm.pairs.collectAsState() val selectedPair by vm.selectedPair.collectAsState() val files by vm.files.collectAsState() + val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() - Column(modifier = modifier.fillMaxSize()) { - // Pair selector chips - if (pairs.size > 1) { - ScrollableTabRow( - selectedTabIndex = pairs.indexOfFirst { it.id == selectedPair?.id }.coerceAtLeast(0), - edgePadding = 16.dp, - containerColor = MaterialTheme.colorScheme.surface, - divider = {}, - ) { - pairs.forEach { pair -> - Tab( - selected = pair.id == selectedPair?.id, - onClick = { vm.selectPair(pair.id) }, - text = { - Text( - pair.name, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - ) - } - } - HorizontalDivider() - } - - if (pairs.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - Icons.Default.FolderOpen, - contentDescription = null, - modifier = Modifier.size(72.dp), - tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), - ) - Text("No sync pairs yet", style = MaterialTheme.typography.titleMedium) - Text( - "Create a sync pair to browse its files", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } else if (files.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - Icons.Default.FolderOpen, - contentDescription = null, - modifier = Modifier.size(72.dp), - tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), - ) - Text("No synced files yet", style = MaterialTheme.typography.titleMedium) - Text( - "Run a sync to populate this view", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } else { - // Summary row - Surface( - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - "${files.size} file${if (files.size != 1) "s" else ""}", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - val totalBytes = files.sumOf { it.localSizeBytes } - Text( - totalBytes.toDisplaySize(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - LazyColumn( - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(0.dp), - ) { - // Group by top-level directory - val grouped = files.groupBy { f -> - val slashIdx = f.relativePath.indexOf('/') - if (slashIdx < 0) "" else f.relativePath.substring(0, slashIdx) - } - grouped.forEach { (dir, dirFiles) -> - if (dir.isNotEmpty()) { - item(key = "dir_$dir") { - Row( - modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - Icons.Default.Folder, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(Modifier.width(6.dp)) - Text( - dir, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - ) - } + LaunchedEffect(Unit) { + vm.fileAction.collect { action -> + when (action) { + is FileAction.Open -> { + try { + val uri = FileProvider.getUriForFile( + context, "${context.packageName}.fileprovider", action.file + ) + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, action.file.name.mimeType()) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) } + context.startActivity( + Intent.createChooser(intent, "Open with").apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + ) + } catch (e: Exception) { + snackbarHostState.showSnackbar("Cannot open file: ${e.message}") } - items(dirFiles, key = { "${it.syncPairId}_${it.relativePath}" }) { file -> - FileRow(file, isInSubDir = dir.isNotEmpty()) - HorizontalDivider( - color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f), - modifier = Modifier.padding(start = if (dir.isNotEmpty()) 38.dp else 16.dp), + } + is FileAction.Share -> { + try { + val uri = FileProvider.getUriForFile( + context, "${context.packageName}.fileprovider", action.file + ) + val intent = Intent(Intent.ACTION_SEND).apply { + type = action.file.name.mimeType() + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity( + Intent.createChooser(intent, "Share via").apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + ) + } catch (e: Exception) { + snackbarHostState.showSnackbar("Cannot share file: ${e.message}") + } + } + is FileAction.Error -> scope.launch { + snackbarHostState.showSnackbar(action.message) + } + } + } + } + + Box(modifier = modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + if (pairs.size > 1) { + ScrollableTabRow( + selectedTabIndex = pairs.indexOfFirst { it.id == selectedPair?.id }.coerceAtLeast(0), + edgePadding = 16.dp, + containerColor = MaterialTheme.colorScheme.surface, + divider = {}, + ) { + pairs.forEach { pair -> + Tab( + selected = pair.id == selectedPair?.id, + onClick = { vm.selectPair(pair.id) }, + text = { Text(pair.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, ) } } - item { Spacer(Modifier.height(80.dp)) } + HorizontalDivider() } + + when { + pairs.isEmpty() -> FilesEmptyState( + icon = Icons.Default.FolderOpen, + title = "No sync pairs yet", + subtitle = "Create a sync pair to browse its files", + ) + files.isEmpty() -> FilesEmptyState( + icon = Icons.Default.FolderOpen, + title = "No synced files yet", + subtitle = "Run a sync to populate this view", + ) + else -> { + Surface(color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "${files.size} file${if (files.size != 1) "s" else ""}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + files.sumOf { it.localSizeBytes }.toDisplaySize(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + LazyColumn( + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + val grouped = files.groupBy { f -> + val idx = f.relativePath.indexOf('/') + if (idx < 0) "" else f.relativePath.substring(0, idx) + } + grouped.forEach { (dir, dirFiles) -> + if (dir.isNotEmpty()) { + item(key = "dir_$dir") { + Row( + modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Default.Folder, contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(6.dp)) + Text( + dir, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + items(dirFiles, key = { "${it.syncPairId}_${it.relativePath}" }) { file -> + FileRow(file = file, isInSubDir = dir.isNotEmpty(), vm = vm) + HorizontalDivider( + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f), + modifier = Modifier.padding( + start = if (dir.isNotEmpty()) 38.dp else 16.dp + ), + ) + } + } + item { Spacer(Modifier.height(80.dp)) } + } + } + } + } + + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } +} + +@Composable +private fun FilesEmptyState(icon: ImageVector, title: String, subtitle: String) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + icon, contentDescription = null, + modifier = Modifier.size(72.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), + ) + Text(title, style = MaterialTheme.typography.titleMedium) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } @Composable -private fun FileRow(file: SyncFileStateEntity, isInSubDir: Boolean) { +private fun FileRow(file: SyncFileStateEntity, isInSubDir: Boolean, vm: FilesViewModel) { val name = file.relativePath.substringAfterLast('/') val timeFmt = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT) val syncedAt = file.lastSyncedAt.atZone(ZoneId.systemDefault()).let { timeFmt.format(it) } + var menuExpanded by remember { mutableStateOf(false) } + var showRenameDialog by remember { mutableStateOf(false) } + var showDeleteDialog by remember { mutableStateOf(false) } + + if (showRenameDialog) { + RenameDialog( + currentName = name, + onConfirm = { newName -> + vm.renameFile(file, newName) + showRenameDialog = false + }, + onDismiss = { showRenameDialog = false }, + ) + } + + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + icon = { Icon(Icons.Default.Delete, contentDescription = null) }, + title = { Text("Delete file?") }, + text = { Text("\"$name\" will be removed from this device.") }, + confirmButton = { + TextButton(onClick = { vm.deleteFile(file); showDeleteDialog = false }) { + Text("Delete", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { Text("Cancel") } + }, + ) + } + Row( modifier = Modifier .fillMaxWidth() @@ -194,8 +266,7 @@ private fun FileRow(file: SyncFileStateEntity, isInSubDir: Boolean) { ) { Box(contentAlignment = Alignment.Center) { Icon( - fileIcon(name), - contentDescription = null, + fileIcon(name), contentDescription = null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSecondaryContainer, ) @@ -215,16 +286,87 @@ private fun FileRow(file: SyncFileStateEntity, isInSubDir: Boolean) { color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - // Sync status indicator - Icon( - Icons.Default.CheckCircle, - contentDescription = "Synced", - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f), - ) + Box { + IconButton(onClick = { menuExpanded = true }, modifier = Modifier.size(36.dp)) { + Icon( + Icons.Default.MoreVert, contentDescription = "File options", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) { + DropdownMenuItem( + text = { Text("Open") }, + leadingIcon = { Icon(Icons.Default.OpenInNew, contentDescription = null) }, + onClick = { menuExpanded = false; vm.openFile(file) }, + ) + DropdownMenuItem( + text = { Text("Share") }, + leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) }, + onClick = { menuExpanded = false; vm.shareFile(file) }, + ) + HorizontalDivider() + DropdownMenuItem( + text = { Text("Rename") }, + leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) }, + onClick = { menuExpanded = false; showRenameDialog = true }, + ) + DropdownMenuItem( + text = { Text("Delete", color = MaterialTheme.colorScheme.error) }, + leadingIcon = { + Icon( + Icons.Default.Delete, contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + onClick = { menuExpanded = false; showDeleteDialog = true }, + ) + } + } } } +@Composable +private fun RenameDialog( + currentName: String, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var newName by remember { mutableStateOf(currentName) } + AlertDialog( + onDismissRequest = onDismiss, + icon = { Icon(Icons.Default.Edit, contentDescription = null) }, + title = { Text("Rename file") }, + text = { + OutlinedTextField( + value = newName, + onValueChange = { newName = it }, + label = { Text("New name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + TextButton( + onClick = { + val trimmed = newName.trim() + if (trimmed.isNotBlank() && trimmed != currentName) onConfirm(trimmed) + else onDismiss() + }, + enabled = newName.isNotBlank(), + ) { Text("Rename") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} + +private fun String.mimeType(): String { + val ext = substringAfterLast('.', "").lowercase() + return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: "*/*" +} + private fun fileIcon(name: String) = when { name.endsWith(".pdf", ignoreCase = true) -> Icons.Default.PictureAsPdf name.endsWith(".jpg", ignoreCase = true) || @@ -247,7 +389,7 @@ private fun fileIcon(name: String) = when { } private fun Long.toDisplaySize(): String = when { - this < 1_024 -> "${this} B" + 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" diff --git a/app/src/main/kotlin/com/syncflow/ui/files/FilesViewModel.kt b/app/src/main/kotlin/com/syncflow/ui/files/FilesViewModel.kt index da1b5f9..baf0fac 100644 --- a/app/src/main/kotlin/com/syncflow/ui/files/FilesViewModel.kt +++ b/app/src/main/kotlin/com/syncflow/ui/files/FilesViewModel.kt @@ -1,5 +1,6 @@ package com.syncflow.ui.files +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.syncflow.data.db.SyncFileStateDao @@ -7,15 +8,26 @@ import com.syncflow.data.db.SyncPairDao import com.syncflow.data.db.entities.SyncFileStateEntity import com.syncflow.data.db.entities.SyncPairEntity import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber +import java.io.File import javax.inject.Inject +sealed class FileAction { + data class Open(val file: File) : FileAction() + data class Share(val file: File) : FileAction() + data class Error(val message: String) : FileAction() +} + @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class FilesViewModel @Inject constructor( - syncPairDao: SyncPairDao, + private val syncPairDao: SyncPairDao, private val fileStateDao: SyncFileStateDao, + @ApplicationContext private val context: Context, ) : ViewModel() { val pairs: StateFlow> = syncPairDao.observeAll() @@ -35,5 +47,71 @@ class FilesViewModel @Inject constructor( } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + private val _fileAction = MutableSharedFlow() + val fileAction: SharedFlow = _fileAction + fun selectPair(id: Long) { _selectedPairId.value = id } + + fun openFile(file: SyncFileStateEntity) { + val resolved = resolveFile(file) ?: return + viewModelScope.launch { _fileAction.emit(FileAction.Open(resolved)) } + } + + fun shareFile(file: SyncFileStateEntity) { + val resolved = resolveFile(file) ?: return + viewModelScope.launch { _fileAction.emit(FileAction.Share(resolved)) } + } + + fun deleteFile(file: SyncFileStateEntity) { + viewModelScope.launch { + try { + val resolved = resolveFile(file) + resolved?.delete() + fileStateDao.delete(file.syncPairId, file.relativePath) + } catch (e: Exception) { + Timber.e(e, "Delete failed: ${file.relativePath}") + _fileAction.emit(FileAction.Error("Delete failed: ${e.message}")) + } + } + } + + fun renameFile(file: SyncFileStateEntity, newName: String) { + viewModelScope.launch { + try { + val resolved = resolveFile(file) ?: return@launch + val parent = resolved.parentFile ?: return@launch + val dest = File(parent, newName) + if (!resolved.renameTo(dest)) { + _fileAction.emit(FileAction.Error("Rename failed")) + return@launch + } + // Update DB: delete old state; the next sync will re-detect as a new upload + fileStateDao.delete(file.syncPairId, file.relativePath) + } catch (e: Exception) { + Timber.e(e, "Rename failed: ${file.relativePath}") + _fileAction.emit(FileAction.Error("Rename failed: ${e.message}")) + } + } + } + + private fun resolveFile(file: SyncFileStateEntity): File? { + val pair = selectedPair.value ?: return null + val root = safTreeUriToRealPath(pair.localPath) ?: pair.localPath + val f = File(root, file.relativePath) + if (!f.exists()) { + viewModelScope.launch { _fileAction.emit(FileAction.Error("File not found on device")) } + return null + } + return f + } + + private fun safTreeUriToRealPath(uriString: String): String? { + if (!uriString.startsWith("content://")) return uriString + return try { + val treeUri = android.net.Uri.parse(uriString) + val docId = android.provider.DocumentsContract.getTreeDocumentId(treeUri) + if (docId.startsWith("primary:")) "/storage/emulated/0/${docId.removePrefix("primary:")}" + else null + } catch (e: Exception) { null } + } } diff --git a/app/src/main/kotlin/com/syncflow/worker/BootReceiver.kt b/app/src/main/kotlin/com/syncflow/worker/BootReceiver.kt index 0d715eb..8dcde4e 100644 --- a/app/src/main/kotlin/com/syncflow/worker/BootReceiver.kt +++ b/app/src/main/kotlin/com/syncflow/worker/BootReceiver.kt @@ -18,7 +18,8 @@ class BootReceiver : BroadcastReceiver() { @Inject lateinit var syncPairDao: SyncPairDao override fun onReceive(context: Context, intent: Intent) { - if (intent.action != Intent.ACTION_BOOT_COMPLETED) return + val validActions = setOf(Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED) + if (intent.action !in validActions) return val wm = WorkManager.getInstance(context) val pending = goAsync() CoroutineScope(Dispatchers.IO).launch { diff --git a/app/src/main/kotlin/com/syncflow/worker/FileWatchService.kt b/app/src/main/kotlin/com/syncflow/worker/FileWatchService.kt index 6d36d3c..43853d1 100644 --- a/app/src/main/kotlin/com/syncflow/worker/FileWatchService.kt +++ b/app/src/main/kotlin/com/syncflow/worker/FileWatchService.kt @@ -16,6 +16,7 @@ import androidx.work.ExistingWorkPolicy import androidx.work.WorkManager import com.syncflow.MainActivity import com.syncflow.R +import com.syncflow.data.db.SyncFileStateDao import com.syncflow.data.db.SyncPairDao import com.syncflow.domain.model.ScheduleType import dagger.hilt.android.AndroidEntryPoint @@ -28,6 +29,7 @@ import javax.inject.Inject class FileWatchService : Service() { @Inject lateinit var syncPairDao: SyncPairDao + @Inject lateinit var fileStateDao: SyncFileStateDao private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val mainHandler = Handler(Looper.getMainLooper()) @@ -151,6 +153,36 @@ class FileWatchService : Service() { observer.startWatching() fileObservers[pairId] = observer Timber.d("FileWatchService: watching filesystem path for pair $pairId: $path") + // Check if anything changed while the service was not running + scope.launch { catchupScan(pairId, dir, wifiOnly, chargingOnly) } + } + + private suspend fun catchupScan(pairId: Long, dir: File, wifiOnly: Boolean, chargingOnly: Boolean) { + val known = fileStateDao.getForPair(pairId).associateBy { it.relativePath } + if (known.isEmpty()) return // Never synced — first sync will be triggered manually + + val current = mutableMapOf() + dir.walk().filter { it.isFile }.forEach { f -> + current[f.relativeTo(dir).path.replace('\\', '/')] = f.lastModified() + } + + val hasNew = current.any { (rel, _) -> rel !in known } + val hasModified = current.any { (rel, mtime) -> + val s = known[rel]; s != null && s.localModifiedAt != null && + s.localModifiedAt.toEpochMilli() != mtime + } + val hasDeleted = known.keys.any { rel -> rel !in current } + + if (hasNew || hasModified || hasDeleted) { + Timber.d("FileWatchService: catchup detected changes for pair $pairId, scheduling sync") + val pair = syncPairDao.getById(pairId) ?: return + WorkManager.getInstance(applicationContext) + .enqueueUniqueWork( + "catchup_$pairId", + ExistingWorkPolicy.KEEP, + SyncWorker.buildOneTimeRequest(pairId, wifiOnly, chargingOnly), + ) + } } private fun onChangeDetected(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean) { diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 3e2a027..311b83f 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -6,83 +6,90 @@ android:viewportWidth="108" android:viewportHeight="108"> - + + android:pathData="M54,54m-30,0a30,30 0 1,0 60,0a30,30 0 1,0 -60,0" + android:fillColor="#0AFFFFFF"/> - - - - + + + + - - - - - - - - - - + + - - - - - - + android:startX="72" android:startY="36" + android:endX="88" android:endY="36" + android:startColor="#FFB347" + android:endColor="#FF8C42"/> - - - + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index dc41c8a..9c5159d 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -1,5 +1,6 @@ + diff --git a/version.properties b/version.properties index e5d07e3..9ecb0e6 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=1.0.22 -VERSION_CODE=23 +VERSION_NAME=1.0.23 +VERSION_CODE=24