Compare commits

...

3 Commits

Author SHA1 Message Date
amir 742f634084 v1.0.26: fix multi-selection reactivity, redesign icon, security review
Fix multi-selection: selectedKeys exposed as StateFlow, collected in
FilesScreen so checkboxes and highlights update correctly on every tap.
fileKey() made public so UI can check membership without ViewModel calls.

Icon: white cloud body with two cyan/teal circular sync arcs (AutoSync
style), deep blue-to-teal gradient background.

Security review clean: no hardcoded credentials, cleartext blocked by
network_security_config, allowBackup=false, path traversal guards in
place on both server responses and local resolution.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 02:45:43 +00:00
amir 8fdd22bc98 v1.0.25: multi-select files, unified notification, dark theme, icon redesign
- FilesScreen: long-press enters selection mode, bulk share/delete toolbar, BackHandler
- FilesViewModel: ShareMultiple action, isSelectionMode/selectedCount state, download-to-cache for open/share
- FileWatchService: recursive FileObserver per subdirectory; unified notification updated with sync result via WorkManager flow observation
- SyncWorker: silent flag suppresses notifications when triggered by watcher; emits KEY_RESULT_SUMMARY output data
- Passbolt-inspired dark theme (Red700/Red500 primary, near-black surface)
- App icon: circular AutoSync-style sync arrows (cyan gradient, deep navy background)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 02:22:43 +00:00
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
12 changed files with 598 additions and 276 deletions
@@ -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,7 +1,11 @@
package com.syncflow.ui.files package com.syncflow.ui.files
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
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
@@ -20,11 +24,12 @@ 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 kotlinx.coroutines.launch
import java.io.File
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
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
fun FilesScreen( fun FilesScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -33,9 +38,16 @@ 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 selectedKeys by vm.selectedKeys.collectAsState()
val isSelectionMode = selectedKeys.isNotEmpty()
val selectedCount = selectedKeys.size
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var showDeleteSelectedDialog by remember { mutableStateOf(false) }
BackHandler(enabled = isSelectionMode) { vm.clearSelection() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
vm.fileAction.collect { action -> vm.fileAction.collect { action ->
@@ -77,6 +89,25 @@ fun FilesScreen(
snackbarHostState.showSnackbar("Cannot share file: ${e.message}") snackbarHostState.showSnackbar("Cannot share file: ${e.message}")
} }
} }
is FileAction.ShareMultiple -> {
try {
val uris = action.files.map { file ->
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
}
val intent = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
type = "*/*"
putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris))
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(
Intent.createChooser(intent, "Share files").apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
} catch (e: Exception) {
snackbarHostState.showSnackbar("Cannot share: ${e.message}")
}
}
is FileAction.Error -> scope.launch { is FileAction.Error -> scope.launch {
snackbarHostState.showSnackbar(action.message) snackbarHostState.showSnackbar(action.message)
} }
@@ -84,9 +115,62 @@ fun FilesScreen(
} }
} }
if (showDeleteSelectedDialog) {
AlertDialog(
onDismissRequest = { showDeleteSelectedDialog = false },
icon = { Icon(Icons.Default.Delete, contentDescription = null) },
title = { Text("Delete $selectedCount file${if (selectedCount != 1) "s" else ""}?") },
text = { Text("Selected files will be removed from this device.") },
confirmButton = {
TextButton(onClick = {
vm.deleteSelected()
showDeleteSelectedDialog = false
}) { Text("Delete", color = MaterialTheme.colorScheme.error) }
},
dismissButton = {
TextButton(onClick = { showDeleteSelectedDialog = false }) { Text("Cancel") }
},
)
}
Box(modifier = modifier.fillMaxSize()) { Box(modifier = modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
if (pairs.size > 1) { // Selection toolbar
if (isSelectionMode) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
tonalElevation = 3.dp,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = { vm.clearSelection() }) {
Icon(Icons.Default.Close, contentDescription = "Clear selection")
}
Text(
"$selectedCount selected",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
IconButton(onClick = { vm.shareSelected() }) {
Icon(Icons.Default.Share, contentDescription = "Share selected")
}
IconButton(onClick = { showDeleteSelectedDialog = true }) {
Icon(
Icons.Default.Delete, contentDescription = "Delete selected",
tint = MaterialTheme.colorScheme.error,
)
}
}
}
HorizontalDivider()
}
// Pair tabs
if (pairs.size > 1 && !isSelectionMode) {
ScrollableTabRow( ScrollableTabRow(
selectedTabIndex = pairs.indexOfFirst { it.id == selectedPair?.id }.coerceAtLeast(0), selectedTabIndex = pairs.indexOfFirst { it.id == selectedPair?.id }.coerceAtLeast(0),
edgePadding = 16.dp, edgePadding = 16.dp,
@@ -116,6 +200,7 @@ fun FilesScreen(
subtitle = "Run a sync to populate this view", subtitle = "Run a sync to populate this view",
) )
else -> { else -> {
if (!isSelectionMode) {
Surface(color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) { Surface(color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -136,6 +221,7 @@ fun FilesScreen(
) )
} }
} }
}
LazyColumn( LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
@@ -146,7 +232,7 @@ fun FilesScreen(
if (idx < 0) "" else f.relativePath.substring(0, idx) if (idx < 0) "" else f.relativePath.substring(0, idx)
} }
grouped.forEach { (dir, dirFiles) -> grouped.forEach { (dir, dirFiles) ->
if (dir.isNotEmpty()) { if (dir.isNotEmpty() && !isSelectionMode) {
item(key = "dir_$dir") { item(key = "dir_$dir") {
Row( Row(
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
@@ -167,12 +253,16 @@ fun FilesScreen(
} }
} }
items(dirFiles, key = { "${it.syncPairId}_${it.relativePath}" }) { file -> items(dirFiles, key = { "${it.syncPairId}_${it.relativePath}" }) { file ->
FileRow(file = file, isInSubDir = dir.isNotEmpty(), vm = vm) FileRow(
file = file,
isInSubDir = dir.isNotEmpty() && !isSelectionMode,
isSelectionMode = isSelectionMode,
isSelected = vm.fileKey(file) in selectedKeys,
vm = vm,
)
HorizontalDivider( HorizontalDivider(
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f), color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f),
modifier = Modifier.padding( modifier = Modifier.padding(start = if (dir.isNotEmpty() && !isSelectionMode) 38.dp else 16.dp),
start = if (dir.isNotEmpty()) 38.dp else 16.dp
),
) )
} }
} }
@@ -182,6 +272,32 @@ fun FilesScreen(
} }
} }
// Download progress
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( SnackbarHost(
hostState = snackbarHostState, hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter), modifier = Modifier.align(Alignment.BottomCenter),
@@ -211,8 +327,15 @@ private fun FilesEmptyState(icon: ImageVector, title: String, subtitle: String)
} }
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun FileRow(file: SyncFileStateEntity, isInSubDir: Boolean, vm: FilesViewModel) { private fun FileRow(
file: SyncFileStateEntity,
isInSubDir: Boolean,
isSelectionMode: Boolean,
isSelected: 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) }
@@ -224,10 +347,7 @@ private fun FileRow(file: SyncFileStateEntity, isInSubDir: Boolean, vm: FilesVie
if (showRenameDialog) { if (showRenameDialog) {
RenameDialog( RenameDialog(
currentName = name, currentName = name,
onConfirm = { newName -> onConfirm = { newName -> vm.renameFile(file, newName); showRenameDialog = false },
vm.renameFile(file, newName)
showRenameDialog = false
},
onDismiss = { showRenameDialog = false }, onDismiss = { showRenameDialog = false },
) )
} }
@@ -249,16 +369,34 @@ private fun FileRow(file: SyncFileStateEntity, isInSubDir: Boolean, vm: FilesVie
) )
} }
Row( Surface(
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
else MaterialTheme.colorScheme.surface,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding( .combinedClickable(
onClick = {
if (isSelectionMode) vm.toggleSelection(file) else menuExpanded = true
},
onLongClick = { vm.toggleSelection(file) },
),
) {
Row(
modifier = Modifier.padding(
start = if (isInSubDir) 22.dp else 0.dp, start = if (isInSubDir) 22.dp else 0.dp,
top = 10.dp, top = 10.dp,
bottom = 10.dp, bottom = 10.dp,
end = 0.dp,
), ),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (isSelectionMode) {
Checkbox(
checked = isSelected,
onCheckedChange = { vm.toggleSelection(file) },
modifier = Modifier.padding(horizontal = 4.dp),
)
} else {
Surface( Surface(
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.secondaryContainer, color = MaterialTheme.colorScheme.secondaryContainer,
@@ -273,6 +411,7 @@ private fun FileRow(file: SyncFileStateEntity, isInSubDir: Boolean, vm: FilesVie
} }
} }
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
}
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
name, name,
@@ -286,6 +425,7 @@ private fun FileRow(file: SyncFileStateEntity, isInSubDir: Boolean, vm: FilesVie
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
if (!isSelectionMode) {
Box { Box {
IconButton(onClick = { menuExpanded = true }, modifier = Modifier.size(36.dp)) { IconButton(onClick = { menuExpanded = true }, modifier = Modifier.size(36.dp)) {
Icon( Icon(
@@ -297,27 +437,24 @@ private fun FileRow(file: SyncFileStateEntity, isInSubDir: Boolean, vm: FilesVie
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) { DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("Open") }, text = { Text("Open") },
leadingIcon = { Icon(Icons.Default.OpenInNew, contentDescription = null) }, leadingIcon = { Icon(Icons.Default.OpenInNew, null) },
onClick = { menuExpanded = false; vm.openFile(file) }, onClick = { menuExpanded = false; vm.openFile(file) },
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text("Share") }, text = { Text("Share") },
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) }, leadingIcon = { Icon(Icons.Default.Share, null) },
onClick = { menuExpanded = false; vm.shareFile(file) }, onClick = { menuExpanded = false; vm.shareFile(file) },
) )
HorizontalDivider() HorizontalDivider()
DropdownMenuItem( DropdownMenuItem(
text = { Text("Rename") }, text = { Text("Rename") },
leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) }, leadingIcon = { Icon(Icons.Default.Edit, null) },
onClick = { menuExpanded = false; showRenameDialog = true }, onClick = { menuExpanded = false; showRenameDialog = true },
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text("Delete", color = MaterialTheme.colorScheme.error) }, text = { Text("Delete", color = MaterialTheme.colorScheme.error) },
leadingIcon = { leadingIcon = {
Icon( Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error)
Icons.Default.Delete, contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
}, },
onClick = { menuExpanded = false; showDeleteDialog = true }, onClick = { menuExpanded = false; showDeleteDialog = true },
) )
@@ -325,13 +462,11 @@ private fun FileRow(file: SyncFileStateEntity, isInSubDir: Boolean, vm: FilesVie
} }
} }
} }
}
}
@Composable @Composable
private fun RenameDialog( private fun RenameDialog(currentName: String, onConfirm: (String) -> Unit, onDismiss: () -> Unit) {
currentName: String,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
) {
var newName by remember { mutableStateOf(currentName) } var newName by remember { mutableStateOf(currentName) }
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -350,15 +485,12 @@ private fun RenameDialog(
TextButton( TextButton(
onClick = { onClick = {
val trimmed = newName.trim() val trimmed = newName.trim()
if (trimmed.isNotBlank() && trimmed != currentName) onConfirm(trimmed) if (trimmed.isNotBlank() && trimmed != currentName) onConfirm(trimmed) else onDismiss()
else onDismiss()
}, },
enabled = newName.isNotBlank(), enabled = newName.isNotBlank(),
) { Text("Rename") } ) { Text("Rename") }
}, },
dismissButton = { dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
TextButton(onClick = onDismiss) { Text("Cancel") }
},
) )
} }
@@ -369,22 +501,16 @@ private fun String.mimeType(): String {
private fun fileIcon(name: String) = when { private fun fileIcon(name: String) = when {
name.endsWith(".pdf", ignoreCase = true) -> Icons.Default.PictureAsPdf name.endsWith(".pdf", ignoreCase = true) -> Icons.Default.PictureAsPdf
name.endsWith(".jpg", ignoreCase = true) || name.endsWith(".jpg", ignoreCase = true) || name.endsWith(".jpeg", ignoreCase = true) ||
name.endsWith(".jpeg", ignoreCase = true) || name.endsWith(".png", ignoreCase = true) || name.endsWith(".gif", ignoreCase = true) ||
name.endsWith(".png", ignoreCase = true) ||
name.endsWith(".gif", ignoreCase = true) ||
name.endsWith(".webp", ignoreCase = true) -> Icons.Default.Image name.endsWith(".webp", ignoreCase = true) -> Icons.Default.Image
name.endsWith(".mp4", ignoreCase = true) || name.endsWith(".mp4", ignoreCase = true) || name.endsWith(".mkv", ignoreCase = true) ||
name.endsWith(".mkv", ignoreCase = true) ||
name.endsWith(".mov", ignoreCase = true) -> Icons.Default.VideoFile name.endsWith(".mov", ignoreCase = true) -> Icons.Default.VideoFile
name.endsWith(".mp3", ignoreCase = true) || name.endsWith(".mp3", ignoreCase = true) || name.endsWith(".m4a", ignoreCase = true) ||
name.endsWith(".m4a", ignoreCase = true) ||
name.endsWith(".ogg", ignoreCase = true) -> Icons.Default.AudioFile name.endsWith(".ogg", ignoreCase = true) -> Icons.Default.AudioFile
name.endsWith(".zip", ignoreCase = true) || name.endsWith(".zip", ignoreCase = true) || name.endsWith(".tar", ignoreCase = true) ||
name.endsWith(".tar", ignoreCase = true) ||
name.endsWith(".gz", ignoreCase = true) -> Icons.Default.FolderZip name.endsWith(".gz", ignoreCase = true) -> Icons.Default.FolderZip
name.endsWith(".txt", ignoreCase = true) || name.endsWith(".txt", ignoreCase = true) || name.endsWith(".md", ignoreCase = true) -> Icons.Default.TextSnippet
name.endsWith(".md", ignoreCase = true) -> Icons.Default.TextSnippet
else -> Icons.Default.InsertDriveFile else -> Icons.Default.InsertDriveFile
} }
@@ -7,11 +7,14 @@ 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 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 kotlinx.coroutines.launch
import kotlinx.coroutines.flow.update
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
@@ -19,6 +22,7 @@ import javax.inject.Inject
sealed class FileAction { sealed class FileAction {
data class Open(val file: File) : FileAction() data class Open(val file: File) : FileAction()
data class Share(val file: File) : FileAction() data class Share(val file: File) : FileAction()
data class ShareMultiple(val files: List<File>) : FileAction()
data class Error(val message: String) : FileAction() data class Error(val message: String) : FileAction()
} }
@@ -27,6 +31,8 @@ sealed class FileAction {
class FilesViewModel @Inject constructor( class FilesViewModel @Inject constructor(
private val 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, @ApplicationContext private val context: Context,
) : ViewModel() { ) : ViewModel() {
@@ -50,22 +56,40 @@ class FilesViewModel @Inject constructor(
private val _fileAction = MutableSharedFlow<FileAction>() private val _fileAction = MutableSharedFlow<FileAction>()
val fileAction: SharedFlow<FileAction> = _fileAction val fileAction: SharedFlow<FileAction> = _fileAction
private val _isDownloading = MutableStateFlow(false)
val isDownloading: StateFlow<Boolean> = _isDownloading
private val _selectedKeys = MutableStateFlow<Set<String>>(emptySet())
val selectedKeys: StateFlow<Set<String>> = _selectedKeys
val isSelectionMode: StateFlow<Boolean> = _selectedKeys.map { it.isNotEmpty() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
val selectedCount: StateFlow<Int> = _selectedKeys.map { it.size }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0)
fun selectPair(id: Long) { _selectedPairId.value = id } fun selectPair(id: Long) { _selectedPairId.value = id }
fun openFile(file: SyncFileStateEntity) { fun openFile(file: SyncFileStateEntity) {
val resolved = resolveFile(file) ?: return val resolved = resolveFile(file, emitErrorIfMissing = false)
if (resolved != null) {
viewModelScope.launch { _fileAction.emit(FileAction.Open(resolved)) } viewModelScope.launch { _fileAction.emit(FileAction.Open(resolved)) }
} else {
downloadAndOpen(file)
}
} }
fun shareFile(file: SyncFileStateEntity) { fun shareFile(file: SyncFileStateEntity) {
val resolved = resolveFile(file) ?: return val resolved = resolveFile(file, emitErrorIfMissing = false)
if (resolved != null) {
viewModelScope.launch { _fileAction.emit(FileAction.Share(resolved)) } viewModelScope.launch { _fileAction.emit(FileAction.Share(resolved)) }
} else {
downloadAndShare(file)
}
} }
fun deleteFile(file: SyncFileStateEntity) { fun deleteFile(file: SyncFileStateEntity) {
viewModelScope.launch { viewModelScope.launch {
try { try {
val resolved = resolveFile(file) val resolved = resolveFile(file, emitErrorIfMissing = false)
resolved?.delete() resolved?.delete()
fileStateDao.delete(file.syncPairId, file.relativePath) fileStateDao.delete(file.syncPairId, file.relativePath)
} catch (e: Exception) { } catch (e: Exception) {
@@ -85,7 +109,6 @@ class FilesViewModel @Inject constructor(
_fileAction.emit(FileAction.Error("Rename failed")) _fileAction.emit(FileAction.Error("Rename failed"))
return@launch return@launch
} }
// Update DB: delete old state; the next sync will re-detect as a new upload
fileStateDao.delete(file.syncPairId, file.relativePath) fileStateDao.delete(file.syncPairId, file.relativePath)
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Rename failed: ${file.relativePath}") Timber.e(e, "Rename failed: ${file.relativePath}")
@@ -94,12 +117,109 @@ class FilesViewModel @Inject constructor(
} }
} }
private fun resolveFile(file: SyncFileStateEntity): File? { fun isSelected(file: SyncFileStateEntity): Boolean = fileKey(file) in _selectedKeys.value
fun toggleSelection(file: SyncFileStateEntity) {
val key = fileKey(file)
_selectedKeys.update { if (key in it) it - key else it + key }
}
fun clearSelection() { _selectedKeys.value = emptySet() }
fun deleteSelected() {
viewModelScope.launch {
val toDelete = files.value.filter { isSelected(it) }
toDelete.forEach { file ->
try {
resolveFile(file, emitErrorIfMissing = false)?.delete()
fileStateDao.delete(file.syncPairId, file.relativePath)
} catch (e: Exception) {
Timber.e(e, "Bulk delete failed: ${file.relativePath}")
}
}
clearSelection()
}
}
fun shareSelected() {
viewModelScope.launch {
val toShare = files.value.filter { isSelected(it) }
val resolved = toShare.mapNotNull { resolveFile(it, emitErrorIfMissing = false) }
if (resolved.isEmpty()) {
_fileAction.emit(FileAction.Error("No local files available to share"))
return@launch
}
_fileAction.emit(FileAction.ShareMultiple(resolved))
}
}
fun fileKey(file: SyncFileStateEntity) = "${file.syncPairId}:${file.relativePath}"
// ── 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 pair = selectedPair.value ?: return null
val root = safTreeUriToRealPath(pair.localPath) ?: pair.localPath 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) val f = File(root, file.relativePath)
if (!f.exists()) { if (!f.exists()) {
if (emitErrorIfMissing) {
viewModelScope.launch { _fileAction.emit(FileAction.Error("File not found on device")) } viewModelScope.launch { _fileAction.emit(FileAction.Error("File not found on device")) }
}
return null return null
} }
return f return f
@@ -2,27 +2,28 @@ package com.syncflow.ui.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
// Primary — indigo // Primary — deep red (Passbolt-inspired)
val Indigo600 = Color(0xFF4F46E5) val Red900 = Color(0xFF7F0000)
val Indigo900 = Color(0xFF312E81) val Red700 = Color(0xFFB71C1C)
val Indigo100 = Color(0xFFE0E7FF) val Red500 = Color(0xFFEF5350)
val Indigo50 = Color(0xFFEEF2FF) val Red100 = Color(0xFFFFCDD2)
val Red50 = Color(0xFFFFEBEE)
// Secondary — teal // Secondary — deep orange
val Teal600 = Color(0xFF0D9488) val Orange700 = Color(0xFFE64A19)
val Teal100 = Color(0xFFCCFBF1) val Orange100 = Color(0xFFFBE9E7)
// Tertiary — amber // Tertiary — amber
val Amber500 = Color(0xFFF59E0B) val Amber500 = Color(0xFFFFB300)
val Amber100 = Color(0xFFFEF3C7) val Amber100 = Color(0xFFFFF8E1)
// Neutrals // Neutrals
val Slate50 = Color(0xFFF8FAFC) val Gray50 = Color(0xFFF8F9FA)
val Slate100 = Color(0xFFF1F5F9) val Gray100 = Color(0xFFF3F4F6)
val Slate200 = Color(0xFFE2E8F0) val Gray200 = Color(0xFFE5E7EB)
val Slate600 = Color(0xFF475569) val Gray600 = Color(0xFF6B7280)
val Slate900 = Color(0xFF0F172A) val Gray900 = Color(0xFF111827)
// Semantic // Semantic
val GreenSuccess = Color(0xFF16A34A) val GreenSuccess = Color(0xFF2E7D32)
val RedError = Color(0xFFDC2626) val RedError = Color(0xFFEF5350)
@@ -13,41 +13,43 @@ import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
private val LightColors = lightColorScheme( private val LightColors = lightColorScheme(
primary = Indigo600, primary = Red700,
onPrimary = Color.White, onPrimary = Color.White,
primaryContainer = Indigo100, primaryContainer = Red50,
onPrimaryContainer = Indigo900, onPrimaryContainer = Red900,
secondary = Teal600, secondary = Orange700,
onSecondary = Color.White, onSecondary = Color.White,
secondaryContainer = Teal100, secondaryContainer = Orange100,
tertiary = Amber500, tertiary = Amber500,
tertiaryContainer = Amber100, tertiaryContainer = Amber100,
background = Slate50, background = Gray50,
surface = Color.White, surface = Color.White,
surfaceVariant = Slate100, surfaceVariant = Gray100,
onSurfaceVariant = Slate600, onSurface = Gray900,
onSurfaceVariant = Gray600,
error = RedError, error = RedError,
errorContainer = Color(0xFFFEE2E2), errorContainer = Red50,
outline = Slate200, outline = Gray200,
) )
private val DarkColors = darkColorScheme( private val DarkColors = darkColorScheme(
primary = Color(0xFF818CF8), primary = Red500,
onPrimary = Indigo900, onPrimary = Color.White,
primaryContainer = Color(0xFF3730A3), primaryContainer = Red900,
onPrimaryContainer = Indigo100, onPrimaryContainer = Red100,
secondary = Color(0xFF2DD4BF), secondary = Color(0xFFFF7043),
onSecondary = Color(0xFF003731), onSecondary = Color.White,
secondaryContainer = Color(0xFF00504A), secondaryContainer = Color(0xFF4E1500),
tertiary = Amber500, tertiary = Amber500,
tertiaryContainer = Color(0xFF92400E), tertiaryContainer = Color(0xFF3E2700),
background = Color(0xFF0F0F1A), background = Color(0xFF0F0F0F),
surface = Color(0xFF1A1A2E), surface = Color(0xFF1C1C1C),
surfaceVariant = Color(0xFF252538), surfaceVariant = Color(0xFF2A2A2A),
onSurfaceVariant = Color(0xFF94A3B8), onSurface = Color(0xFFEAEAEA),
error = Color(0xFFF87171), onSurfaceVariant = Color(0xFF9E9E9E),
errorContainer = Color(0xFF7F1D1D), error = Color(0xFFFF5252),
outline = Color(0xFF334155), errorContainer = Color(0xFF5C0000),
outline = Color(0xFF3D3D3D),
) )
private val AppTypography = Typography( private val AppTypography = Typography(
@@ -13,7 +13,9 @@ import android.os.Looper
import android.provider.DocumentsContract import android.provider.DocumentsContract
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
import androidx.work.WorkInfo
import androidx.work.WorkManager import androidx.work.WorkManager
import kotlinx.coroutines.flow.first
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.SyncFileStateDao
@@ -34,7 +36,8 @@ class FileWatchService : Service() {
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>()
@@ -106,7 +109,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) {
@@ -138,23 +141,44 @@ 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
// Check if anything changed while the service was not running dir.listFiles()?.filter { it.isDirectory }?.forEach { sub ->
scope.launch { catchupScan(pairId, dir, wifiOnly, chargingOnly) } watchDirRecursive(sub, pairId, wifiOnly, chargingOnly)
}
} }
private suspend fun catchupScan(pairId: Long, dir: File, wifiOnly: Boolean, chargingOnly: Boolean) { private suspend fun catchupScan(pairId: Long, dir: File, wifiOnly: Boolean, chargingOnly: Boolean) {
@@ -192,14 +216,39 @@ class FileWatchService : Service() {
val pair = syncPairDao.getById(pairId) val pair = syncPairDao.getById(pairId)
if (pair == null || !pair.isEnabled) return@launch if (pair == null || !pair.isEnabled) return@launch
Timber.d("FileWatchService: triggering sync for pair $pairId after debounce") Timber.d("FileWatchService: triggering sync for pair $pairId after debounce")
val req = SyncWorker.buildOneTimeRequest(pairId, wifiOnly, chargingOnly)
val req = SyncWorker.buildOneTimeRequest(pairId, wifiOnly, chargingOnly, silent = true)
WorkManager.getInstance(applicationContext) WorkManager.getInstance(applicationContext)
.enqueueUniqueWork("onchange_$pairId", ExistingWorkPolicy.KEEP, req) .enqueueUniqueWork("onchange_$pairId", ExistingWorkPolicy.KEEP, req)
// Update notification while sync is in progress
updateNotificationDynamic("Syncing: ${pair.name}")
// Wait for completion and show result in the persistent notification
scope.launch {
try {
val info = WorkManager.getInstance(applicationContext)
.getWorkInfoByIdFlow(req.id)
.first { it?.state?.isFinished == true }
val summary = info?.outputData?.getString(SyncWorker.KEY_RESULT_SUMMARY)
val watchCount = fileObservers.keys.size + contentObservers.size
val watching = "Watching $watchCount folder${if (watchCount != 1) "s" else ""}"
if (info?.state == WorkInfo.State.SUCCEEDED && summary != null) {
updateNotificationDynamic("${pair.name}: $summary$watching")
} else {
updateNotificationDynamic("$watching")
}
delay(12_000)
updateNotificationDynamic(null) // revert to default watching text
} catch (_: Exception) {
updateNotificationDynamic(null)
}
}
} }
} }
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()
@@ -219,7 +268,7 @@ class FileWatchService : Service() {
} }
} }
private fun buildNotification(count: Int): Notification { private fun buildNotification(count: Int, overrideText: String? = null): Notification {
val tapIntent = PendingIntent.getActivity( val tapIntent = PendingIntent.getActivity(
this, 0, this, 0,
Intent(this, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP }, Intent(this, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP },
@@ -228,7 +277,7 @@ class FileWatchService : Service() {
return NotificationCompat.Builder(this, CHANNEL_WATCH) return NotificationCompat.Builder(this, CHANNEL_WATCH)
.setContentTitle("SyncFlow") .setContentTitle("SyncFlow")
.setContentText( .setContentText(
if (count > 0) "Watching $count folder${if (count != 1) "s" else ""} for changes" overrideText ?: if (count > 0) "Watching $count folder${if (count != 1) "s" else ""} for changes"
else "Starting file watcher…" else "Starting file watcher…"
) )
.setSmallIcon(R.drawable.ic_sync) .setSmallIcon(R.drawable.ic_sync)
@@ -242,4 +291,10 @@ class FileWatchService : Service() {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIFICATION_ID, buildNotification(count)) nm.notify(NOTIFICATION_ID, buildNotification(count))
} }
private fun updateNotificationDynamic(overrideText: String?) {
val count = fileObservers.keys.size + contentObservers.size
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIFICATION_ID, buildNotification(count, overrideText))
}
} }
@@ -39,6 +39,8 @@ class SyncWorker @AssistedInject constructor(
val pair = syncPairDao.getById(pairId) ?: return Result.failure() val pair = syncPairDao.getById(pairId) ?: return Result.failure()
val account = accountRepository.getAccount(pair.accountId) ?: return Result.failure() val account = accountRepository.getAccount(pair.accountId) ?: return Result.failure()
val silent = inputData.getBoolean(KEY_SILENT, false)
ensureChannels() ensureChannels()
setForeground(buildForegroundInfo(pair.name, "Syncing…")) setForeground(buildForegroundInfo(pair.name, "Syncing…"))
@@ -47,7 +49,15 @@ class SyncWorker @AssistedInject constructor(
val provider = providerFactory.create(account) val provider = providerFactory.create(account)
val result = syncEngine.sync(domainPair, provider) val result = syncEngine.sync(domainPair, provider)
if (result.error != null && pair.notifyOnError) { val lines = buildList {
if (result.uploaded > 0) add("${result.uploaded}")
if (result.downloaded > 0) add("${result.downloaded}")
if (result.deleted > 0) add("🗑${result.deleted}")
if (result.conflicts > 0) add("${result.conflicts}")
}
val resultSummary = if (lines.isEmpty()) "Up to date" else lines.joinToString(" ")
if (!silent && result.error != null && pair.notifyOnError) {
notify( notify(
id = pairId.toInt() + RESULT_ID_OFFSET, id = pairId.toInt() + RESULT_ID_OFFSET,
channelId = CHANNEL_ALERTS, channelId = CHANNEL_ALERTS,
@@ -55,28 +65,28 @@ class SyncWorker @AssistedInject constructor(
text = result.error.message ?: "Unknown error", text = result.error.message ?: "Unknown error",
priority = NotificationCompat.PRIORITY_DEFAULT, priority = NotificationCompat.PRIORITY_DEFAULT,
) )
} else if (pair.notifyOnComplete && result.error == null) { } else if (!silent && pair.notifyOnComplete && result.error == null) {
val lines = buildList { val fullLines = buildList {
if (result.uploaded > 0) add("↑ Uploaded: ${result.uploaded} file${if (result.uploaded != 1) "s" else ""}") if (result.uploaded > 0) add("↑ Uploaded: ${result.uploaded} file${if (result.uploaded != 1) "s" else ""}")
if (result.downloaded > 0) add("↓ Downloaded: ${result.downloaded} file${if (result.downloaded != 1) "s" else ""}") if (result.downloaded > 0) add("↓ Downloaded: ${result.downloaded} file${if (result.downloaded != 1) "s" else ""}")
if (result.deleted > 0) add("🗑 Deleted: ${result.deleted} file${if (result.deleted != 1) "s" else ""}") if (result.deleted > 0) add("🗑 Deleted: ${result.deleted} file${if (result.deleted != 1) "s" else ""}")
if (result.conflicts > 0) add("${result.conflicts} conflict${if (result.conflicts != 1) "s" else ""}") if (result.conflicts > 0) add("${result.conflicts} conflict${if (result.conflicts != 1) "s" else ""}")
} }
val summary = if (lines.isEmpty()) "Up to date — nothing to sync" else lines.joinToString("\n") val summary = if (fullLines.isEmpty()) "Up to date — nothing to sync" else fullLines.joinToString("\n")
notify( notify(
id = pairId.toInt() + RESULT_ID_OFFSET, id = pairId.toInt() + RESULT_ID_OFFSET,
channelId = CHANNEL_COMPLETE, channelId = CHANNEL_COMPLETE,
title = "${pair.name}Changes synced", title = "${pair.name}Synced",
text = if (lines.isEmpty()) summary else lines.first(), text = if (fullLines.isEmpty()) summary else fullLines.first(),
bigText = summary, bigText = summary,
priority = NotificationCompat.PRIORITY_LOW, priority = NotificationCompat.PRIORITY_LOW,
) )
} }
Result.success() Result.success(workDataOf(KEY_RESULT_SUMMARY to resultSummary))
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "SyncWorker failed for pair $pairId") Timber.e(e, "SyncWorker failed for pair $pairId")
if (pair.notifyOnError) { if (!silent && pair.notifyOnError) {
notify( notify(
id = pairId.toInt() + RESULT_ID_OFFSET, id = pairId.toInt() + RESULT_ID_OFFSET,
channelId = CHANNEL_ALERTS, channelId = CHANNEL_ALERTS,
@@ -146,19 +156,21 @@ class SyncWorker @AssistedInject constructor(
companion object { companion object {
const val KEY_PAIR_ID = "pair_id" const val KEY_PAIR_ID = "pair_id"
const val KEY_SILENT = "silent"
const val KEY_RESULT_SUMMARY = "result_summary"
private const val NOTIFICATION_ID = 1001 private const val NOTIFICATION_ID = 1001
private const val RESULT_ID_OFFSET = 2000 private const val RESULT_ID_OFFSET = 2000
private const val CHANNEL_PROGRESS = "sync_progress" private const val CHANNEL_PROGRESS = "sync_progress"
private const val CHANNEL_COMPLETE = "sync_complete" private const val CHANNEL_COMPLETE = "sync_complete"
private const val CHANNEL_ALERTS = "sync_alerts" private const val CHANNEL_ALERTS = "sync_alerts"
fun buildOneTimeRequest(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean): OneTimeWorkRequest { fun buildOneTimeRequest(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean, silent: Boolean = false): OneTimeWorkRequest {
val constraints = Constraints.Builder() val constraints = Constraints.Builder()
.setRequiredNetworkType(if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED) .setRequiredNetworkType(if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
.setRequiresCharging(chargingOnly) .setRequiresCharging(chargingOnly)
.build() .build()
return OneTimeWorkRequestBuilder<SyncWorker>() return OneTimeWorkRequestBuilder<SyncWorker>()
.setInputData(workDataOf(KEY_PAIR_ID to pairId)) .setInputData(workDataOf(KEY_PAIR_ID to pairId, KEY_SILENT to silent))
.setConstraints(constraints) .setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.addTag("sync_$pairId") .addTag("sync_$pairId")
@@ -1,11 +1,36 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"> <vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Deep blue-to-teal gradient background matching reference icon -->
<path
android:pathData="M0,0 H108 V108 H0 Z">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="0" android:startY="0"
android:endX="108" android:endY="108"
android:startColor="#1565C0"
android:endColor="#00897B"/>
</aapt:attr>
</path>
<!-- Subtle radial highlight in upper-right -->
<path
android:pathData="M108,0 A90,90 0 0,1 108,90 Z"
android:fillAlpha="0.18">
<aapt:attr name="android:fillColor">
<gradient <gradient
android:type="radial" android:type="radial"
android:gradientRadius="80%" android:gradientRadius="80"
android:centerX="0.35" android:centerX="85" android:centerY="23"
android:centerY="0.3" android:startColor="#80DEEA"
android:startColor="#1C1124" android:endColor="#00000000"/>
android:centerColor="#0E0A18" </aapt:attr>
android:endColor="#060408"/> </path>
</shape>
</vector>
@@ -6,90 +6,65 @@
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<!-- Subtle inner glow --> <!-- Cloud body: white, centred at (54,56). Composed of three arc bumps. -->
<path <path
android:pathData="M54,54m-30,0a30,30 0 1,0 60,0a30,30 0 1,0 -60,0" android:pathData="
android:fillColor="#0AFFFFFF"/> M 38,67
A 9,9 0 0,1 38,49
A 9,9 0 0,1 47,40.5
A 12,12 0 0,1 68,42
A 8,8 0 0,1 76,53
A 8,8 0 0,1 70,67
Z"
android:fillColor="#FFFFFF"/>
<!-- ═══ Arrow 1: curving RIGHT (top) — coral gradient ═══ --> <!-- Cloud drop shadow -->
<!-- Curved shaft via quadratic bezier: left→ dips up → right -->
<path <path
android:pathData="M28,40 Q54,22 80,36" android:pathData="
M 38,69
A 9,9 0 0,1 38,51
A 9,9 0 0,1 47,42.5
A 12,12 0 0,1 68,44
A 8,8 0 0,1 76,55
A 8,8 0 0,1 70,69
Z"
android:fillColor="#000000"
android:fillAlpha="0.10"/>
<!-- Sync arc 1: lower half CW, cyan to teal -->
<path
android:pathData="M 49.14,81.57 A 28,28 0 1,1 49.14,26.43"
android:fillColor="#00000000" android:fillColor="#00000000"
android:strokeWidth="7" android:strokeWidth="5.5"
android:strokeLineCap="round" android:strokeLineCap="round">
android:strokeLineJoin="round">
<aapt:attr name="android:strokeColor"> <aapt:attr name="android:strokeColor">
<gradient android:type="linear" <gradient android:type="linear"
android:startX="28" android:startY="36" android:startX="49.14" android:startY="81.57"
android:endX="80" android:endY="36" android:endX="49.14" android:endY="26.43"
android:startColor="#FF6B6B" android:startColor="#40C4FF"
android:endColor="#FFB347"/> android:endColor="#00BFA5"/>
</aapt:attr>
</path>
<!-- Arrowhead at (80,36) pointing right/slightly-down -->
<path android:pathData="M76,28 L72,44 L88,38 Z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear"
android:startX="72" android:startY="36"
android:endX="88" android:endY="36"
android:startColor="#FFB347"
android:endColor="#FF8C42"/>
</aapt:attr> </aapt:attr>
</path> </path>
<!-- Arrowhead at end of arc 1 (near 260 deg) -->
<path android:pathData="M 42.5,30.5 L 49.14,26.43 L 46.0,34.5 Z"
android:fillColor="#00BFA5"/>
<!-- ═══ Arrow 2: curving LEFT (middle) — silver/white ═══ --> <!-- Sync arc 2: upper half CW, teal to cyan -->
<!-- Curved shaft: right→ dips down → left -->
<path <path
android:pathData="M80,56 Q54,74 28,60" android:pathData="M 58.86,26.43 A 28,28 0 1,1 58.86,81.57"
android:fillColor="#00000000" android:fillColor="#00000000"
android:strokeWidth="7" android:strokeWidth="5.5"
android:strokeLineCap="round" android:strokeLineCap="round">
android:strokeLineJoin="round">
<aapt:attr name="android:strokeColor"> <aapt:attr name="android:strokeColor">
<gradient android:type="linear" <gradient android:type="linear"
android:startX="80" android:startY="60" android:startX="58.86" android:startY="26.43"
android:endX="28" android:endY="60" android:endX="58.86" android:endY="81.57"
android:startColor="#D0D8EE" android:startColor="#00BFA5"
android:endColor="#8892AA"/> android:endColor="#40C4FF"/>
</aapt:attr>
</path>
<!-- Arrowhead at (28,60) pointing left/slightly-down -->
<path android:pathData="M32,52 L36,68 L20,62 Z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear"
android:startX="36" android:startY="60"
android:endX="20" android:endY="60"
android:startColor="#D0D8EE"
android:endColor="#8892AA"/>
</aapt:attr>
</path>
<!-- ═══ Arrow 3: curving RIGHT (bottom) — teal gradient ═══ -->
<!-- Curved shaft: left→ dips up → right -->
<path
android:pathData="M28,74 Q54,58 80,72"
android:fillColor="#00000000"
android:strokeWidth="7"
android:strokeLineCap="round"
android:strokeLineJoin="round">
<aapt:attr name="android:strokeColor">
<gradient android:type="linear"
android:startX="28" android:startY="72"
android:endX="80" android:endY="72"
android:startColor="#4DD0E1"
android:endColor="#00BCD4"/>
</aapt:attr>
</path>
<!-- Arrowhead at (80,72) pointing right/slightly-up -->
<path android:pathData="M76,64 L72,80 L88,74 Z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear"
android:startX="72" android:startY="72"
android:endX="88" android:endY="72"
android:startColor="#00BCD4"
android:endColor="#00ACC1"/>
</aapt:attr> </aapt:attr>
</path> </path>
<!-- Arrowhead at end of arc 2 (near 80 deg) -->
<path android:pathData="M 65.5,77.5 L 58.86,81.57 L 62.0,73.5 Z"
android:fillColor="#40C4FF"/>
</vector> </vector>
+1
View File
@@ -3,4 +3,5 @@
<external-path name="external_storage" path="." /> <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.23 VERSION_NAME=1.0.26
VERSION_CODE=24 VERSION_CODE=27