Compare commits

...

7 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
amir 422e8f0f0f feat: fix sync counters, polished activity rows, Files tab, new icon
- Fix SYNC_COMPLETED showing ↑0 ↓0 ✗0 when only deletions occurred: add ✕N
  for deleted files to the summary message (↑N ↓N ✕N ✗N format)
- Fix PairDetail Activity section showing raw "SYNC_STARTED" enum names and
  "remote" as a plain subtitle: replace dot-based EventRow with the same
  polished icon-bubble rows as the global Log tab
- Extract shared SyncEventRow composable + iconAndTint/label helpers to
  ui/shared/SyncEventRow.kt; both LogScreen and PairDetailScreen now use it
- Add Files tab (4th tab between Log and Accounts): folder browser showing
  all synced files per pair, grouped by subdirectory, with file-type icons,
  size, last-synced date, and a summary header (N files, total size)
- Add SyncFileStateDao.observeForPair() reactive Flow query for Files tab
- Completely redesign app icon: near-black radial gradient background with
  three bold directional arrows in an S-pattern (coral → silver → teal),
  each with gradient fills and tip-glow dots — entirely different from the
  typical circular sync-arrow style
- Bump version to 1.0.22 (build 23)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:05:28 +00:00
amir a7c5ed713a feat: fix notifications on Android 13+/16, add Log tab, fix ON_CHANGE detection
- Request POST_NOTIFICATIONS permission at runtime in MainActivity (primary fix
  for notifications never appearing on Android 13+ phones including Android 16)
- Register all 4 notification channels eagerly in SyncFlowApp.onCreate() instead
  of lazily inside workers
- Add FOREGROUND_SERVICE_SHORT_SERVICE permission + shortService foreground type
  for Android 16 foreground service compatibility
- Add global activity Log tab (new tab 2 in main nav) showing all sync events
  across all pairs, grouped by date with pair name, event icon, and file detail
- Fix FileWatchService ON_CHANGE detection: ContentObserver on SAF tree URIs only
  fires for SAF-API writes, not raw filesystem writes. Now resolves primary:/*
  tree URIs to /storage/emulated/0/* and uses FileObserver for reliable detection
- Bump version to 1.0.21 (build 22)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 21:34:48 +00:00
amir 739e6ece46 fix: implement findExistingAlgorithms in TofuHostKeyVerifier (sshj 0.38 API)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 19:11:30 +00:00
amir d70defe3e1 build: add missing gradle-wrapper.jar
Required by the standard gradlew launcher. Was absent because the original
gradlew bypassed the wrapper mechanism entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 19:08:00 +00:00
amir a4aca43fa7 build: fix gradlew and wrapper URL to work on any machine
gradlew was hardcoded to /home/amir/gradle/gradle-8.6/bin/gradle.
gradle-wrapper.properties used a local file:// URL.
Both now use the standard portable approach (HTTPS distribution URL)
so builds work in CI and on any dev machine without a local Gradle install.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 19:07:24 +00:00
25 changed files with 1215 additions and 157 deletions
+6 -2
View File
@@ -8,6 +8,7 @@
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SHORT_SERVICE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
@@ -65,19 +66,22 @@
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<!-- File watcher for ON_CHANGE sync pairs -->
<!-- stopWithTask=false keeps the service alive when the user swipes the app away -->
<service
android:name=".worker.FileWatchService"
android:foregroundServiceType="dataSync"
android:foregroundServiceType="dataSync|shortService"
android:stopWithTask="false"
android:exported="false" />
<!-- Required on API 29+ so WorkManager can start a typed foreground service -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
android:foregroundServiceType="dataSync|shortService"
tools:node="merge" />
<!-- Remove WorkManager's default initializer — app uses on-demand init via Configuration.Provider -->
@@ -1,9 +1,12 @@
package com.syncflow
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
@@ -49,10 +52,14 @@ class MainActivity : AppCompatActivity() {
private var isLocked by mutableStateOf(false)
private var showRetry by mutableStateOf(false)
private val requestNotificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* proceed regardless */ }
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
enableEdgeToEdge()
requestNotificationPermissionIfNeeded()
setContent {
SyncFlowTheme {
Surface(modifier = Modifier.fillMaxSize()) {
@@ -127,6 +134,16 @@ class MainActivity : AppCompatActivity() {
BIOMETRIC_WEAK or DEVICE_CREDENTIAL
}
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
private fun canAuthenticate(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R)
return BiometricManager.from(this).canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS
@@ -1,6 +1,9 @@
package com.syncflow
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import com.syncflow.data.db.SyncPairDao
@@ -22,6 +25,7 @@ class SyncFlowApp : Application(), Configuration.Provider {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
createNotificationChannels()
// Start file watcher on every app launch for any existing ON_CHANGE pairs
CoroutineScope(Dispatchers.IO).launch {
val hasOnChange = syncPairDao.getEnabled().any { it.scheduleType == ScheduleType.ON_CHANGE }
@@ -29,6 +33,27 @@ class SyncFlowApp : Application(), Configuration.Provider {
}
}
private fun createNotificationChannels() {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
listOf(
NotificationChannel("sync_progress", "Sync progress", NotificationManager.IMPORTANCE_LOW).apply {
description = "Shown while a sync is running"
},
NotificationChannel("sync_complete", "Sync complete", NotificationManager.IMPORTANCE_LOW).apply {
description = "Summary after each successful sync"
},
NotificationChannel("sync_alerts", "Sync errors", NotificationManager.IMPORTANCE_DEFAULT).apply {
description = "Alerts for sync failures and conflicts"
},
NotificationChannel("sync_watching", "File watching", NotificationManager.IMPORTANCE_MIN).apply {
description = "Background service watching folders for changes"
setShowBadge(false)
},
).forEach { channel ->
if (nm.getNotificationChannel(channel.id) == null) nm.createNotificationChannel(channel)
}
}
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
@@ -9,6 +9,9 @@ interface SyncEventDao {
@Query("SELECT * FROM sync_events WHERE syncPairId = :pairId ORDER BY timestamp DESC LIMIT :limit")
fun observeRecent(pairId: Long, limit: Int = 200): Flow<List<SyncEventEntity>>
@Query("SELECT * FROM sync_events ORDER BY timestamp DESC LIMIT :limit")
fun observeAll(limit: Int = 500): Flow<List<SyncEventEntity>>
@Insert
suspend fun insert(entity: SyncEventEntity): Long
@@ -2,9 +2,13 @@ package com.syncflow.data.db
import androidx.room.*
import com.syncflow.data.db.entities.SyncFileStateEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface SyncFileStateDao {
@Query("SELECT * FROM sync_file_states WHERE syncPairId = :pairId ORDER BY relativePath ASC")
fun observeForPair(pairId: Long): Flow<List<SyncFileStateEntity>>
@Query("SELECT * FROM sync_file_states WHERE syncPairId = :pairId")
suspend fun getForPair(pairId: Long): List<SyncFileStateEntity>
@@ -24,6 +24,10 @@ class TofuHostKeyVerifier(private val credentialStore: CredentialStore) : HostKe
}
}
// Return empty list so sshj uses server preference order for key exchange.
// Our verify() will accept or reject whatever algorithm is negotiated.
override fun findExistingAlgorithms(hostname: String, port: Int): List<String> = emptyList()
private fun sha256Fingerprint(key: PublicKey): String {
val digest = MessageDigest.getInstance("SHA-256").digest(key.encoded)
return digest.joinToString(":") { "%02x".format(it) }
@@ -1,7 +1,7 @@
package com.syncflow.data.providers.webdav
import android.util.Log
import com.syncflow.data.providers.CloudProvider
import timber.log.Timber
import com.syncflow.domain.model.CloudAccount
import com.syncflow.domain.model.RemoteFile
import kotlinx.coroutines.Dispatchers
@@ -59,14 +59,13 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
withContext(Dispatchers.IO) {
val req = Request.Builder().url(baseUrl).method("PROPFIND", PROPFIND_BODY).header("Depth", "0").build()
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) {
val body = resp.body?.string()?.take(300) ?: ""
throw Exception("HTTP ${resp.code} ${resp.message}$body")
throw Exception("HTTP ${resp.code} ${resp.message}")
}
}
}
}.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 {
withContext(Dispatchers.IO) {
@@ -192,9 +191,14 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
"response" -> if (inResponse && href.isNotBlank()) {
val rawName = href.trimEnd('/').substringAfterLast('/')
val name = try { java.net.URLDecoder.decode(rawName, "UTF-8") } catch (_: Exception) { rawName }
val relPath = "$parentPath/$name".replace("//", "/")
results.add(RemoteFile(relPath, name, isCollection, contentLength, lastModified, etag, contentType))
inResponse = false
// 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("//", "/")
results.add(RemoteFile(relPath, name, isCollection, contentLength, lastModified, etag, contentType))
inResponse = false
}
}
}
}
@@ -12,6 +12,7 @@ import javax.inject.Singleton
class CredentialStore @Inject constructor(@ApplicationContext private val context: Context) {
private val prefs: SharedPreferences by lazy {
@Suppress("DEPRECATION")
val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
EncryptedSharedPreferences.create(
"syncflow_credentials",
@@ -46,7 +46,7 @@ class SyncEngine @Inject constructor(
else -> SyncStatus.SUCCESS
}
syncPairDao.updateSyncResult(pair.id, Instant.now(), finalStatus, result.conflicts)
logEvent(pair.id, SyncEventType.SYNC_COMPLETED, null, "${result.uploaded}${result.downloaded}${result.failedFiles}", result.bytesTransferred)
logEvent(pair.id, SyncEventType.SYNC_COMPLETED, null, "${result.uploaded}${result.downloaded}${result.deleted}${result.failedFiles}", result.bytesTransferred)
result
} catch (e: Exception) {
Timber.e(e, "Sync failed for pair ${pair.id}")
@@ -0,0 +1,422 @@
package com.syncflow.ui.files
import android.content.Intent
import android.webkit.MimeTypeMap
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.data.db.entities.SyncFileStateEntity
import kotlinx.coroutines.launch
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FilesScreen(
modifier: Modifier = Modifier,
vm: FilesViewModel = hiltViewModel(),
) {
val pairs by vm.pairs.collectAsState()
val selectedPair by vm.selectedPair.collectAsState()
val files by vm.files.collectAsState()
val isDownloading by vm.isDownloading.collectAsState()
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
vm.fileAction.collect { action ->
when (action) {
is FileAction.Open -> {
try {
val uri = FileProvider.getUriForFile(
context, "${context.packageName}.fileprovider", action.file
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, action.file.name.mimeType())
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(
Intent.createChooser(intent, "Open with").apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
} catch (e: Exception) {
snackbarHostState.showSnackbar("Cannot open file: ${e.message}")
}
}
is FileAction.Share -> {
try {
val uri = FileProvider.getUriForFile(
context, "${context.packageName}.fileprovider", action.file
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = action.file.name.mimeType()
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(
Intent.createChooser(intent, "Share via").apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
} catch (e: Exception) {
snackbarHostState.showSnackbar("Cannot share file: ${e.message}")
}
}
is FileAction.Error -> scope.launch {
snackbarHostState.showSnackbar(action.message)
}
}
}
}
Box(modifier = modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
if (pairs.size > 1) {
ScrollableTabRow(
selectedTabIndex = pairs.indexOfFirst { it.id == selectedPair?.id }.coerceAtLeast(0),
edgePadding = 16.dp,
containerColor = MaterialTheme.colorScheme.surface,
divider = {},
) {
pairs.forEach { pair ->
Tab(
selected = pair.id == selectedPair?.id,
onClick = { vm.selectPair(pair.id) },
text = { Text(pair.name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
)
}
}
HorizontalDivider()
}
when {
pairs.isEmpty() -> FilesEmptyState(
icon = Icons.Default.FolderOpen,
title = "No sync pairs yet",
subtitle = "Create a sync pair to browse its files",
)
files.isEmpty() -> FilesEmptyState(
icon = Icons.Default.FolderOpen,
title = "No synced files yet",
subtitle = "Run a sync to populate this view",
)
else -> {
Surface(color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"${files.size} file${if (files.size != 1) "s" else ""}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
files.sumOf { it.localSizeBytes }.toDisplaySize(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
val grouped = files.groupBy { f ->
val idx = f.relativePath.indexOf('/')
if (idx < 0) "" else f.relativePath.substring(0, idx)
}
grouped.forEach { (dir, dirFiles) ->
if (dir.isNotEmpty()) {
item(key = "dir_$dir") {
Row(
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.Folder, contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(6.dp))
Text(
dir,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
items(dirFiles, key = { "${it.syncPairId}_${it.relativePath}" }) { file ->
FileRow(file = file, isInSubDir = dir.isNotEmpty(), vm = vm)
HorizontalDivider(
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f),
modifier = Modifier.padding(
start = if (dir.isNotEmpty()) 38.dp else 16.dp
),
)
}
}
item { Spacer(Modifier.height(80.dp)) }
}
}
}
}
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
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 timeFmt = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
val syncedAt = file.lastSyncedAt.atZone(ZoneId.systemDefault()).let { timeFmt.format(it) }
var menuExpanded by remember { mutableStateOf(false) }
var showRenameDialog by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
if (showRenameDialog) {
RenameDialog(
currentName = name,
onConfirm = { newName ->
vm.renameFile(file, newName)
showRenameDialog = false
},
onDismiss = { showRenameDialog = false },
)
}
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
icon = { Icon(Icons.Default.Delete, contentDescription = null) },
title = { Text("Delete file?") },
text = { Text("\"$name\" will be removed from this device.") },
confirmButton = {
TextButton(onClick = { vm.deleteFile(file); showDeleteDialog = false }) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) { Text("Cancel") }
},
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
start = if (isInSubDir) 22.dp else 0.dp,
top = 10.dp,
bottom = 10.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.size(32.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
fileIcon(name), contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
name,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
"Synced $syncedAt · ${file.localSizeBytes.toDisplaySize()}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Box {
IconButton(onClick = { menuExpanded = true }, modifier = Modifier.size(36.dp)) {
Icon(
Icons.Default.MoreVert, contentDescription = "File options",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
DropdownMenuItem(
text = { Text("Open") },
leadingIcon = { Icon(Icons.Default.OpenInNew, contentDescription = null) },
onClick = { menuExpanded = false; vm.openFile(file) },
)
DropdownMenuItem(
text = { Text("Share") },
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) },
onClick = { menuExpanded = false; vm.shareFile(file) },
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Rename") },
leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) },
onClick = { menuExpanded = false; showRenameDialog = true },
)
DropdownMenuItem(
text = { Text("Delete", color = MaterialTheme.colorScheme.error) },
leadingIcon = {
Icon(
Icons.Default.Delete, contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
onClick = { menuExpanded = false; showDeleteDialog = true },
)
}
}
}
}
@Composable
private fun RenameDialog(
currentName: String,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
) {
var newName by remember { mutableStateOf(currentName) }
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.Edit, contentDescription = null) },
title = { Text("Rename file") },
text = {
OutlinedTextField(
value = newName,
onValueChange = { newName = it },
label = { Text("New name") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = {
TextButton(
onClick = {
val trimmed = newName.trim()
if (trimmed.isNotBlank() && trimmed != currentName) onConfirm(trimmed)
else onDismiss()
},
enabled = newName.isNotBlank(),
) { Text("Rename") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
private fun String.mimeType(): String {
val ext = substringAfterLast('.', "").lowercase()
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: "*/*"
}
private fun fileIcon(name: String) = when {
name.endsWith(".pdf", ignoreCase = true) -> Icons.Default.PictureAsPdf
name.endsWith(".jpg", ignoreCase = true) ||
name.endsWith(".jpeg", ignoreCase = true) ||
name.endsWith(".png", ignoreCase = true) ||
name.endsWith(".gif", ignoreCase = true) ||
name.endsWith(".webp", ignoreCase = true) -> Icons.Default.Image
name.endsWith(".mp4", ignoreCase = true) ||
name.endsWith(".mkv", ignoreCase = true) ||
name.endsWith(".mov", ignoreCase = true) -> Icons.Default.VideoFile
name.endsWith(".mp3", ignoreCase = true) ||
name.endsWith(".m4a", ignoreCase = true) ||
name.endsWith(".ogg", ignoreCase = true) -> Icons.Default.AudioFile
name.endsWith(".zip", ignoreCase = true) ||
name.endsWith(".tar", ignoreCase = true) ||
name.endsWith(".gz", ignoreCase = true) -> Icons.Default.FolderZip
name.endsWith(".txt", ignoreCase = true) ||
name.endsWith(".md", ignoreCase = true) -> Icons.Default.TextSnippet
else -> Icons.Default.InsertDriveFile
}
private fun Long.toDisplaySize(): String = when {
this < 1_024 -> "$this B"
this < 1_048_576 -> "${"%.1f".format(this / 1_024.0)} KB"
this < 1_073_741_824 -> "${"%.1f".format(this / 1_048_576.0)} MB"
else -> "${"%.1f".format(this / 1_073_741_824.0)} GB"
}
@@ -0,0 +1,190 @@
package com.syncflow.ui.files
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.syncflow.data.db.SyncFileStateDao
import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.SyncFileStateEntity
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.qualifiers.ApplicationContext
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.File
import javax.inject.Inject
sealed class FileAction {
data class Open(val file: File) : FileAction()
data class Share(val file: File) : FileAction()
data class Error(val message: String) : FileAction()
}
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class FilesViewModel @Inject constructor(
private val syncPairDao: SyncPairDao,
private val fileStateDao: SyncFileStateDao,
private val accountRepository: AccountRepository,
private val providerFactory: ProviderFactory,
@ApplicationContext private val context: Context,
) : ViewModel() {
val pairs: StateFlow<List<SyncPairEntity>> = syncPairDao.observeAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val _selectedPairId = MutableStateFlow<Long?>(null)
val selectedPair: StateFlow<SyncPairEntity?> = combine(_selectedPairId, pairs) { id, list ->
list.firstOrNull { it.id == id } ?: list.firstOrNull()
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
val files: StateFlow<List<SyncFileStateEntity>> = _selectedPairId
.flatMapLatest { id ->
if (id == null) pairs.map { it.firstOrNull()?.id }.filterNotNull()
.flatMapLatest { fileStateDao.observeForPair(it) }
else fileStateDao.observeForPair(id)
}
.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 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 }
}
}
@@ -0,0 +1,152 @@
package com.syncflow.ui.log
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.domain.model.SyncEventType
import com.syncflow.ui.shared.iconAndTint
import com.syncflow.ui.shared.label
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@Composable
fun LogScreen(
modifier: Modifier = Modifier,
vm: LogViewModel = hiltViewModel(),
) {
val entries by vm.entries.collectAsState()
if (entries.isEmpty()) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
Icons.Default.Notifications,
contentDescription = null,
modifier = Modifier.size(72.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.35f),
)
Text("No activity yet", style = MaterialTheme.typography.titleMedium)
Text(
"Sync events will appear here",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
} else {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
// Group entries by calendar date
val grouped = entries.groupBy { entry ->
entry.event.timestamp.atZone(ZoneId.systemDefault()).toLocalDate()
}
grouped.forEach { (date, dayEntries) ->
item(key = date.toString()) {
Text(
text = date.toRelativeLabel(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp),
)
}
items(dayEntries, key = { it.event.id }) { entry ->
LogEntryRow(entry)
HorizontalDivider(
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
modifier = Modifier.padding(start = 52.dp),
)
}
}
item { Spacer(Modifier.height(80.dp)) }
}
}
}
@Composable
private fun LogEntryRow(entry: LogEntry) {
val (icon, tint) = entry.event.eventType.iconAndTint()
val timeFmt = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
val timeStr = entry.event.timestamp.atZone(ZoneId.systemDefault()).let { timeFmt.format(it) }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
verticalAlignment = Alignment.Top,
) {
// Icon bubble
Surface(
shape = RoundedCornerShape(10.dp),
color = tint.copy(alpha = 0.12f),
modifier = Modifier.size(36.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(icon, contentDescription = null, Modifier.size(18.dp), tint = tint)
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
entry.pairName,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f, fill = false),
)
Text(
timeStr,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(2.dp))
Text(
text = entry.event.eventType.label(),
style = MaterialTheme.typography.bodySmall,
)
val detail = entry.event.filePath ?: entry.event.message
if (detail != null) {
Text(
text = detail,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
}
}
private fun java.time.LocalDate.toRelativeLabel(): String {
val today = java.time.LocalDate.now()
return when {
this == today -> "Today"
this == today.minusDays(1) -> "Yesterday"
else -> DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).format(this)
}
}
@@ -0,0 +1,29 @@
package com.syncflow.ui.log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.syncflow.data.db.SyncEventDao
import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.SyncEventEntity
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
data class LogEntry(val event: SyncEventEntity, val pairName: String)
@HiltViewModel
class LogViewModel @Inject constructor(
syncEventDao: SyncEventDao,
syncPairDao: SyncPairDao,
) : ViewModel() {
val entries = combine(
syncEventDao.observeAll(500),
syncPairDao.observeAll(),
) { events, pairs ->
val pairNames = pairs.associateBy({ it.id }, { it.name })
events.map { LogEntry(it, pairNames[it.syncPairId] ?: "Unknown") }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
}
@@ -19,9 +19,13 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.ManageAccounts
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.outlined.FolderOpen
import androidx.compose.material.icons.outlined.ManageAccounts
import androidx.compose.material.icons.outlined.NotificationsNone
import androidx.compose.material.icons.outlined.Sync
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
@@ -31,7 +35,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.syncflow.R
import com.syncflow.ui.files.FilesScreen
import com.syncflow.ui.home.HomeScreen
import com.syncflow.ui.log.LogScreen
import com.syncflow.ui.settings.SettingsScreen
import kotlinx.coroutines.launch
@@ -42,7 +48,7 @@ fun MainShell(
onPairClick: (Long) -> Unit,
onAddAccount: () -> Unit,
) {
val pagerState = rememberPagerState(pageCount = { 2 })
val pagerState = rememberPagerState(pageCount = { 4 })
val scope = rememberCoroutineScope()
val currentPage = pagerState.currentPage
@@ -88,7 +94,29 @@ fun MainShell(
onClick = { scope.launch { pagerState.animateScrollToPage(1) } },
icon = {
Icon(
if (currentPage == 1) Icons.Filled.ManageAccounts else Icons.Outlined.ManageAccounts,
if (currentPage == 1) Icons.Filled.Notifications else Icons.Outlined.NotificationsNone,
contentDescription = null,
)
},
label = { Text("Log") },
)
NavigationBarItem(
selected = currentPage == 2,
onClick = { scope.launch { pagerState.animateScrollToPage(2) } },
icon = {
Icon(
if (currentPage == 2) Icons.Filled.Folder else Icons.Outlined.FolderOpen,
contentDescription = null,
)
},
label = { Text("Files") },
)
NavigationBarItem(
selected = currentPage == 3,
onClick = { scope.launch { pagerState.animateScrollToPage(3) } },
icon = {
Icon(
if (currentPage == 3) Icons.Filled.ManageAccounts else Icons.Outlined.ManageAccounts,
contentDescription = null,
)
},
@@ -120,7 +148,9 @@ fun MainShell(
) { page ->
when (page) {
0 -> HomeScreen(onAddPair = onAddPair, onPairClick = onPairClick)
1 -> SettingsScreen(onAddAccount = onAddAccount)
1 -> LogScreen()
2 -> FilesScreen()
3 -> SettingsScreen(onAddAccount = onAddAccount)
}
}
}
@@ -17,14 +17,12 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.data.db.entities.SyncEventEntity
import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.domain.model.SyncEventType
import com.syncflow.domain.model.SyncStatus
import com.syncflow.ui.shared.SyncEventRow
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
@@ -132,7 +130,7 @@ fun PairDetailScreen(
}
} else {
items(events, key = { it.id }) { event ->
EventRow(event)
SyncEventRow(event, showDivider = event != events.last())
}
}
}
@@ -231,56 +229,6 @@ private fun InfoRow(
}
}
@Composable
private fun EventRow(event: SyncEventEntity) {
val fmt = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
val zone = ZoneId.systemDefault()
val dotColor = eventColor(event.eventType)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Colored dot indicator
Surface(
shape = RoundedCornerShape(50),
color = dotColor,
modifier = Modifier.size(8.dp),
) {}
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
event.filePath ?: event.message ?: event.eventType.name,
style = MaterialTheme.typography.bodySmall,
)
event.message?.takeIf { event.filePath != null }?.let {
Text(
it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Text(
fmt.format(event.timestamp.atZone(zone)),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun eventColor(type: SyncEventType): Color = when (type) {
SyncEventType.SYNC_STARTED -> MaterialTheme.colorScheme.primary
SyncEventType.SYNC_COMPLETED -> MaterialTheme.colorScheme.primary
SyncEventType.SYNC_FAILED -> MaterialTheme.colorScheme.error
SyncEventType.FILE_UPLOADED -> MaterialTheme.colorScheme.secondary
SyncEventType.FILE_DOWNLOADED -> MaterialTheme.colorScheme.secondary
SyncEventType.FILE_DELETED -> MaterialTheme.colorScheme.tertiary
SyncEventType.FILE_SKIPPED -> MaterialTheme.colorScheme.onSurfaceVariant
SyncEventType.CONFLICT_DETECTED -> MaterialTheme.colorScheme.tertiary
SyncEventType.CONFLICT_RESOLVED -> MaterialTheme.colorScheme.primary
}
private fun String.toDisplayPath(): String {
val decoded = java.net.URLDecoder.decode(this, "UTF-8")
return decoded.trimEnd('/').substringAfterLast('/').substringAfterLast(':').ifEmpty { decoded }
@@ -0,0 +1,102 @@
package com.syncflow.ui.shared
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Circle
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import com.syncflow.data.db.entities.SyncEventEntity
import com.syncflow.domain.model.SyncEventType
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@Composable
fun SyncEventRow(event: SyncEventEntity, showDivider: Boolean = true) {
val (icon, tint) = event.eventType.iconAndTint()
val timeFmt = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
val timeStr = timeFmt.format(event.timestamp.atZone(ZoneId.systemDefault()))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
verticalAlignment = Alignment.Top,
) {
Surface(
shape = RoundedCornerShape(10.dp),
color = tint.copy(alpha = 0.12f),
modifier = Modifier.size(36.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(icon, contentDescription = null, Modifier.size(18.dp), tint = tint)
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
event.eventType.label(),
style = MaterialTheme.typography.labelMedium,
)
Text(
timeStr,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
val detail = event.filePath ?: event.message
if (detail != null) {
Spacer(Modifier.height(2.dp))
Text(
text = detail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
}
if (showDivider) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
modifier = Modifier.padding(start = 48.dp),
)
}
}
@Composable
fun SyncEventType.iconAndTint(): Pair<ImageVector, Color> = when (this) {
SyncEventType.SYNC_STARTED -> Icons.Default.Sync to MaterialTheme.colorScheme.secondary
SyncEventType.SYNC_COMPLETED -> Icons.Default.CheckCircle to MaterialTheme.colorScheme.primary
SyncEventType.SYNC_FAILED -> Icons.Default.Error to MaterialTheme.colorScheme.error
SyncEventType.FILE_UPLOADED -> Icons.Default.CloudUpload to MaterialTheme.colorScheme.secondary
SyncEventType.FILE_DOWNLOADED -> Icons.Default.CloudDownload to MaterialTheme.colorScheme.secondary
SyncEventType.FILE_DELETED -> Icons.Default.DeleteForever to MaterialTheme.colorScheme.tertiary
SyncEventType.FILE_SKIPPED -> Icons.Outlined.Circle to MaterialTheme.colorScheme.onSurfaceVariant
SyncEventType.CONFLICT_DETECTED -> Icons.Default.Warning to MaterialTheme.colorScheme.tertiary
SyncEventType.CONFLICT_RESOLVED -> Icons.Default.CheckCircle to MaterialTheme.colorScheme.primary
}
fun SyncEventType.label(): String = when (this) {
SyncEventType.SYNC_STARTED -> "Sync started"
SyncEventType.SYNC_COMPLETED -> "Sync completed"
SyncEventType.SYNC_FAILED -> "Sync failed"
SyncEventType.FILE_UPLOADED -> "File uploaded"
SyncEventType.FILE_DOWNLOADED -> "File downloaded"
SyncEventType.FILE_DELETED -> "File deleted"
SyncEventType.FILE_SKIPPED -> "File skipped"
SyncEventType.CONFLICT_DETECTED -> "Conflict detected"
SyncEventType.CONFLICT_RESOLVED -> "Conflict resolved"
}
@@ -18,7 +18,8 @@ class BootReceiver : BroadcastReceiver() {
@Inject lateinit var syncPairDao: SyncPairDao
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
val validActions = setOf(Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED)
if (intent.action !in validActions) return
val wm = WorkManager.getInstance(context)
val pending = goAsync()
CoroutineScope(Dispatchers.IO).launch {
@@ -10,11 +10,13 @@ import android.os.FileObserver
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.provider.DocumentsContract
import androidx.core.app.NotificationCompat
import androidx.work.ExistingWorkPolicy
import androidx.work.WorkManager
import com.syncflow.MainActivity
import com.syncflow.R
import com.syncflow.data.db.SyncFileStateDao
import com.syncflow.data.db.SyncPairDao
import com.syncflow.domain.model.ScheduleType
import dagger.hilt.android.AndroidEntryPoint
@@ -27,11 +29,13 @@ import javax.inject.Inject
class FileWatchService : Service() {
@Inject lateinit var syncPairDao: SyncPairDao
@Inject lateinit var fileStateDao: SyncFileStateDao
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val mainHandler = Handler(Looper.getMainLooper())
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 debounceJobs = mutableMapOf<Long, Job>()
@@ -81,39 +85,29 @@ class FileWatchService : Service() {
val localPath = pair.localPath
if (localPath.startsWith("content://")) {
val treeUri = Uri.parse(localPath)
val observer = object : ContentObserver(mainHandler) {
override fun onChange(selfChange: Boolean) = onChangeDetected(pairId, pair.wifiOnly, pair.chargingOnly)
override fun onChange(selfChange: Boolean, uri: Uri?) = onChangeDetected(pairId, pair.wifiOnly, pair.chargingOnly)
}
contentResolver.registerContentObserver(treeUri, true, observer)
contentObservers[pairId] = observer
Timber.d("FileWatchService: watching SAF URI for pair $pairId")
} else {
val dir = File(localPath)
if (!dir.exists()) {
Timber.w("FileWatchService: path does not exist for pair $pairId: $localPath")
return@forEach
}
val mask = FileObserver.CREATE or FileObserver.DELETE or FileObserver.MODIFY or
FileObserver.MOVED_FROM or FileObserver.MOVED_TO
val observer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
object : FileObserver(dir, mask) {
override fun onEvent(event: Int, path: String?) = onChangeDetected(pairId, pair.wifiOnly, pair.chargingOnly)
}
// Try to resolve the SAF tree URI to a real filesystem path so we can use
// FileObserver. ContentObserver on a DocumentsProvider tree URI only fires
// when changes come through the SAF API, not for raw filesystem writes.
val realPath = safTreeUriToRealPath(localPath)
if (realPath != null) {
watchPath(realPath, pairId, pair.wifiOnly, pair.chargingOnly)
} else {
@Suppress("DEPRECATION")
object : FileObserver(localPath, mask) {
override fun onEvent(event: Int, path: String?) = onChangeDetected(pairId, pair.wifiOnly, pair.chargingOnly)
// Fallback: register a ContentObserver for SAF paths that can't be resolved
val treeUri = Uri.parse(localPath)
val observer = object : ContentObserver(mainHandler) {
override fun onChange(selfChange: Boolean) = onChangeDetected(pairId, pair.wifiOnly, pair.chargingOnly)
override fun onChange(selfChange: Boolean, uri: Uri?) = onChangeDetected(pairId, pair.wifiOnly, pair.chargingOnly)
}
contentResolver.registerContentObserver(treeUri, true, observer)
contentObservers[pairId] = observer
Timber.d("FileWatchService: watching SAF URI (ContentObserver fallback) for pair $pairId")
}
observer.startWatching()
fileObservers[pairId] = observer
Timber.d("FileWatchService: watching filesystem path for pair $pairId: $localPath")
} else {
watchPath(localPath, pairId, pair.wifiOnly, pair.chargingOnly)
}
}
val count = fileObservers.size + contentObservers.size
val count = fileObservers.keys.size + contentObservers.size
updateNotification(count)
if (count == 0) {
@@ -122,6 +116,97 @@ class FileWatchService : Service() {
}
}
private fun safTreeUriToRealPath(uriString: String): String? {
return try {
val treeUri = Uri.parse(uriString)
val docId = DocumentsContract.getTreeDocumentId(treeUri)
// docId format is "primary:RelativePath" for primary internal storage
if (docId.startsWith("primary:")) {
val relative = docId.removePrefix("primary:")
"/storage/emulated/0/$relative"
} else {
null
}
} catch (e: Exception) {
Timber.w("FileWatchService: could not resolve SAF URI to real path: $e")
null
}
}
private fun watchPath(path: String, pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean) {
val dir = File(path)
if (!dir.exists()) {
Timber.w("FileWatchService: path does not exist for pair $pairId: $path")
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
FileObserver.MOVED_FROM or FileObserver.MOVED_TO
val observer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
object : FileObserver(dir, mask) {
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 {
@Suppress("DEPRECATION")
object : FileObserver(dir.absolutePath, mask) {
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()
fileObservers.getOrPut(pairId) { mutableListOf() }.add(observer)
// 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) {
debounceJobs[pairId]?.cancel()
debounceJobs[pairId] = scope.launch {
@@ -136,7 +221,7 @@ class FileWatchService : Service() {
}
private fun clearWatchers() {
fileObservers.values.forEach { it.stopWatching() }
fileObservers.values.flatten().forEach { it.stopWatching() }
fileObservers.clear()
contentObservers.values.forEach { contentResolver.unregisterContentObserver(it) }
contentObservers.clear()
@@ -1,9 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:type="linear"
android:angle="135"
android:startColor="#2E1065"
android:centerColor="#6D28D9"
android:endColor="#1E40AF"/>
android:type="radial"
android:gradientRadius="80%"
android:centerX="0.35"
android:centerY="0.3"
android:startColor="#1C1124"
android:centerColor="#0E0A18"
android:endColor="#060408"/>
</shape>
@@ -6,65 +6,61 @@
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Outer soft glow ring -->
<path
android:pathData="M54,54m-44,0a44,44 0 1,0 88,0a44,44 0 1,0 -88,0"
android:fillColor="#12FFFFFF"/>
<!--
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.
-->
<!-- Mid glow ring -->
<!-- Arc 1 — top -->
<path
android:pathData="M54,54m-33,0a33,33 0 1,0 66,0a33,33 0 1,0 -66,0"
android:fillColor="#18FFFFFF"/>
<!-- Inner glow ring -->
<path
android:pathData="M54,54m-21,0a21,21 0 1,0 42,0a21,21 0 1,0 -42,0"
android:fillColor="#10FFFFFF"/>
<!-- Upload arrow (top-right) — neon cyan → sky blue -->
<path android:pathData="M54,18V4.5L36,22.5l18,18V27c14.895,0 27,12.105 27,27 0,4.545-1.125,8.865-3.15,12.6l6.57,6.57C87.93,67.635 90,61.065 90,54c0-19.89-16.11-36-36-36z">
<aapt:attr name="android:fillColor">
android:pathData="M 26,34 Q 54,20 82,34"
android:fillColor="#00000000"
android:strokeWidth="8.5"
android:strokeLineCap="round"
android:strokeLineJoin="round">
<aapt:attr name="android:strokeColor">
<gradient android:type="linear"
android:startX="36" android:startY="4"
android:endX="90" android:endY="70"
android:startColor="#67E8F9"
android:endColor="#38BDF8"/>
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"/>
<!-- Download arrow (bottom-left) — hot pink → coral -->
<path android:pathData="M54,81c-14.895,0-27,-12.105-27,-27 0,-4.545 1.125,-8.865 3.15,-12.6L23.58,34.83C20.07,40.365 18,46.935 18,54c0,19.89 16.11,36 36,36v13.5l18,-18-18,-18v13.5z">
<aapt:attr name="android:fillColor">
<!-- Arc 2 — middle -->
<path
android:pathData="M 26,54 Q 54,40 82,54"
android:fillColor="#00000000"
android:strokeWidth="8.5"
android:strokeLineCap="round"
android:strokeLineJoin="round">
<aapt:attr name="android:strokeColor">
<gradient android:type="linear"
android:startX="18" android:startY="35"
android:endX="72" android:endY="103"
android:startColor="#F472B6"
android:endColor="#FB923C"/>
android:startX="26" android:startY="54"
android:endX="82" android:endY="54"
android:startColor="#64C8FF"
android:endColor="#32EDBB"/>
</aapt:attr>
</path>
<path android:pathData="M 77.5,47.3 L 82,54 L 74.0,54.5 Z" android:fillColor="#32EDBB"/>
<!-- Center glowing orb -->
<!-- Arc 3 — bottom -->
<path
android:pathData="M54,54m-7,0a7,7 0 1,0 14,0a7,7 0 1,0 -14,0"
android:fillColor="#60FFFFFF"/>
<path
android:pathData="M54,54m-4,0a4,4 0 1,0 8,0a4,4 0 1,0 -8,0"
android:fillColor="#FFFFFF"/>
<!-- Cardinal accent sparks -->
<!-- Top — cyan -->
<path android:pathData="M54,13m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0" android:fillColor="#22D3EE"/>
<!-- Right — indigo -->
<path android:pathData="M95,54m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0" android:fillColor="#818CF8"/>
<!-- Bottom — pink -->
<path android:pathData="M54,95m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0" android:fillColor="#F9A8D4"/>
<!-- Left — emerald -->
<path android:pathData="M13,54m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0" android:fillColor="#6EE7B7"/>
<!-- Diagonal mini sparks (45°) -->
<path android:pathData="M85,23m-2,0a2,2 0 1,0 4,0a2,2 0 1,0 -4,0" android:fillColor="#A5F3FC"/>
<path android:pathData="M85,85m-2,0a2,2 0 1,0 4,0a2,2 0 1,0 -4,0" android:fillColor="#FDBA74"/>
<path android:pathData="M23,85m-2,0a2,2 0 1,0 4,0a2,2 0 1,0 -4,0" android:fillColor="#C084FC"/>
<path android:pathData="M23,23m-2,0a2,2 0 1,0 4,0a2,2 0 1,0 -4,0" android:fillColor="#86EFAC"/>
android:pathData="M 26,74 Q 54,60 82,74"
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="74"
android:endX="82" android:endY="74"
android:startColor="#64C8FF"
android:endColor="#32EDBB"/>
</aapt:attr>
</path>
<path android:pathData="M 77.5,67.3 L 82,74 L 74.0,74.5 Z" android:fillColor="#32EDBB"/>
</vector>
+2
View File
@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-path name="external_storage" path="." />
<external-files-path name="external_files" path="." />
<files-path name="internal_files" path="." />
<cache-path name="syncflow_cache" path="syncflow_open/" />
</paths>
Binary file not shown.
+1 -1
View File
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=file\:///home/amir/gradle/gradle-8.6/gradle-8.6-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+39 -2
View File
@@ -1,2 +1,39 @@
#!/bin/bash
exec /home/amir/gradle/gradle-8.6/bin/gradle "$@"
#!/bin/sh
##############################################################################
# Gradle wrapper — standard portable launcher
##############################################################################
app_path=$0
while [ -h "$app_path" ]; do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in
/*) app_path=$link ;;
*) app_path=${app_path%"${app_path##*/}"}$link ;;
esac
done
APP_HOME=$( cd "${app_path%"${app_path##*/}"}." && pwd -P ) || exit
APP_BASE_NAME=${0##*/}
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
if [ -n "$JAVA_HOME" ]; then
JAVACMD=$JAVA_HOME/bin/java
else
JAVACMD=java
fi
MAX_FD=maximum
case "$( uname )" in
Darwin*) ;;
*)
MAX_FD=$( ulimit -H -n 2>/dev/null ) && ulimit -n "$MAX_FD" 2>/dev/null ;;
esac
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS \
"\"-Dorg.gradle.appname=$APP_BASE_NAME\"" \
-classpath "\"$CLASSPATH\"" \
org.gradle.wrapper.GradleWrapperMain '"$@"'
exec "$JAVACMD" "$@"
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.19
VERSION_CODE=20
VERSION_NAME=1.0.24
VERSION_CODE=25