Compare commits

...

2 Commits

Author SHA1 Message Date
amir 146b8baf9a v1.0.24: harmonious icon, recursive file watching, download-then-open, security fixes
Icon: three identical parallel arcing arrows (same bezier curve, same blue-to-teal
gradient #64C8FF→#32EDBB, same arrowhead geometry) — visually cohesive and clearly
visible against the near-black background.

FileWatchService: FileObserver is now recursive — watchDirRecursive() creates an
observer for each subdirectory at startup, and adds new watchers when CREATE events
produce new directories. Fixes files added to subdirectories not being detected.

FilesViewModel: openFile/shareFile now fall back to download-then-open when the file
is absent locally. AccountRepository + ProviderFactory injected; downloads to
context.cacheDir/syncflow_open/ with isDownloading state. Path traversal guard added
(reject relativePath containing ".."). file_paths.xml gains cache-path entry.

WebDavProvider: path-traversal guard in parsePropfind — skip any server-returned
filename containing "..", "/" or "\". Replace android.util.Log with Timber so debug
logs are stripped from release builds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 00:37:16 +00:00
amir 08dc4f5bd4 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 <noreply@anthropic.com>
2026-05-24 23:25:58 +00:00
10 changed files with 595 additions and 233 deletions
+3
View File
@@ -66,13 +66,16 @@
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" /> <action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter> </intent-filter>
</receiver> </receiver>
<!-- File watcher for ON_CHANGE sync pairs --> <!-- File watcher for ON_CHANGE sync pairs -->
<!-- stopWithTask=false keeps the service alive when the user swipes the app away -->
<service <service
android:name=".worker.FileWatchService" android:name=".worker.FileWatchService"
android:foregroundServiceType="dataSync|shortService" android:foregroundServiceType="dataSync|shortService"
android:stopWithTask="false"
android:exported="false" /> android:exported="false" />
<!-- Required on API 29+ so WorkManager can start a typed foreground service --> <!-- Required on API 29+ so WorkManager can start a typed foreground service -->
@@ -1,7 +1,7 @@
package com.syncflow.data.providers.webdav package com.syncflow.data.providers.webdav
import android.util.Log
import com.syncflow.data.providers.CloudProvider import com.syncflow.data.providers.CloudProvider
import timber.log.Timber
import com.syncflow.domain.model.CloudAccount import com.syncflow.domain.model.CloudAccount
import com.syncflow.domain.model.RemoteFile import com.syncflow.domain.model.RemoteFile
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -59,14 +59,13 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val req = Request.Builder().url(baseUrl).method("PROPFIND", PROPFIND_BODY).header("Depth", "0").build() val req = Request.Builder().url(baseUrl).method("PROPFIND", PROPFIND_BODY).header("Depth", "0").build()
client.newCall(req).execute().use { resp -> client.newCall(req).execute().use { resp ->
Log.d("SyncFlow/WebDAV", "testConnection ${resp.code} url=$baseUrl") Timber.d("WebDAV testConnection %d url=%s", resp.code, baseUrl)
if (!resp.isSuccessful && resp.code != 207) { if (!resp.isSuccessful && resp.code != 207) {
val body = resp.body?.string()?.take(300) ?: "" throw Exception("HTTP ${resp.code} ${resp.message}")
throw Exception("HTTP ${resp.code} ${resp.message}$body")
} }
} }
} }
}.onFailure { e -> Log.e("SyncFlow/WebDAV", "testConnection failed: ${e::class.simpleName}: ${e.message}", e.cause) } }.onFailure { e -> Timber.e(e, "WebDAV testConnection failed") }
override suspend fun listFiles(remotePath: String): Result<List<RemoteFile>> = runCatching { override suspend fun listFiles(remotePath: String): Result<List<RemoteFile>> = runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -192,12 +191,17 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
"response" -> if (inResponse && href.isNotBlank()) { "response" -> if (inResponse && href.isNotBlank()) {
val rawName = href.trimEnd('/').substringAfterLast('/') val rawName = href.trimEnd('/').substringAfterLast('/')
val name = try { java.net.URLDecoder.decode(rawName, "UTF-8") } catch (_: Exception) { rawName } val name = try { java.net.URLDecoder.decode(rawName, "UTF-8") } catch (_: Exception) { rawName }
// Guard against path-traversal sequences delivered by a malicious server
if (name.contains("..") || name.contains('/') || name.contains('\\')) {
inResponse = false
} else {
val relPath = "$parentPath/$name".replace("//", "/") val relPath = "$parentPath/$name".replace("//", "/")
results.add(RemoteFile(relPath, name, isCollection, contentLength, lastModified, etag, contentType)) results.add(RemoteFile(relPath, name, isCollection, contentLength, lastModified, etag, contentType))
inResponse = false inResponse = false
} }
} }
} }
}
eventType = parser.next() eventType = parser.next()
} }
return if (dropFirst) results.drop(1) else results return if (dropFirst) results.drop(1) else results
@@ -12,6 +12,7 @@ import javax.inject.Singleton
class CredentialStore @Inject constructor(@ApplicationContext private val context: Context) { class CredentialStore @Inject constructor(@ApplicationContext private val context: Context) {
private val prefs: SharedPreferences by lazy { private val prefs: SharedPreferences by lazy {
@Suppress("DEPRECATION")
val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
EncryptedSharedPreferences.create( EncryptedSharedPreferences.create(
"syncflow_credentials", "syncflow_credentials",
@@ -1,5 +1,7 @@
package com.syncflow.ui.files package com.syncflow.ui.files
import android.content.Intent
import android.webkit.MimeTypeMap
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -10,10 +12,14 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.data.db.entities.SyncFileStateEntity import com.syncflow.data.db.entities.SyncFileStateEntity
import kotlinx.coroutines.launch
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle import java.time.format.FormatStyle
@@ -27,9 +33,60 @@ fun FilesScreen(
val pairs by vm.pairs.collectAsState() val pairs by vm.pairs.collectAsState()
val selectedPair by vm.selectedPair.collectAsState() val selectedPair by vm.selectedPair.collectAsState()
val files by vm.files.collectAsState() val files by vm.files.collectAsState()
val isDownloading by vm.isDownloading.collectAsState()
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
Column(modifier = modifier.fillMaxSize()) { LaunchedEffect(Unit) {
// Pair selector chips 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}")
}
}
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) { if (pairs.size > 1) {
ScrollableTabRow( ScrollableTabRow(
selectedTabIndex = pairs.indexOfFirst { it.id == selectedPair?.id }.coerceAtLeast(0), selectedTabIndex = pairs.indexOfFirst { it.id == selectedPair?.id }.coerceAtLeast(0),
@@ -41,70 +98,26 @@ fun FilesScreen(
Tab( Tab(
selected = pair.id == selectedPair?.id, selected = pair.id == selectedPair?.id,
onClick = { vm.selectPair(pair.id) }, onClick = { vm.selectPair(pair.id) },
text = { text = { Text(pair.name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
Text(
pair.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
) )
} }
} }
HorizontalDivider() HorizontalDivider()
} }
if (pairs.isEmpty()) { when {
Box( pairs.isEmpty() -> FilesEmptyState(
modifier = Modifier.fillMaxSize(), icon = Icons.Default.FolderOpen,
contentAlignment = Alignment.Center, title = "No sync pairs yet",
) { subtitle = "Create a sync pair to browse its files",
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) files.isEmpty() -> FilesEmptyState(
Text( icon = Icons.Default.FolderOpen,
"Create a sync pair to browse its files", title = "No synced files yet",
style = MaterialTheme.typography.bodySmall, subtitle = "Run a sync to populate this view",
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} else -> {
} Surface(color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) {
} 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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -117,9 +130,8 @@ fun FilesScreen(
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
val totalBytes = files.sumOf { it.localSizeBytes }
Text( Text(
totalBytes.toDisplaySize(), files.sumOf { it.localSizeBytes }.toDisplaySize(),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -130,10 +142,9 @@ fun FilesScreen(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(0.dp), verticalArrangement = Arrangement.spacedBy(0.dp),
) { ) {
// Group by top-level directory
val grouped = files.groupBy { f -> val grouped = files.groupBy { f ->
val slashIdx = f.relativePath.indexOf('/') val idx = f.relativePath.indexOf('/')
if (slashIdx < 0) "" else f.relativePath.substring(0, slashIdx) if (idx < 0) "" else f.relativePath.substring(0, idx)
} }
grouped.forEach { (dir, dirFiles) -> grouped.forEach { (dir, dirFiles) ->
if (dir.isNotEmpty()) { if (dir.isNotEmpty()) {
@@ -143,8 +154,7 @@ fun FilesScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon( Icon(
Icons.Default.Folder, Icons.Default.Folder, contentDescription = null,
contentDescription = null,
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
) )
@@ -158,10 +168,12 @@ fun FilesScreen(
} }
} }
items(dirFiles, key = { "${it.syncPairId}_${it.relativePath}" }) { file -> items(dirFiles, key = { "${it.syncPairId}_${it.relativePath}" }) { file ->
FileRow(file, isInSubDir = dir.isNotEmpty()) FileRow(file = file, isInSubDir = dir.isNotEmpty(), vm = vm)
HorizontalDivider( HorizontalDivider(
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f), color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f),
modifier = Modifier.padding(start = if (dir.isNotEmpty()) 38.dp else 16.dp), modifier = Modifier.padding(
start = if (dir.isNotEmpty()) 38.dp else 16.dp
),
) )
} }
} }
@@ -171,12 +183,98 @@ fun FilesScreen(
} }
} }
if (isDownloading) {
Box(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter),
) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
tonalElevation = 4.dp,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
Text("Downloading for preview…", style = MaterialTheme.typography.bodySmall)
}
}
}
}
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}
@Composable @Composable
private fun FileRow(file: SyncFileStateEntity, isInSubDir: Boolean) { 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, vm: FilesViewModel) {
val name = file.relativePath.substringAfterLast('/') val name = file.relativePath.substringAfterLast('/')
val timeFmt = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT) val timeFmt = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
val syncedAt = file.lastSyncedAt.atZone(ZoneId.systemDefault()).let { timeFmt.format(it) } 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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -194,8 +292,7 @@ private fun FileRow(file: SyncFileStateEntity, isInSubDir: Boolean) {
) { ) {
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {
Icon( Icon(
fileIcon(name), fileIcon(name), contentDescription = null,
contentDescription = null,
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer, tint = MaterialTheme.colorScheme.onSecondaryContainer,
) )
@@ -215,14 +312,85 @@ private fun FileRow(file: SyncFileStateEntity, isInSubDir: Boolean) {
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
// Sync status indicator Box {
IconButton(onClick = { menuExpanded = true }, modifier = Modifier.size(36.dp)) {
Icon( Icon(
Icons.Default.CheckCircle, Icons.Default.MoreVert, contentDescription = "File options",
contentDescription = "Synced", modifier = Modifier.size(18.dp),
modifier = Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant,
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f),
) )
} }
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 { private fun fileIcon(name: String) = when {
@@ -247,7 +415,7 @@ private fun fileIcon(name: String) = when {
} }
private fun Long.toDisplaySize(): 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_048_576 -> "${"%.1f".format(this / 1_024.0)} KB"
this < 1_073_741_824 -> "${"%.1f".format(this / 1_048_576.0)} MB" this < 1_073_741_824 -> "${"%.1f".format(this / 1_048_576.0)} MB"
else -> "${"%.1f".format(this / 1_073_741_824.0)} GB" else -> "${"%.1f".format(this / 1_073_741_824.0)} GB"
@@ -1,21 +1,37 @@
package com.syncflow.ui.files package com.syncflow.ui.files
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.syncflow.data.db.SyncFileStateDao import com.syncflow.data.db.SyncFileStateDao
import com.syncflow.data.db.SyncPairDao import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.SyncFileStateEntity import com.syncflow.data.db.entities.SyncFileStateEntity
import com.syncflow.data.db.entities.SyncPairEntity import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.data.providers.ProviderFactory
import com.syncflow.data.repository.AccountRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.File
import javax.inject.Inject 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) @OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel @HiltViewModel
class FilesViewModel @Inject constructor( class FilesViewModel @Inject constructor(
syncPairDao: SyncPairDao, private val syncPairDao: SyncPairDao,
private val fileStateDao: SyncFileStateDao, private val fileStateDao: SyncFileStateDao,
private val accountRepository: AccountRepository,
private val providerFactory: ProviderFactory,
@ApplicationContext private val context: Context,
) : ViewModel() { ) : ViewModel() {
val pairs: StateFlow<List<SyncPairEntity>> = syncPairDao.observeAll() val pairs: StateFlow<List<SyncPairEntity>> = syncPairDao.observeAll()
@@ -35,5 +51,140 @@ class FilesViewModel @Inject constructor(
} }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val _fileAction = MutableSharedFlow<FileAction>()
val fileAction: SharedFlow<FileAction> = _fileAction
private val _isDownloading = MutableStateFlow(false)
val isDownloading: StateFlow<Boolean> = _isDownloading
fun selectPair(id: Long) { _selectedPairId.value = id } fun selectPair(id: Long) { _selectedPairId.value = id }
fun openFile(file: SyncFileStateEntity) {
val resolved = resolveFile(file, emitErrorIfMissing = false)
if (resolved != null) {
viewModelScope.launch { _fileAction.emit(FileAction.Open(resolved)) }
} else {
downloadAndOpen(file)
}
}
fun shareFile(file: SyncFileStateEntity) {
val resolved = resolveFile(file, emitErrorIfMissing = false)
if (resolved != null) {
viewModelScope.launch { _fileAction.emit(FileAction.Share(resolved)) }
} else {
downloadAndShare(file)
}
}
fun deleteFile(file: SyncFileStateEntity) {
viewModelScope.launch {
try {
val resolved = resolveFile(file, emitErrorIfMissing = false)
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
}
fileStateDao.delete(file.syncPairId, file.relativePath)
} catch (e: Exception) {
Timber.e(e, "Rename failed: ${file.relativePath}")
_fileAction.emit(FileAction.Error("Rename failed: ${e.message}"))
}
}
}
// ── Download-then-open/share ──────────────────────────────────────────────
private fun downloadAndOpen(file: SyncFileStateEntity) {
viewModelScope.launch {
downloadToCache(file)?.let { cached ->
_fileAction.emit(FileAction.Open(cached))
}
}
}
private fun downloadAndShare(file: SyncFileStateEntity) {
viewModelScope.launch {
downloadToCache(file)?.let { cached ->
_fileAction.emit(FileAction.Share(cached))
}
}
}
private suspend fun downloadToCache(file: SyncFileStateEntity): File? {
val pair = selectedPair.value ?: run {
_fileAction.emit(FileAction.Error("No sync pair selected"))
return null
}
val account = accountRepository.getAccount(pair.accountId) ?: run {
_fileAction.emit(FileAction.Error("Cloud account not found"))
return null
}
val provider = providerFactory.create(account)
val fileName = file.relativePath.substringAfterLast('/')
val cacheFile = File(context.cacheDir, "syncflow_open/$fileName")
cacheFile.parentFile?.mkdirs()
_isDownloading.value = true
return try {
cacheFile.outputStream().use { out ->
provider.downloadFile("${pair.remotePath}/${file.relativePath}", out) { }.getOrThrow()
}
cacheFile
} catch (e: Exception) {
Timber.e(e, "Download for preview failed: ${file.relativePath}")
cacheFile.delete()
_fileAction.emit(FileAction.Error("Download failed: ${e.message}"))
null
} finally {
_isDownloading.value = false
}
}
// ── Path resolution ───────────────────────────────────────────────────────
private fun resolveFile(file: SyncFileStateEntity, emitErrorIfMissing: Boolean = true): File? {
// Guard against path traversal from untrusted server responses
if (file.relativePath.contains("..")) {
viewModelScope.launch { _fileAction.emit(FileAction.Error("Invalid file path")) }
return null
}
val pair = selectedPair.value ?: return null
val root = safTreeUriToRealPath(pair.localPath) ?: pair.localPath
// localPath is a content:// URI we couldn't resolve — File-based access won't work
if (root.startsWith("content://")) return null
val f = File(root, file.relativePath)
if (!f.exists()) {
if (emitErrorIfMissing) {
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 }
}
} }
@@ -18,7 +18,8 @@ class BootReceiver : BroadcastReceiver() {
@Inject lateinit var syncPairDao: SyncPairDao @Inject lateinit var syncPairDao: SyncPairDao
override fun onReceive(context: Context, intent: Intent) { 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 wm = WorkManager.getInstance(context)
val pending = goAsync() val pending = goAsync()
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
@@ -16,6 +16,7 @@ import androidx.work.ExistingWorkPolicy
import androidx.work.WorkManager import androidx.work.WorkManager
import com.syncflow.MainActivity import com.syncflow.MainActivity
import com.syncflow.R import com.syncflow.R
import com.syncflow.data.db.SyncFileStateDao
import com.syncflow.data.db.SyncPairDao import com.syncflow.data.db.SyncPairDao
import com.syncflow.domain.model.ScheduleType import com.syncflow.domain.model.ScheduleType
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@@ -28,11 +29,13 @@ import javax.inject.Inject
class FileWatchService : Service() { class FileWatchService : Service() {
@Inject lateinit var syncPairDao: SyncPairDao @Inject lateinit var syncPairDao: SyncPairDao
@Inject lateinit var fileStateDao: SyncFileStateDao
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private val fileObservers = mutableMapOf<Long, FileObserver>() // Multiple FileObserver instances per pair: one per directory (recursive)
private val fileObservers = mutableMapOf<Long, MutableList<FileObserver>>()
private val contentObservers = mutableMapOf<Long, ContentObserver>() private val contentObservers = mutableMapOf<Long, ContentObserver>()
private val debounceJobs = mutableMapOf<Long, Job>() private val debounceJobs = mutableMapOf<Long, Job>()
@@ -104,7 +107,7 @@ class FileWatchService : Service() {
} }
} }
val count = fileObservers.size + contentObservers.size val count = fileObservers.keys.size + contentObservers.size
updateNotification(count) updateNotification(count)
if (count == 0) { if (count == 0) {
@@ -136,21 +139,72 @@ class FileWatchService : Service() {
Timber.w("FileWatchService: path does not exist for pair $pairId: $path") Timber.w("FileWatchService: path does not exist for pair $pairId: $path")
return return
} }
fileObservers[pairId] = mutableListOf()
watchDirRecursive(dir, pairId, wifiOnly, chargingOnly)
Timber.d("FileWatchService: watching pair $pairId at $path (${fileObservers[pairId]?.size} dirs)")
scope.launch { catchupScan(pairId, dir, wifiOnly, chargingOnly) }
}
private fun watchDirRecursive(dir: File, pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean) {
if (!dir.isDirectory) return
val mask = FileObserver.CREATE or FileObserver.DELETE or FileObserver.MODIFY or val mask = FileObserver.CREATE or FileObserver.DELETE or FileObserver.MODIFY or
FileObserver.MOVED_FROM or FileObserver.MOVED_TO FileObserver.MOVED_FROM or FileObserver.MOVED_TO
val observer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val observer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
object : FileObserver(dir, mask) { object : FileObserver(dir, mask) {
override fun onEvent(event: Int, p: String?) = onChangeDetected(pairId, wifiOnly, chargingOnly) override fun onEvent(event: Int, path: String?) {
if (event and FileObserver.CREATE != 0 && path != null) {
val created = File(dir, path)
if (created.isDirectory) watchDirRecursive(created, pairId, wifiOnly, chargingOnly)
}
onChangeDetected(pairId, wifiOnly, chargingOnly)
}
} }
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
object : FileObserver(path, mask) { object : FileObserver(dir.absolutePath, mask) {
override fun onEvent(event: Int, p: String?) = onChangeDetected(pairId, wifiOnly, chargingOnly) override fun onEvent(event: Int, path: String?) {
if (event and FileObserver.CREATE != 0 && path != null) {
val created = File(dir, path)
if (created.isDirectory) watchDirRecursive(created, pairId, wifiOnly, chargingOnly)
}
onChangeDetected(pairId, wifiOnly, chargingOnly)
}
} }
} }
observer.startWatching() observer.startWatching()
fileObservers[pairId] = observer fileObservers.getOrPut(pairId) { mutableListOf() }.add(observer)
Timber.d("FileWatchService: watching filesystem path for pair $pairId: $path") // Recursively watch existing subdirectories
dir.listFiles()?.filter { it.isDirectory }?.forEach { sub ->
watchDirRecursive(sub, pairId, 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<String, Long>()
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) { private fun onChangeDetected(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean) {
@@ -167,7 +221,7 @@ class FileWatchService : Service() {
} }
private fun clearWatchers() { private fun clearWatchers() {
fileObservers.values.forEach { it.stopWatching() } fileObservers.values.flatten().forEach { it.stopWatching() }
fileObservers.clear() fileObservers.clear()
contentObservers.values.forEach { contentResolver.unregisterContentObserver(it) } contentObservers.values.forEach { contentResolver.unregisterContentObserver(it) }
contentObservers.clear() contentObservers.clear()
@@ -6,83 +6,61 @@
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<!-- Soft ambient glow underneath everything --> <!--
Three identical arcs pointing right, evenly spaced vertically.
Same curve shape, same gradient direction, same arrowhead geometry — purely harmonious.
Control point 14dp above midline on each arc.
-->
<!-- Arc 1 — top -->
<path <path
android:pathData="M54,54m-36,0a36,36 0 1,0 72,0a36,36 0 1,0 -72,0" android:pathData="M 26,34 Q 54,20 82,34"
android:fillColor="#18FF6B6B"/> android:fillColor="#00000000"
android:strokeWidth="8.5"
android:strokeLineCap="round"
android:strokeLineJoin="round">
<aapt:attr name="android:strokeColor">
<gradient android:type="linear"
android:startX="26" android:startY="34"
android:endX="82" android:endY="34"
android:startColor="#64C8FF"
android:endColor="#32EDBB"/>
</aapt:attr>
</path>
<path android:pathData="M 77.5,27.3 L 82,34 L 74.0,34.5 Z" android:fillColor="#32EDBB"/>
<!-- ═══ Arrow 1: pointing RIGHT (top) — electric coral ═══ --> <!-- Arc 2 — middle -->
<!-- Shaft with rounded left cap --> <path
<path android:pathData="M27,31 Q22,31 22,36 Q22,41 27,41 L64,41 L64,31 Z"> android:pathData="M 26,54 Q 54,40 82,54"
<aapt:attr name="android:fillColor"> android:fillColor="#00000000"
android:strokeWidth="8.5"
android:strokeLineCap="round"
android:strokeLineJoin="round">
<aapt:attr name="android:strokeColor">
<gradient android:type="linear" <gradient android:type="linear"
android:startX="22" android:startY="36" android:startX="26" android:startY="54"
android:endX="64" android:endY="36" android:endX="82" android:endY="54"
android:startColor="#FF6B6B" android:startColor="#64C8FF"
android:endColor="#FF9F6B"/> android:endColor="#32EDBB"/>
</aapt:attr>
</path>
<!-- Arrowhead -->
<path android:pathData="M63,27 L63,45 L86,36 Z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear"
android:startX="63" android:startY="36"
android:endX="86" android:endY="36"
android:startColor="#FF9F6B"
android:endColor="#FFB347"/>
</aapt:attr> </aapt:attr>
</path> </path>
<path android:pathData="M 77.5,47.3 L 82,54 L 74.0,54.5 Z" android:fillColor="#32EDBB"/>
<!-- ═══ Arrow 2: pointing LEFT (middle) — cool white/silver ═══ --> <!-- Arc 3 — bottom -->
<!-- Shaft with rounded right cap --> <path
<path android:pathData="M44,50 L81,50 Q86,50 86,55 Q86,60 81,60 L44,60 Z"> android:pathData="M 26,74 Q 54,60 82,74"
<aapt:attr name="android:fillColor"> android:fillColor="#00000000"
android:strokeWidth="8.5"
android:strokeLineCap="round"
android:strokeLineJoin="round">
<aapt:attr name="android:strokeColor">
<gradient android:type="linear" <gradient android:type="linear"
android:startX="44" android:startY="55" android:startX="26" android:startY="74"
android:endX="86" android:endY="55" android:endX="82" android:endY="74"
android:startColor="#B0B8D0" android:startColor="#64C8FF"
android:endColor="#E8EDF5"/> android:endColor="#32EDBB"/>
</aapt:attr> </aapt:attr>
</path> </path>
<!-- Arrowhead --> <path android:pathData="M 77.5,67.3 L 82,74 L 74.0,74.5 Z" android:fillColor="#32EDBB"/>
<path android:pathData="M45,46 L45,64 L22,55 Z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear"
android:startX="45" android:startY="55"
android:endX="22" android:endY="55"
android:startColor="#B0B8D0"
android:endColor="#8892A8"/>
</aapt:attr>
</path>
<!-- ═══ Arrow 3: pointing RIGHT (bottom) — electric teal ═══ -->
<!-- Shaft with rounded left cap -->
<path android:pathData="M27,69 Q22,69 22,74 Q22,79 27,79 L64,79 L64,69 Z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear"
android:startX="22" android:startY="74"
android:endX="64" android:endY="74"
android:startColor="#4DD0E1"
android:endColor="#26C6DA"/>
</aapt:attr>
</path>
<!-- Arrowhead -->
<path android:pathData="M63,65 L63,83 L86,74 Z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear"
android:startX="63" android:startY="74"
android:endX="86" android:endY="74"
android:startColor="#26C6DA"
android:endColor="#00BCD4"/>
</aapt:attr>
</path>
<!-- Small glow dots at arrowhead tips for sparkle -->
<path android:pathData="M86,36m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0"
android:fillColor="#FFFFB347"/>
<path android:pathData="M22,55m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0"
android:fillColor="#FF8892A8"/>
<path android:pathData="M86,74m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0"
android:fillColor="#FF00BCD4"/>
</vector> </vector>
+2
View File
@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<paths> <paths>
<external-path name="external_storage" path="." />
<external-files-path name="external_files" path="." /> <external-files-path name="external_files" path="." />
<files-path name="internal_files" path="." /> <files-path name="internal_files" path="." />
<cache-path name="syncflow_cache" path="syncflow_open/" />
</paths> </paths>
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.22 VERSION_NAME=1.0.24
VERSION_CODE=23 VERSION_CODE=25