Compare commits

..

9 Commits

Author SHA1 Message Date
amir 66d28761a8 v1.0.31: fix remaining sync loop triggers + icon redesign
Three additional fixes found via live device logs:

1. Startup race window: FileObserver fires immediately after
   startWatching() before catchupScan coroutine runs, starting a 5s
   debounce with cooldown=0. Fixed by setting a 15s startup cooldown
   in watchPath() BEFORE calling watchDirRecursive.

2. Stale debounce bypass: debounce job started with cooldown=0 fires
   5s later even after catchupScan has already set cooldown and started
   a catchup sync. Fixed by re-checking cooldown after the 5s delay
   and aborting if already active.

3. Debounce not cancelled by catchupScan: if a debounce was queued
   before catchupScan ran, catchupScan would enqueue a catchup sync
   AND the old debounce would fire 5s later enqueuing a second sync.
   Fixed by cancelling pending debounce in catchupScan before enqueue.

Icon: four thick arcs (blue/red/green/orange) in a 4-way pinwheel
with over/under ordering. White sync-arrow circle at center.
Pure black background.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 14:06:19 +00:00
amir ec478531da v1.0.30: fix sync loop root causes + icon redesign
Three root causes found via live logcat on device:

1. concurrent refresh() race: onStartCommand received twice causes two
   refresh() coroutines to run in parallel, doubling FileObserver and
   catchupScan registrations. Fixed with Mutex.withLock on refresh().

2. catchupScan no cooldown: catchup syncs write files but never set
   syncCooldownUntil, so every written file immediately re-triggers
   onChangeDetected. Fixed by setting cooldown before enqueue and
   watching work completion same as onChangeDetected does.

3. CancellationException caught silently: exception handler
   catch(_: Exception) was catching CancellationException and resetting
   cooldown to 0L, re-opening the loop. Fixed by rethrowing
   CancellationException and setting 60s cooldown on other errors.

Icon: interlocked rings (blue/red/green/orange) with sync arrow at
center, pure black background — matches reference image.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 13:48:18 +00:00
amir 5ade80a334 v1.0.29: fix sync loop, stale-state auto-heal, icon redesign
- SyncEngine: self-healing stale folder state detection (isRetry) wipes
  orphaned SyncFileStateEntity records when localPath changes without a
  pair re-save — prevents repeated DELETE_REMOTE on 32 old files
- SyncEngine: second-precision mtime comparison (/ 1000 / .epochSecond)
  eliminates phantom localChanged=true from FAT32/WebDAV precision mismatch
- FileWatchService: syncCooldownUntil map suppresses FileObserver events
  for 120s after sync starts and 60s after it finishes, breaking the
  download→FileObserver→sync→download feedback loop
- Icon: three bold teardrop shapes (teal/red/amber) rotated 0/120/240°
  on dark charcoal background with white cloud at intersection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 11:51:59 +00:00
amir 34fb06a673 v1.0.28: fix sync rewrite/delete loop, Avast-inspired icon
Sync loop root-cause fixes (three independent bugs):

1. Folder change clears stale file states (AddPairViewModel): when
   localPath or remotePath changes on an existing pair, all
   SyncFileStateEntity records are wiped. Previously those stale records
   caused every sync to attempt DELETE_REMOTE on the old folder's files
   and to treat all new-folder files as changed — causing both the
   "deleting 32 files" loop and rewrites on every run.

2. Download stores null localModifiedAt (SyncEngine): SAF document
   cursors can return a stale mtime immediately after a write. Storing
   null forces the SKIP reconciliation pass on the next sync to read
   the actual walkFiles cursor value, breaking the download->changed->
   download loop caused by mtime inconsistency.

3. Second-precision mtime comparison (syncDecide): WebDAV RFC-1123 has
   1-second precision; FAT32 has 2-second precision. Comparing at
   millisecond level caused phantom "changed" detections after syncing
   to/from these systems. Now uses epochSecond for both local and remote.

Icon: three bold teal/red/yellow teardrop streaks (Avast palette) flying
into a white cloud centre, on dark charcoal background.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 04:18:13 +00:00
amir dc2a0b2c68 v1.0.27: knot-inspired icon, fix media-not-found on photo open
Icon: two thick tube-style arcs with 3D glossy highlights.
  Arc 1 (left side): coral #E8665A to orange #E8A040
  Arc 2 (right side): steel blue #4A7FD4 to deep purple #7B5EA7
  Arrowheads: orange and purple. Background: dark purple-black.
  Inspired by the braided knot color palette.

Fix "media not found" when opening photos:
  - Intent now sets ClipData alongside FLAG_GRANT_READ_URI_PERMISSION
    so the permission correctly propagates through the system chooser
    to whichever app the user picks.
  - openFile() and downloadToCache() both call MediaScannerConnection
    so newly synced/downloaded files appear in gallery MediaStore index
    before the viewer launches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 03:08:44 +00:00
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
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
16 changed files with 1015 additions and 347 deletions
+3
View File
@@ -66,13 +66,16 @@
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" /> <action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter> </intent-filter>
</receiver> </receiver>
<!-- File watcher for ON_CHANGE sync pairs --> <!-- File watcher for ON_CHANGE sync pairs -->
<!-- stopWithTask=false keeps the service alive when the user swipes the app away -->
<service <service
android:name=".worker.FileWatchService" android:name=".worker.FileWatchService"
android:foregroundServiceType="dataSync|shortService" android:foregroundServiceType="dataSync|shortService"
android:stopWithTask="false"
android:exported="false" /> android:exported="false" />
<!-- Required on API 29+ so WorkManager can start a typed foreground service --> <!-- Required on API 29+ so WorkManager can start a typed foreground service -->
@@ -1,7 +1,7 @@
package com.syncflow.data.providers.webdav package com.syncflow.data.providers.webdav
import android.util.Log
import com.syncflow.data.providers.CloudProvider import com.syncflow.data.providers.CloudProvider
import timber.log.Timber
import com.syncflow.domain.model.CloudAccount import com.syncflow.domain.model.CloudAccount
import com.syncflow.domain.model.RemoteFile import com.syncflow.domain.model.RemoteFile
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -59,14 +59,13 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val req = Request.Builder().url(baseUrl).method("PROPFIND", PROPFIND_BODY).header("Depth", "0").build() val req = Request.Builder().url(baseUrl).method("PROPFIND", PROPFIND_BODY).header("Depth", "0").build()
client.newCall(req).execute().use { resp -> client.newCall(req).execute().use { resp ->
Log.d("SyncFlow/WebDAV", "testConnection ${resp.code} url=$baseUrl") Timber.d("WebDAV testConnection %d url=%s", resp.code, baseUrl)
if (!resp.isSuccessful && resp.code != 207) { if (!resp.isSuccessful && resp.code != 207) {
val body = resp.body?.string()?.take(300) ?: "" throw Exception("HTTP ${resp.code} ${resp.message}")
throw Exception("HTTP ${resp.code} ${resp.message}$body")
} }
} }
} }
}.onFailure { e -> Log.e("SyncFlow/WebDAV", "testConnection failed: ${e::class.simpleName}: ${e.message}", e.cause) } }.onFailure { e -> Timber.e(e, "WebDAV testConnection failed") }
override suspend fun listFiles(remotePath: String): Result<List<RemoteFile>> = runCatching { override suspend fun listFiles(remotePath: String): Result<List<RemoteFile>> = runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -192,9 +191,14 @@ 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 }
val relPath = "$parentPath/$name".replace("//", "/") // Guard against path-traversal sequences delivered by a malicious server
results.add(RemoteFile(relPath, name, isCollection, contentLength, lastModified, etag, contentType)) if (name.contains("..") || name.contains('/') || name.contains('\\')) {
inResponse = false 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) { 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",
@@ -62,13 +62,28 @@ class SyncEngine @Inject constructor(
else else
LocalAccessor.JavaFile(File(localPath)) LocalAccessor.JavaFile(File(localPath))
private suspend fun performSync(pair: SyncPair, provider: CloudProvider): SyncResult { private suspend fun performSync(
pair: SyncPair,
provider: CloudProvider,
isRetry: Boolean = false,
): SyncResult {
val accessor = makeAccessor(pair.localPath) val accessor = makeAccessor(pair.localPath)
val knownStates = fileStateDao.getForPair(pair.id).associateBy { it.relativePath } var knownStates = fileStateDao.getForPair(pair.id).associateBy { it.relativePath }
val remoteFiles = provider.listFiles(pair.remotePath).getOrThrow() val remoteFiles = provider.listFiles(pair.remotePath).getOrThrow()
.associateBy { it.path.removePrefix(pair.remotePath).trimStart('/') } .associateBy { it.path.removePrefix(pair.remotePath).trimStart('/') }
val localFiles = accessor.walkFiles(pair) val localFiles = accessor.walkFiles(pair)
// Self-healing: if every known-state path is absent from the current local scan but
// the local folder does have files, the localPath was changed without clearing state.
// The stale records would cause every old file to look like "DELETE_REMOTE" and every
// new file to re-upload indefinitely. Wipe and retry once as a fresh initial sync.
if (!isRetry && knownStates.isNotEmpty() && localFiles.isNotEmpty() &&
knownStates.keys.none { it in localFiles }) {
Timber.w("SyncEngine: stale folder states detected for pair ${pair.id} — resetting")
fileStateDao.deleteForPair(pair.id)
return performSync(pair, provider, isRetry = true)
}
val allPaths = (localFiles.keys + remoteFiles.keys + knownStates.keys).toSet() val allPaths = (localFiles.keys + remoteFiles.keys + knownStates.keys).toSet()
val hasPriorSyncState = knownStates.isNotEmpty() val hasPriorSyncState = knownStates.isNotEmpty()
val semaphore = Semaphore(4) val semaphore = Semaphore(4)
@@ -126,7 +141,9 @@ class SyncEngine @Inject constructor(
logEvent(pair.id, SyncEventType.FILE_DOWNLOADED, rel, null, bytes) logEvent(pair.id, SyncEventType.FILE_DOWNLOADED, rel, null, bytes)
FileOutcome(downloaded = 1, bytesTransferred = bytes, FileOutcome(downloaded = 1, bytesTransferred = bytes,
newState = buildState(pair.id, rel, newState = buildState(pair.id, rel,
LocalFileInfo(rel, remote!!.sizeBytes, localMtime), remoteAfterTransfer = remote)) LocalFileInfo(rel, remote!!.sizeBytes, localMtime),
remoteAfterTransfer = remote,
storeLocalMtime = false))
} }
SyncDecision.DELETE_LOCAL -> { SyncDecision.DELETE_LOCAL -> {
accessor.delete(rel) accessor.delete(rel)
@@ -203,10 +220,13 @@ class SyncEngine @Inject constructor(
rel: String, rel: String,
local: LocalFileInfo?, local: LocalFileInfo?,
remoteAfterTransfer: RemoteFile?, remoteAfterTransfer: RemoteFile?,
storeLocalMtime: Boolean = true,
) = SyncFileStateEntity( ) = SyncFileStateEntity(
syncPairId = pairId, syncPairId = pairId,
relativePath = rel, relativePath = rel,
localModifiedAt = local?.lastModifiedMs?.let { Instant.ofEpochMilli(it) }, // When storeLocalMtime=false, leave localModifiedAt null so the SKIP reconciliation
// pass on the next sync reads it from the walkFiles cursor (avoids SAF stale-mtime loops).
localModifiedAt = if (storeLocalMtime) local?.lastModifiedMs?.let { Instant.ofEpochMilli(it) } else null,
localSizeBytes = local?.sizeBytes ?: 0L, localSizeBytes = local?.sizeBytes ?: 0L,
localHash = null, localHash = null,
remoteModifiedAt = remoteAfterTransfer?.modifiedAt, remoteModifiedAt = remoteAfterTransfer?.modifiedAt,
@@ -236,12 +256,16 @@ internal fun syncDecide(
// Treat null known timestamps as "not yet recorded" — don't treat as changed. // Treat null known timestamps as "not yet recorded" — don't treat as changed.
// The SKIP reconciliation pass will fill them in on the next sync. // The SKIP reconciliation pass will fill them in on the next sync.
// Use second-precision for both sides: FAT32 has 2-second mtime resolution, WebDAV
// RFC-1123 has 1-second resolution, so millisecond comparison causes phantom "changed"
// detections and rewrite loops after a fresh download/upload.
val localChanged = known == null || val localChanged = known == null ||
(localExists && known.localModifiedAt != null && (localExists && known.localModifiedAt != null &&
local!!.lastModifiedMs != known.localModifiedAt.toEpochMilli()) local!!.lastModifiedMs / 1000 != known.localModifiedAt.epochSecond)
val remoteChanged = known == null || val remoteChanged = known == null ||
(remoteExists && known.remoteModifiedAt != null && (remoteExists && known.remoteModifiedAt != null &&
(remote!!.etag != known.remoteEtag || remote.modifiedAt != known.remoteModifiedAt)) (remote!!.etag != known.remoteEtag ||
remote.modifiedAt.epochSecond != known.remoteModifiedAt.epochSecond))
return when { return when {
!localExists && !remoteExists -> SyncDecision.SKIP !localExists && !remoteExists -> SyncDecision.SKIP
@@ -5,6 +5,7 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.syncflow.data.db.CloudAccountDao import com.syncflow.data.db.CloudAccountDao
import com.syncflow.data.db.SyncFileStateDao
import com.syncflow.data.db.SyncPairDao import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.CloudAccountEntity import com.syncflow.data.db.entities.CloudAccountEntity
import com.syncflow.data.db.entities.SyncPairEntity import com.syncflow.data.db.entities.SyncPairEntity
@@ -58,6 +59,7 @@ data class AddPairUiState(
@HiltViewModel @HiltViewModel
class AddPairViewModel @Inject constructor( class AddPairViewModel @Inject constructor(
private val syncPairDao: SyncPairDao, private val syncPairDao: SyncPairDao,
private val fileStateDao: SyncFileStateDao,
private val accountDao: CloudAccountDao, private val accountDao: CloudAccountDao,
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
savedState: SavedStateHandle, savedState: SavedStateHandle,
@@ -148,7 +150,20 @@ class AddPairViewModel @Inject constructor(
notifyOnComplete = s.notifyOnComplete, notifyOnError = s.notifyOnError, notifyOnComplete = s.notifyOnComplete, notifyOnError = s.notifyOnError,
isEnabled = true, lastSyncAt = null, lastSyncResult = SyncStatus.IDLE, pendingConflicts = 0, isEnabled = true, lastSyncAt = null, lastSyncResult = SyncStatus.IDLE, pendingConflicts = 0,
) )
if (editPairId == null) syncPairDao.insert(entity) else syncPairDao.update(entity) if (editPairId == null) {
syncPairDao.insert(entity)
} else {
val existing = syncPairDao.getById(editPairId)
syncPairDao.update(entity)
// If local or remote folder changed, old file-state records no longer
// correspond to any real path — wipe them so the next sync starts fresh
// instead of trying to delete/re-upload stale paths.
if (existing != null &&
(existing.localPath != entity.localPath || existing.remotePath != entity.remotePath)
) {
fileStateDao.deleteForPair(editPairId)
}
}
} }
.onSuccess { .onSuccess {
if (s.scheduleType == ScheduleType.ON_CHANGE) FileWatchService.start(context) if (s.scheduleType == ScheduleType.ON_CHANGE) FileWatchService.start(context)
@@ -1,5 +1,12 @@
package com.syncflow.ui.files package com.syncflow.ui.files
import android.content.ClipData
import android.content.Intent
import android.net.Uri
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
@@ -10,15 +17,20 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.data.db.entities.SyncFileStateEntity import com.syncflow.data.db.entities.SyncFileStateEntity
import kotlinx.coroutines.launch
import java.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,
@@ -27,227 +39,488 @@ 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 snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
var showDeleteSelectedDialog by remember { mutableStateOf(false) }
Column(modifier = modifier.fillMaxSize()) { BackHandler(enabled = isSelectionMode) { vm.clearSelection() }
// Pair selector chips
if (pairs.size > 1) { LaunchedEffect(Unit) {
ScrollableTabRow( vm.fileAction.collect { action ->
selectedTabIndex = pairs.indexOfFirst { it.id == selectedPair?.id }.coerceAtLeast(0), when (action) {
edgePadding = 16.dp, is FileAction.Open -> {
containerColor = MaterialTheme.colorScheme.surface, try {
divider = {}, val uri = FileProvider.getUriForFile(
) { context, "${context.packageName}.fileprovider", action.file
pairs.forEach { pair -> )
Tab( val mimeType = action.file.name.mimeType()
selected = pair.id == selectedPair?.id, val intent = Intent(Intent.ACTION_VIEW).apply {
onClick = { vm.selectPair(pair.id) }, setDataAndType(uri, mimeType)
text = { // ClipData is required so FLAG_GRANT_READ_URI_PERMISSION
Text( // propagates to whichever app the system chooser picks.
pair.name, clipData = ClipData.newRawUri("", uri)
maxLines = 1, addFlags(
overflow = TextOverflow.Ellipsis, Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_ACTIVITY_NEW_TASK
) )
}, }
) context.startActivity(intent)
} 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)
clipData = ClipData.newRawUri("", 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.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 {
snackbarHostState.showSnackbar(action.message)
} }
} }
HorizontalDivider()
} }
}
if (pairs.isEmpty()) { if (showDeleteSelectedDialog) {
Box( AlertDialog(
modifier = Modifier.fillMaxSize(), onDismissRequest = { showDeleteSelectedDialog = false },
contentAlignment = Alignment.Center, icon = { Icon(Icons.Default.Delete, contentDescription = null) },
) { title = { Text("Delete $selectedCount file${if (selectedCount != 1) "s" else ""}?") },
Column( text = { Text("Selected files will be removed from this device.") },
horizontalAlignment = Alignment.CenterHorizontally, confirmButton = {
verticalArrangement = Arrangement.spacedBy(8.dp), TextButton(onClick = {
vm.deleteSelected()
showDeleteSelectedDialog = false
}) { Text("Delete", color = MaterialTheme.colorScheme.error) }
},
dismissButton = {
TextButton(onClick = { showDeleteSelectedDialog = false }) { Text("Cancel") }
},
)
}
Box(modifier = modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
// Selection toolbar
if (isSelectionMode) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
tonalElevation = 3.dp,
) { ) {
Icon( Row(
Icons.Default.FolderOpen, modifier = Modifier
contentDescription = null, .fillMaxWidth()
modifier = Modifier.size(72.dp), .padding(horizontal = 4.dp, vertical = 4.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), verticalAlignment = Alignment.CenterVertically,
) ) {
Text("No sync pairs yet", style = MaterialTheme.typography.titleMedium) IconButton(onClick = { vm.clearSelection() }) {
Text( Icon(Icons.Default.Close, contentDescription = "Clear selection")
"Create a sync pair to browse its files", }
style = MaterialTheme.typography.bodySmall, Text(
color = MaterialTheme.colorScheme.onSurfaceVariant, "$selectedCount selected",
) style = MaterialTheme.typography.titleSmall,
} modifier = Modifier.weight(1f),
} )
} else if (files.isEmpty()) { IconButton(onClick = { vm.shareSelected() }) {
Box( Icon(Icons.Default.Share, contentDescription = "Share selected")
modifier = Modifier.fillMaxSize(), }
contentAlignment = Alignment.Center, IconButton(onClick = { showDeleteSelectedDialog = true }) {
) { Icon(
Column( Icons.Default.Delete, contentDescription = "Delete selected",
horizontalAlignment = Alignment.CenterHorizontally, tint = MaterialTheme.colorScheme.error,
verticalArrangement = Arrangement.spacedBy(8.dp), )
) { }
Icon( }
Icons.Default.FolderOpen,
contentDescription = null,
modifier = Modifier.size(72.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.35f),
)
Text("No synced files yet", style = MaterialTheme.typography.titleMedium)
Text(
"Run a sync to populate this view",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
} else {
// Summary row
Surface(
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"${files.size} file${if (files.size != 1) "s" else ""}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val totalBytes = files.sumOf { it.localSizeBytes }
Text(
totalBytes.toDisplaySize(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
HorizontalDivider()
} }
LazyColumn( // Pair tabs
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), if (pairs.size > 1 && !isSelectionMode) {
verticalArrangement = Arrangement.spacedBy(0.dp), ScrollableTabRow(
) { selectedTabIndex = pairs.indexOfFirst { it.id == selectedPair?.id }.coerceAtLeast(0),
// Group by top-level directory edgePadding = 16.dp,
val grouped = files.groupBy { f -> containerColor = MaterialTheme.colorScheme.surface,
val slashIdx = f.relativePath.indexOf('/') divider = {},
if (slashIdx < 0) "" else f.relativePath.substring(0, slashIdx) ) {
pairs.forEach { pair ->
Tab(
selected = pair.id == selectedPair?.id,
onClick = { vm.selectPair(pair.id) },
text = { Text(pair.name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
)
}
} }
grouped.forEach { (dir, dirFiles) -> HorizontalDivider()
if (dir.isNotEmpty()) { }
item(key = "dir_$dir") {
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 -> {
if (!isSelectionMode) {
Surface(color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) {
Row( Row(
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon(
Icons.Default.Folder,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(6.dp))
Text( Text(
dir, "${files.size} file${if (files.size != 1) "s" else ""}",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
files.sumOf { it.localSizeBytes }.toDisplaySize(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} }
} }
items(dirFiles, key = { "${it.syncPairId}_${it.relativePath}" }) { file ->
FileRow(file, isInSubDir = dir.isNotEmpty()) LazyColumn(
HorizontalDivider( contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f), verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = Modifier.padding(start = if (dir.isNotEmpty()) 38.dp else 16.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() && !isSelectionMode) {
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() && !isSelectionMode,
isSelectionMode = isSelectionMode,
isSelected = vm.fileKey(file) in selectedKeys,
vm = vm,
)
HorizontalDivider(
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f),
modifier = Modifier.padding(start = if (dir.isNotEmpty() && !isSelectionMode) 38.dp else 16.dp),
)
}
}
item { Spacer(Modifier.height(80.dp)) }
}
}
}
}
// 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(
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,
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun FileRow(
file: SyncFileStateEntity,
isInSubDir: Boolean,
isSelectionMode: Boolean,
isSelected: 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") }
},
)
}
Surface(
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
else MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth()
.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,
top = 10.dp,
bottom = 10.dp,
end = 0.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
if (isSelectionMode) {
Checkbox(
checked = isSelected,
onCheckedChange = { vm.toggleSelection(file) },
modifier = Modifier.padding(horizontal = 4.dp),
)
} else {
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,
)
}
if (!isSelectionMode) {
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, null) },
onClick = { menuExpanded = false; vm.openFile(file) },
)
DropdownMenuItem(
text = { Text("Share") },
leadingIcon = { Icon(Icons.Default.Share, null) },
onClick = { menuExpanded = false; vm.shareFile(file) },
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Rename") },
leadingIcon = { Icon(Icons.Default.Edit, null) },
onClick = { menuExpanded = false; showRenameDialog = true },
)
DropdownMenuItem(
text = { Text("Delete", color = MaterialTheme.colorScheme.error) },
leadingIcon = {
Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error)
},
onClick = { menuExpanded = false; showDeleteDialog = true },
) )
} }
} }
item { Spacer(Modifier.height(80.dp)) }
} }
} }
} }
} }
@Composable @Composable
private fun FileRow(file: SyncFileStateEntity, isInSubDir: Boolean) { private fun RenameDialog(currentName: String, onConfirm: (String) -> Unit, onDismiss: () -> Unit) {
val name = file.relativePath.substringAfterLast('/') var newName by remember { mutableStateOf(currentName) }
val timeFmt = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT) AlertDialog(
val syncedAt = file.lastSyncedAt.atZone(ZoneId.systemDefault()).let { timeFmt.format(it) } 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") } },
)
}
Row( private fun String.mimeType(): String {
modifier = Modifier val ext = substringAfterLast('.', "").lowercase()
.fillMaxWidth() return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: "*/*"
.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,
)
}
// Sync status indicator
Icon(
Icons.Default.CheckCircle,
contentDescription = "Synced",
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f),
)
}
} }
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
} }
private fun Long.toDisplaySize(): String = when { private fun Long.toDisplaySize(): String = when {
this < 1_024 -> "${this} B" this < 1_024 -> "$this B"
this < 1_048_576 -> "${"%.1f".format(this / 1_024.0)} KB" this < 1_048_576 -> "${"%.1f".format(this / 1_024.0)} KB"
this < 1_073_741_824 -> "${"%.1f".format(this / 1_048_576.0)} MB" this < 1_073_741_824 -> "${"%.1f".format(this / 1_048_576.0)} MB"
else -> "${"%.1f".format(this / 1_073_741_824.0)} GB" else -> "${"%.1f".format(this / 1_073_741_824.0)} GB"
@@ -1,21 +1,40 @@
package com.syncflow.ui.files package com.syncflow.ui.files
import android.content.Context
import android.media.MediaScannerConnection
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.syncflow.data.db.SyncFileStateDao import com.syncflow.data.db.SyncFileStateDao
import com.syncflow.data.db.SyncPairDao import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.SyncFileStateEntity import com.syncflow.data.db.entities.SyncFileStateEntity
import com.syncflow.data.db.entities.SyncPairEntity import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.data.providers.ProviderFactory
import com.syncflow.data.repository.AccountRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.update
import timber.log.Timber
import java.io.File
import javax.inject.Inject import javax.inject.Inject
sealed class FileAction {
data class Open(val file: File) : FileAction()
data class Share(val file: File) : FileAction()
data class ShareMultiple(val files: List<File>) : FileAction()
data class Error(val message: String) : FileAction()
}
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel @HiltViewModel
class FilesViewModel @Inject constructor( class FilesViewModel @Inject constructor(
syncPairDao: SyncPairDao, private val syncPairDao: SyncPairDao,
private val fileStateDao: SyncFileStateDao, private val fileStateDao: SyncFileStateDao,
private val accountRepository: AccountRepository,
private val providerFactory: ProviderFactory,
@ApplicationContext private val context: Context,
) : ViewModel() { ) : ViewModel() {
val pairs: StateFlow<List<SyncPairEntity>> = syncPairDao.observeAll() val pairs: StateFlow<List<SyncPairEntity>> = syncPairDao.observeAll()
@@ -35,5 +54,188 @@ class FilesViewModel @Inject constructor(
} }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val _fileAction = MutableSharedFlow<FileAction>()
val fileAction: SharedFlow<FileAction> = _fileAction
private val _isDownloading = MutableStateFlow(false)
val isDownloading: StateFlow<Boolean> = _isDownloading
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) {
val resolved = resolveFile(file, emitErrorIfMissing = false)
if (resolved != null) {
// Ensure MediaStore knows about this file so gallery apps can open it
MediaScannerConnection.scanFile(context, arrayOf(resolved.absolutePath), null, 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}"))
}
}
}
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()
}
MediaScannerConnection.scanFile(context, arrayOf(cacheFile.absolutePath), null, null)
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 }
}
} }
@@ -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(
@@ -18,7 +18,8 @@ class BootReceiver : BroadcastReceiver() {
@Inject lateinit var syncPairDao: SyncPairDao @Inject lateinit var syncPairDao: SyncPairDao
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return val validActions = setOf(Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED)
if (intent.action !in validActions) return
val wm = WorkManager.getInstance(context) val wm = WorkManager.getInstance(context)
val pending = goAsync() val pending = goAsync()
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
@@ -13,13 +13,18 @@ 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.SyncPairDao import com.syncflow.data.db.SyncPairDao
import com.syncflow.domain.model.ScheduleType import com.syncflow.domain.model.ScheduleType
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
@@ -28,13 +33,20 @@ import javax.inject.Inject
class FileWatchService : Service() { class FileWatchService : Service() {
@Inject lateinit var syncPairDao: SyncPairDao @Inject lateinit var syncPairDao: SyncPairDao
@Inject lateinit var fileStateDao: SyncFileStateDao
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
// Prevents concurrent refresh() calls from doubling watchers + catchup scans
private val refreshMutex = Mutex()
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>()
// After a watcher-triggered sync completes, suppress FileObserver events for this long
// to stop the feedback loop: sync writes files → FileObserver fires → another sync → repeat.
private val syncCooldownUntil = mutableMapOf<Long, Long>()
companion object { companion object {
const val CHANNEL_WATCH = "sync_watching" const val CHANNEL_WATCH = "sync_watching"
@@ -73,7 +85,7 @@ class FileWatchService : Service() {
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
private suspend fun refresh() { private suspend fun refresh() = refreshMutex.withLock {
clearWatchers() clearWatchers()
val pairs = syncPairDao.getEnabled().filter { it.scheduleType == ScheduleType.ON_CHANGE } val pairs = syncPairDao.getEnabled().filter { it.scheduleType == ScheduleType.ON_CHANGE }
@@ -104,7 +116,7 @@ class FileWatchService : Service() {
} }
} }
val count = fileObservers.size + contentObservers.size val count = fileObservers.keys.size + contentObservers.size
updateNotification(count) updateNotification(count)
if (count == 0) { if (count == 0) {
@@ -136,43 +148,156 @@ 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()
// Set startup cooldown BEFORE registering watchers so inotify events that fire
// immediately on registration don't trigger the debounce before catchupScan runs.
syncCooldownUntil[pairId] = System.currentTimeMillis() + 15_000
watchDirRecursive(dir, pairId, wifiOnly, chargingOnly)
Timber.d("FileWatchService: watching pair $pairId at $path (${fileObservers[pairId]?.size} dirs)")
scope.launch { catchupScan(pairId, dir, wifiOnly, chargingOnly) }
}
private fun watchDirRecursive(dir: File, pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean) {
if (!dir.isDirectory) return
val mask = FileObserver.CREATE or FileObserver.DELETE or FileObserver.MODIFY or val mask = FileObserver.CREATE or FileObserver.DELETE or FileObserver.MODIFY or
FileObserver.MOVED_FROM or FileObserver.MOVED_TO FileObserver.MOVED_FROM or FileObserver.MOVED_TO
val observer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val observer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
object : FileObserver(dir, mask) { object : FileObserver(dir, mask) {
override fun onEvent(event: Int, p: String?) = onChangeDetected(pairId, wifiOnly, chargingOnly) override fun onEvent(event: Int, path: String?) {
if (event and FileObserver.CREATE != 0 && path != null) {
val created = File(dir, path)
if (created.isDirectory) watchDirRecursive(created, pairId, wifiOnly, chargingOnly)
}
onChangeDetected(pairId, wifiOnly, chargingOnly)
}
} }
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
object : FileObserver(path, mask) { object : FileObserver(dir.absolutePath, mask) {
override fun onEvent(event: Int, p: String?) = onChangeDetected(pairId, wifiOnly, chargingOnly) override fun onEvent(event: Int, path: String?) {
if (event and FileObserver.CREATE != 0 && path != null) {
val created = File(dir, path)
if (created.isDirectory) watchDirRecursive(created, pairId, wifiOnly, chargingOnly)
}
onChangeDetected(pairId, wifiOnly, chargingOnly)
}
} }
} }
observer.startWatching() observer.startWatching()
fileObservers[pairId] = observer fileObservers.getOrPut(pairId) { mutableListOf() }.add(observer)
Timber.d("FileWatchService: watching filesystem path for pair $pairId: $path") // Recursively watch existing subdirectories
dir.listFiles()?.filter { it.isDirectory }?.forEach { sub ->
watchDirRecursive(sub, pairId, wifiOnly, chargingOnly)
}
}
private suspend fun catchupScan(pairId: Long, dir: File, wifiOnly: Boolean, chargingOnly: Boolean) {
val known = fileStateDao.getForPair(pairId).associateBy { it.relativePath }
if (known.isEmpty()) return // Never synced — first sync will be triggered manually
val current = mutableMapOf<String, Long>()
dir.walk().filter { it.isFile }.forEach { f ->
current[f.relativeTo(dir).path.replace('\\', '/')] = f.lastModified()
}
val hasNew = current.any { (rel, _) -> rel !in known }
val hasModified = current.any { (rel, mtime) ->
val s = known[rel]; s != null && s.localModifiedAt != null &&
s.localModifiedAt.toEpochMilli() != mtime
}
val hasDeleted = known.keys.any { rel -> rel !in current }
if (hasNew || hasModified || hasDeleted) {
Timber.d("FileWatchService: catchup detected changes for pair $pairId, scheduling sync")
val pair = syncPairDao.getById(pairId) ?: return
// Cancel any debounce that started before our startup cooldown was set
debounceJobs[pairId]?.cancel()
debounceJobs.remove(pairId)
// Hold cooldown for duration of sync + 60s settle
syncCooldownUntil[pairId] = System.currentTimeMillis() + 120_000
val req = SyncWorker.buildOneTimeRequest(pairId, wifiOnly, chargingOnly)
WorkManager.getInstance(applicationContext)
.enqueueUniqueWork("catchup_$pairId", ExistingWorkPolicy.KEEP, req)
scope.launch {
try {
WorkManager.getInstance(applicationContext)
.getWorkInfoByIdFlow(req.id)
.first { it?.state?.isFinished == true }
syncCooldownUntil[pairId] = System.currentTimeMillis() + 60_000
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
syncCooldownUntil[pairId] = System.currentTimeMillis() + 60_000
}
}
}
} }
private fun onChangeDetected(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean) { private fun onChangeDetected(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean) {
// Ignore events fired by our own sync writing files — prevents the feedback loop
// where downloaded/uploaded files trigger another sync indefinitely.
if (System.currentTimeMillis() < (syncCooldownUntil[pairId] ?: 0L)) {
Timber.d("FileWatchService: suppressing change event for pair $pairId (sync cooldown)")
return
}
debounceJobs[pairId]?.cancel() debounceJobs[pairId]?.cancel()
debounceJobs[pairId] = scope.launch { debounceJobs[pairId] = scope.launch {
delay(5_000) delay(5_000)
// Re-check: catchupScan or another path may have already set a cooldown
// and handled this sync while we were waiting.
if (System.currentTimeMillis() < (syncCooldownUntil[pairId] ?: 0L)) {
Timber.d("FileWatchService: debounce fired but cooldown active for pair $pairId, skipping")
return@launch
}
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)
// Block new triggers from this point until 60s after sync completes
syncCooldownUntil[pairId] = System.currentTimeMillis() + 120_000
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)
updateNotificationDynamic("Syncing: ${pair.name}")
scope.launch {
try {
val info = WorkManager.getInstance(applicationContext)
.getWorkInfoByIdFlow(req.id)
.first { it?.state?.isFinished == true }
// Extend cooldown: 60s after sync finishes to let filesystem settle
syncCooldownUntil[pairId] = System.currentTimeMillis() + 60_000
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)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
syncCooldownUntil[pairId] = System.currentTimeMillis() + 60_000
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()
debounceJobs.values.forEach { it.cancel() } debounceJobs.values.forEach { it.cancel() }
debounceJobs.clear() debounceJobs.clear()
syncCooldownUntil.clear()
} }
private fun ensureChannel() { private fun ensureChannel() {
@@ -187,7 +312,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 },
@@ -196,7 +321,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)
@@ -210,4 +335,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,12 @@
<?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"
<gradient android:width="108dp"
android:type="radial" android:height="108dp"
android:gradientRadius="80%" android:viewportWidth="108"
android:centerX="0.35" android:viewportHeight="108">
android:centerY="0.3"
android:startColor="#1C1124" <!-- Pure black background -->
android:centerColor="#0E0A18" <path android:pathData="M0,0 H108 V108 H0 Z"
android:endColor="#060408"/> android:fillColor="#000000"/>
</shape>
</vector>
@@ -1,88 +1,84 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<vector 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:width="108dp"
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<!-- Soft ambient glow underneath everything --> <!--
Four thick arcs arranged as an interlocked pinwheel.
Each arc sweeps ~210 degrees, rounded caps, radius 18 from center (54,54).
Draw order creates natural over/under at the four crossing points:
blue under green, green under red, red under orange, orange under blue (re-draw blue tip).
Arc endpoints computed at radius 18, sweep 210 deg clockwise:
start angle end angle start point end point
270 (top) 120 (54, 36) (45, 70)
0 (right) 210 (72, 54) (39, 45)
90 (bot) 300 (54, 72) (63, 38)
180 (left) 390=30 (36, 54) (69, 63)
-->
<!-- Blue — starts at top, sweeps clockwise to lower-left -->
<path <path
android:pathData="M54,54m-36,0a36,36 0 1,0 72,0a36,36 0 1,0 -72,0" android:strokeColor="#2979FF"
android:fillColor="#18FF6B6B"/> android:strokeWidth="8.5"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 54,36 A 18,18 0 1,1 45,70"/>
<!-- ═══ Arrow 1: pointing RIGHT (top) — electric coral ═══ --> <!-- Green — starts at bottom, sweeps clockwise to upper-right -->
<!-- Shaft with rounded left cap --> <path
<path android:pathData="M27,31 Q22,31 22,36 Q22,41 27,41 L64,41 L64,31 Z"> android:strokeColor="#00C853"
<aapt:attr name="android:fillColor"> android:strokeWidth="8.5"
<gradient android:type="linear" android:fillColor="#00000000"
android:startX="22" android:startY="36" android:strokeLineCap="round"
android:endX="64" android:endY="36" android:pathData="M 54,72 A 18,18 0 1,1 63,38"/>
android:startColor="#FF6B6B"
android:endColor="#FF9F6B"/>
</aapt:attr>
</path>
<!-- Arrowhead -->
<path android:pathData="M63,27 L63,45 L86,36 Z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear"
android:startX="63" android:startY="36"
android:endX="86" android:endY="36"
android:startColor="#FF9F6B"
android:endColor="#FFB347"/>
</aapt:attr>
</path>
<!-- ═══ Arrow 2: pointing LEFT (middle) — cool white/silver ═══ --> <!-- Red — starts at right, sweeps clockwise to lower-left -->
<!-- Shaft with rounded right cap --> <path
<path android:pathData="M44,50 L81,50 Q86,50 86,55 Q86,60 81,60 L44,60 Z"> android:strokeColor="#E53935"
<aapt:attr name="android:fillColor"> android:strokeWidth="8.5"
<gradient android:type="linear" android:fillColor="#00000000"
android:startX="44" android:startY="55" android:strokeLineCap="round"
android:endX="86" android:endY="55" android:pathData="M 72,54 A 18,18 0 1,1 39,45"/>
android:startColor="#B0B8D0"
android:endColor="#E8EDF5"/>
</aapt:attr>
</path>
<!-- Arrowhead -->
<path android:pathData="M45,46 L45,64 L22,55 Z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear"
android:startX="45" android:startY="55"
android:endX="22" android:endY="55"
android:startColor="#B0B8D0"
android:endColor="#8892A8"/>
</aapt:attr>
</path>
<!-- ═══ Arrow 3: pointing RIGHT (bottom) — electric teal ═══ --> <!-- Orange — starts at left, sweeps clockwise to upper-right -->
<!-- Shaft with rounded left cap --> <path
<path android:pathData="M27,69 Q22,69 22,74 Q22,79 27,79 L64,79 L64,69 Z"> android:strokeColor="#FF6D00"
<aapt:attr name="android:fillColor"> android:strokeWidth="8.5"
<gradient android:type="linear" android:fillColor="#00000000"
android:startX="22" android:startY="74" android:strokeLineCap="round"
android:endX="64" android:endY="74" android:pathData="M 36,54 A 18,18 0 1,1 69,63"/>
android:startColor="#4DD0E1"
android:endColor="#26C6DA"/>
</aapt:attr>
</path>
<!-- Arrowhead -->
<path android:pathData="M63,65 L63,83 L86,74 Z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear"
android:startX="63" android:startY="74"
android:endX="86" android:endY="74"
android:startColor="#26C6DA"
android:endColor="#00BCD4"/>
</aapt:attr>
</path>
<!-- Small glow dots at arrowhead tips for sparkle --> <!-- Re-draw blue start cap on top so it goes OVER orange end -->
<path android:pathData="M86,36m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0" <path
android:fillColor="#FFFFB347"/> android:strokeColor="#2979FF"
<path android:pathData="M22,55m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0" android:strokeWidth="8.5"
android:fillColor="#FF8892A8"/> android:fillColor="#00000000"
<path android:pathData="M86,74m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0" android:strokeLineCap="round"
android:fillColor="#FF00BCD4"/> android:pathData="M 54,36 A 18,18 0 0,1 62,37.5"/>
<!-- White sync circle at center -->
<path
android:fillColor="#000000"
android:pathData="M 45,54 A 9,9 0 1,0 63,54 A 9,9 0 1,0 45,54 Z"/>
<!-- Sync ring -->
<path
android:strokeColor="#FFFFFF"
android:strokeWidth="2.5"
android:fillColor="#00000000"
android:pathData="M 46.5,54 A 7.5,7.5 0 1,0 61.5,54 A 7.5,7.5 0 1,0 46.5,54 Z"/>
<!-- Top arrow head (pointing up) -->
<path
android:fillColor="#FFFFFF"
android:pathData="M 54,46.5 L 57,50.5 L 51,50.5 Z"/>
<!-- Bottom arrow head (pointing down) -->
<path
android:fillColor="#FFFFFF"
android:pathData="M 54,61.5 L 51,57.5 L 57,57.5 Z"/>
</vector> </vector>
+2
View File
@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<paths> <paths>
<external-path name="external_storage" path="." />
<external-files-path name="external_files" path="." /> <external-files-path name="external_files" path="." />
<files-path name="internal_files" path="." /> <files-path name="internal_files" path="." />
<cache-path name="syncflow_cache" path="syncflow_open/" />
</paths> </paths>
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.22 VERSION_NAME=1.0.31
VERSION_CODE=23 VERSION_CODE=32