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