Compare commits

...

15 Commits

Author SHA1 Message Date
amir 99193af2c5 v1.0.52: fix Select button cut off on Android 16 edge-to-edge dialogs
Android 15+ enforces edge-to-edge on Dialog windows, making standard
Compose WindowInsets APIs return 0 inside dialogs. Fix: use ViewCompat
insets listener inside the Dialog to read actual system bar heights,
with 56dp minimum to guarantee full nav bar clearance. Spacer inside
the button Surface lets the elevated background extend behind the nav
bar. Also make the entire local folder field tappable (not just the
trailing icon) for better UX.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 00:14:43 +00:00
amir 3c008ec8df v1.0.47: built-in folder browsers, icon crop fix, nav bar button fix
- Replace SAF document picker with custom LocalBrowserDialog (File API,
  quick-access shortcuts, breadcrumb nav, search, folder-only listing)
- Rewrite RemoteBrowserDialog as full-screen dialog with breadcrumbs,
  search, and new-folder creation; add navigateToBreadcrumb/createFolder
  to RemoteBrowserViewModel
- Fix Select button cut off by navigation bar in both browsers: wrap
  button in Column(navigationBarsPadding()) so the button sits above
  the nav bar rather than behind it
- Tighten icon foreground crop to remove excess black border

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:46:25 +00:00
amir c869f84a9d 1.0.40: fix icon sizing (adaptive XML) + semantic status colors
- Restore mipmap-anydpi-v26 adaptive icon XMLs so Android 8+ shows
  the icon at full size instead of scaling it to 66% safe zone
- Foreground at 108dp sizes (108-432px), background #050E05
- Status colors now semantic (not tied to app red/orange theme):
  SUCCESS=green, SYNCING=blue, FAILED=red, PARTIAL=orange, CONFLICT=amber

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 19:49:34 +00:00
amir c3be23417d 1.0.39: fix OOM on large-file uploads; use exact reference icon
- WebDavProvider: replace readBytes() with streaming RequestBody
  (Okio sink.writeAll) so large files (1+ GB) upload without
  allocating the full file in heap — fixes PARTIAL sync status
- App icon: replace vector XML with PNG mipmaps generated directly
  from the user-provided reference image at all densities

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:51:23 +00:00
amir ae10ed0c82 Fix upload rollback and update app icon
Upload rollback fix: after uploading a file, do not store the remote
mtime/etag from the upload response PROPFIND. Nextcloud and other
WebDAV servers can change a file's Last-Modified or ETag after upload
(thumbnail generation, checksums, folder aggregation). Storing stale
metadata caused the next sync to see remoteChanged=true and download
the file back, reverting the upload. Leaving remoteAfterTransfer=null
forces the SKIP reconciliation pass to fill in remote state from the
directory listing, which is the same source all future syncs use.

Icon: update foreground to thick ribbons with 3D highlight stripes
(blue/green/red/orange, width 12 + highlight 5); update background
to dark space theme with star dots.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 15:59:59 +00:00
amir 897b685c70 Fix perpetual sync loop and wrong delete decisions
Three bugs fixed:

1. catchupScan used raw dir.walk() with no filters, causing hidden/excluded
   files to appear as "new" every startup and trigger a catchup sync.
   Fixed by using LocalAccessor.walkFiles(pair) which applies the same
   filters and uses the same mtime source (SAF cursor) as SyncEngine.

2. catchupScan compared localModifiedAt.toEpochMilli() vs File.lastModified()
   (millisecond precision) while SyncEngine uses second precision. Every file
   appeared "modified" after a successful sync. Fixed by using epochSecond.

3. syncDecide() treated !localExists && remoteExists && known==null as
   "user deleted local copy → delete remote" even on files that were never
   synced. Fixed to treat unknown remote files as new (download them), which
   is safe because a genuinely-deleted file will always have a known state
   record from the previous sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 15:13:43 +00:00
amir 4b20697bb1 v1.0.32: fix manual sync loop via WorkManager tag monitor
Root cause: manual sync triggered from the UI had no cooldown set in
FileWatchService, so file writes during any manual sync fired FileObserver
→ debounce → another sync → loop.

Fix: startSyncMonitor() subscribes to getWorkInfosByTagFlow("sync_$pairId")
and watches ALL sync work for each pair — manual, catchup, onchange — via
the tag that SyncWorker.buildOneTimeRequest() always adds.
  - When any sync is RUNNING or ENQUEUED: cooldown extended to now+120s
  - When sync transitions from running to finished: 60s settle cooldown
  - Monitor job stored in syncMonitorJobs map and cancelled in clearWatchers()

This means no matter what triggers a sync, FileObserver events from the
resulting file writes are always suppressed until the folder settles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 14:11:58 +00:00
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
46 changed files with 1400 additions and 500 deletions
@@ -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
@@ -13,6 +13,8 @@ import okhttp3.*
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import okio.BufferedSink
import okio.source
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
import java.io.InputStream
@@ -59,14 +61,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) {
@@ -85,13 +86,18 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
onProgress: (Long) -> Unit,
): Result<RemoteFile> = runCatching {
withContext(Dispatchers.IO) {
val bytes = localStream.readBytes()
val body = bytes.toRequestBody("application/octet-stream".toMediaType())
val body = object : RequestBody() {
override fun contentType() = "application/octet-stream".toMediaType()
override fun contentLength() = sizeBytes
override fun writeTo(sink: BufferedSink) {
localStream.source().use { source -> sink.writeAll(source) }
}
}
val req = Request.Builder().url(url(remotePath)).put(body).build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) throw Exception("Upload HTTP ${resp.code}")
}
onProgress(bytes.size.toLong())
onProgress(sizeBytes)
getFileMetadata(remotePath).getOrThrow()
}
}
@@ -192,9 +198,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",
@@ -62,13 +62,28 @@ class SyncEngine @Inject constructor(
else
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 knownStates = fileStateDao.getForPair(pair.id).associateBy { it.relativePath }
var knownStates = fileStateDao.getForPair(pair.id).associateBy { it.relativePath }
val remoteFiles = provider.listFiles(pair.remotePath).getOrThrow()
.associateBy { it.path.removePrefix(pair.remotePath).trimStart('/') }
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 hasPriorSyncState = knownStates.isNotEmpty()
val semaphore = Semaphore(4)
@@ -92,11 +107,10 @@ class SyncEngine @Inject constructor(
when (decision) {
SyncDecision.UPLOAD -> {
var uploadedRemoteFile: RemoteFile? = null
val bytes = runCatching {
ensureRemoteDirs(provider, pair.remotePath, rel)
accessor.openInputStream(rel)?.use { stream ->
uploadedRemoteFile = provider.uploadFile(stream, "${pair.remotePath}/$rel", local!!.sizeBytes) { }.getOrThrow()
provider.uploadFile(stream, "${pair.remotePath}/$rel", local!!.sizeBytes) { }.getOrThrow()
}
local!!.sizeBytes
}.getOrElse { e ->
@@ -105,8 +119,12 @@ class SyncEngine @Inject constructor(
return@withPermit FileOutcome(failed = 1)
}
logEvent(pair.id, SyncEventType.FILE_UPLOADED, rel, null, bytes)
// Don't store remote metadata from upload response — the server (Nextcloud etc.)
// may change mtime/etag during post-upload processing. Leaving remoteModifiedAt
// null forces the SKIP reconciliation on the next sync to fill it in from the
// directory listing, which is the same source all future syncs will use.
FileOutcome(uploaded = 1, bytesTransferred = bytes,
newState = buildState(pair.id, rel, local!!, remoteAfterTransfer = uploadedRemoteFile))
newState = buildState(pair.id, rel, local!!, remoteAfterTransfer = null))
}
SyncDecision.DOWNLOAD -> {
val bytes = runCatching {
@@ -126,16 +144,20 @@ class SyncEngine @Inject constructor(
logEvent(pair.id, SyncEventType.FILE_DOWNLOADED, rel, null, bytes)
FileOutcome(downloaded = 1, bytesTransferred = bytes,
newState = buildState(pair.id, rel,
LocalFileInfo(rel, remote!!.sizeBytes, localMtime), remoteAfterTransfer = remote))
LocalFileInfo(rel, remote!!.sizeBytes, localMtime),
remoteAfterTransfer = remote,
storeLocalMtime = false))
}
SyncDecision.DELETE_LOCAL -> {
accessor.delete(rel)
val deleted = accessor.delete(rel)
if (!deleted) Timber.w("SyncEngine: DELETE_LOCAL failed (silent) for $rel")
fileStateDao.delete(pair.id, rel)
logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "local", 0)
FileOutcome(deleted = 1)
}
SyncDecision.DELETE_REMOTE -> {
provider.deleteFile("${pair.remotePath}/$rel")
runCatching { provider.deleteFile("${pair.remotePath}/$rel") }
.onFailure { e -> Timber.e(e, "SyncEngine: DELETE_REMOTE failed for $rel") }
fileStateDao.delete(pair.id, rel)
logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "remote", 0)
FileOutcome(deleted = 1)
@@ -203,10 +225,13 @@ class SyncEngine @Inject constructor(
rel: String,
local: LocalFileInfo?,
remoteAfterTransfer: RemoteFile?,
storeLocalMtime: Boolean = true,
) = SyncFileStateEntity(
syncPairId = pairId,
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,
localHash = null,
remoteModifiedAt = remoteAfterTransfer?.modifiedAt,
@@ -236,12 +261,16 @@ internal fun syncDecide(
// 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.
// 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 ||
(localExists && known.localModifiedAt != null &&
local!!.lastModifiedMs != known.localModifiedAt.toEpochMilli())
local!!.lastModifiedMs / 1000 != known.localModifiedAt.epochSecond)
val remoteChanged = known == 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 {
!localExists && !remoteExists -> SyncDecision.SKIP
@@ -259,21 +288,15 @@ internal fun syncDecide(
}
!localExists && remoteExists -> when {
known == null -> if (!hasPriorSyncState) {
// Initial sync: no history at all — remote files are new, download them.
known == null -> {
// No state record: could be a new remote file OR a file whose state was lost.
// Downloading is always safer than deleting — if the user deleted the local
// copy intentionally, the state record will still exist (known != null) and
// the else-branch below correctly deletes the remote copy.
when (direction) {
SyncDirection.DOWNLOAD_ONLY, SyncDirection.TWO_WAY -> SyncDecision.DOWNLOAD
else -> SyncDecision.SKIP
}
} else {
// Pair has been synced before but this file has no state record
// (e.g. uploaded before state-tracking was fixed). Treat the same
// as a known remote-deletion: apply mirror/keep behavior.
when {
deleteBehavior == DeleteBehavior.KEEP -> SyncDecision.SKIP
direction == SyncDirection.UPLOAD_ONLY || direction == SyncDirection.TWO_WAY -> SyncDecision.DELETE_REMOTE
else -> SyncDecision.SKIP
}
}
else -> when {
deleteBehavior == DeleteBehavior.KEEP -> SyncDecision.SKIP
@@ -1,10 +1,7 @@
package com.syncflow.ui.addpair
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
@@ -15,13 +12,13 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.domain.model.*
import com.syncflow.ui.browser.LocalBrowserDialog
import com.syncflow.ui.browser.RemoteBrowserDialog
import java.time.DayOfWeek
@@ -31,17 +28,18 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
val s by vm.state.collectAsState()
LaunchedEffect(s.done) { if (s.done) onDone() }
val context = LocalContext.current
var showRemoteBrowser by remember { mutableStateOf(false) }
var showLocalBrowser by remember { mutableStateOf(false) }
val dirPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
uri?.let {
context.contentResolver.takePersistableUriPermission(
it,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
vm.update { copy(localPath = it.toString()) }
}
if (showLocalBrowser) {
LocalBrowserDialog(
initialPath = s.localPath.ifBlank { "" },
onSelect = { path ->
vm.update { copy(localPath = path) }
showLocalBrowser = false
},
onDismiss = { showLocalBrowser = false },
)
}
if (showRemoteBrowser && s.selectedAccountId != -1L) {
@@ -108,18 +106,17 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
Spacer(Modifier.height(4.dp))
// Local folder
OutlinedTextField(
value = uriToDisplay(s.localPath), onValueChange = {},
label = { Text("Local folder") },
leadingIcon = { Icon(Icons.Default.PhoneAndroid, null) },
trailingIcon = {
IconButton(onClick = { dirPicker.launch(null) }) {
Icon(Icons.Default.FolderOpen, "Browse")
}
},
readOnly = true, singleLine = true, modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Tap to choose folder…") },
)
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = uriToDisplay(s.localPath), onValueChange = {},
label = { Text("Local folder") },
leadingIcon = { Icon(Icons.Default.PhoneAndroid, null) },
trailingIcon = { Icon(Icons.Default.FolderOpen, null) },
readOnly = true, singleLine = true, modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Tap to choose folder…") },
)
Box(modifier = Modifier.matchParentSize().clickable { showLocalBrowser = true })
}
// Remote folder
OutlinedTextField(
@@ -5,6 +5,7 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.syncflow.data.db.CloudAccountDao
import com.syncflow.data.db.SyncFileStateDao
import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.CloudAccountEntity
import com.syncflow.data.db.entities.SyncPairEntity
@@ -58,6 +59,7 @@ data class AddPairUiState(
@HiltViewModel
class AddPairViewModel @Inject constructor(
private val syncPairDao: SyncPairDao,
private val fileStateDao: SyncFileStateDao,
private val accountDao: CloudAccountDao,
@ApplicationContext private val context: Context,
savedState: SavedStateHandle,
@@ -148,7 +150,20 @@ class AddPairViewModel @Inject constructor(
notifyOnComplete = s.notifyOnComplete, notifyOnError = s.notifyOnError,
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 {
if (s.scheduleType == ScheduleType.ON_CHANGE) FileWatchService.start(context)
@@ -0,0 +1,352 @@
package com.syncflow.ui.browser
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
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.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
private data class LocalEntry(val file: File, val childCount: Int)
private val STORAGE_ROOT = File("/storage/emulated/0")
private data class Shortcut(val label: String, val icon: ImageVector, val path: String)
private val SHORTCUTS = listOf(
Shortcut("Camera", Icons.Default.PhotoCamera, "/storage/emulated/0/DCIM/Camera"),
Shortcut("Downloads", Icons.Default.Download, "/storage/emulated/0/Download"),
Shortcut("Documents", Icons.Default.Description, "/storage/emulated/0/Documents"),
Shortcut("Pictures", Icons.Default.Image, "/storage/emulated/0/Pictures"),
Shortcut("Music", Icons.Default.MusicNote, "/storage/emulated/0/Music"),
Shortcut("Videos", Icons.Default.Videocam, "/storage/emulated/0/Movies"),
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LocalBrowserDialog(
initialPath: String = STORAGE_ROOT.absolutePath,
onSelect: (path: String) -> Unit,
onDismiss: () -> Unit,
) {
var currentPath by remember { mutableStateOf(File(initialPath.ifBlank { STORAGE_ROOT.absolutePath }).let { if (it.isDirectory) it else STORAGE_ROOT }) }
var pathStack by remember { mutableStateOf(listOf(currentPath)) }
var entries by remember { mutableStateOf<List<LocalEntry>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var searchQuery by remember { mutableStateOf("") }
var searchActive by remember { mutableStateOf(false) }
val breadcrumbState = rememberLazyListState()
val scope = rememberCoroutineScope()
fun loadDir(dir: File) {
isLoading = true
entries = emptyList()
scope.launch {
val result = withContext(Dispatchers.IO) {
dir.listFiles()
?.filter { it.isDirectory && !it.name.startsWith(".") }
?.sortedBy { it.name.lowercase() }
?.map { f -> LocalEntry(f, f.listFiles()?.count { it.isDirectory } ?: 0) }
?: emptyList()
}
entries = result
isLoading = false
}
}
fun navigateTo(dir: File) {
currentPath = dir
pathStack = pathStack + dir
searchQuery = ""
searchActive = false
loadDir(dir)
}
fun navigateUp(): Boolean {
if (pathStack.size <= 1) return false
val newStack = pathStack.dropLast(1)
pathStack = newStack
currentPath = newStack.last()
searchQuery = ""
searchActive = false
loadDir(currentPath)
return true
}
fun navigateToBreadcrumb(dir: File) {
val idx = pathStack.indexOfLast { it.absolutePath == dir.absolutePath }
pathStack = if (idx >= 0) pathStack.take(idx + 1) else listOf(dir)
currentPath = dir
searchQuery = ""
searchActive = false
loadDir(dir)
}
LaunchedEffect(Unit) { loadDir(currentPath) }
// Build breadcrumb segments relative to storage root
val relParts = currentPath.absolutePath
.removePrefix(STORAGE_ROOT.absolutePath)
.trimStart('/')
.split('/')
.filter { it.isNotEmpty() }
// Auto-scroll breadcrumbs to end
LaunchedEffect(currentPath) {
scope.launch { breadcrumbState.animateScrollToItem(maxOf(0, relParts.size)) }
}
val filtered = if (searchQuery.isBlank()) entries
else entries.filter { it.file.name.contains(searchQuery, ignoreCase = true) }
val currentFolderName = currentPath.name.ifBlank { "Internal Storage" }
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
) {
val view = LocalView.current
val density = LocalDensity.current
var topInset by remember { mutableStateOf(0.dp) }
var bottomInset by remember { mutableStateOf(56.dp) }
DisposableEffect(view) {
ViewCompat.setOnApplyWindowInsetsListener(view) { _, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
with(density) {
topInset = bars.top.toDp()
bottomInset = maxOf(bars.bottom.toDp(), 56.dp)
}
insets
}
ViewCompat.requestApplyInsets(view)
onDispose { ViewCompat.setOnApplyWindowInsetsListener(view, null) }
}
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) {
// ── Top bar ──────────────────────────────────────────────────
TopAppBar(
navigationIcon = {
IconButton(onClick = { if (!navigateUp()) onDismiss() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, null)
}
},
title = {
if (searchActive) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
placeholder = { Text("Search folders…") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = {}),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
),
)
} else {
Text("Choose Local Folder", style = MaterialTheme.typography.titleMedium)
}
},
actions = {
IconButton(onClick = { searchActive = !searchActive; if (!searchActive) searchQuery = "" }) {
Icon(if (searchActive) Icons.Default.Close else Icons.Default.Search, "Search")
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface),
)
// ── Breadcrumbs ──────────────────────────────────────────────
Surface(tonalElevation = 1.dp) {
LazyRow(
state = breadcrumbState,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
item {
BreadcrumbChip(label = "📱 Storage", isLast = relParts.isEmpty(),
onClick = { navigateToBreadcrumb(STORAGE_ROOT) })
}
itemsIndexed(relParts) { idx, part ->
Icon(Icons.Default.ChevronRight, null, Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
val partPath = STORAGE_ROOT.absolutePath + "/" + relParts.take(idx + 1).joinToString("/")
BreadcrumbChip(label = part, isLast = idx == relParts.lastIndex,
onClick = { navigateToBreadcrumb(File(partPath)) })
}
}
}
HorizontalDivider()
// ── Content ──────────────────────────────────────────────────
Box(modifier = Modifier.weight(1f)) {
when {
isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
if (currentPath.absolutePath == STORAGE_ROOT.absolutePath && searchQuery.isBlank()) {
item {
Text("Quick access", style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 20.dp, top = 14.dp, bottom = 6.dp))
LazyRow(contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(bottom = 8.dp)) {
items(SHORTCUTS.filter { File(it.path).isDirectory }) { sc ->
ShortcutChip(sc, onClick = { navigateTo(File(sc.path)) })
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp))
Text("All folders", style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 20.dp, top = 4.dp, bottom = 4.dp))
}
}
if (filtered.isEmpty()) {
item {
Box(Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.FolderOpen, null, Modifier.size(56.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
Spacer(Modifier.height(12.dp))
Text(if (searchQuery.isBlank()) "No subfolders" else "No results for \"$searchQuery\"",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
} else {
items(filtered, key = { it.file.absolutePath }) { entry ->
LocalFolderItem(entry = entry, onClick = { navigateTo(entry.file) })
}
}
item { Spacer(Modifier.height(8.dp)) }
}
}
}
// ── Select button ────────────────────────────────────────────
Surface(tonalElevation = 4.dp, modifier = Modifier.fillMaxWidth()) {
Column {
Button(
onClick = { onSelect(currentPath.absolutePath) },
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 12.dp)
.height(52.dp),
shape = RoundedCornerShape(14.dp),
) {
Icon(Icons.Default.CheckCircle, null, Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text("Select \"$currentFolderName\"",
style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
}
Spacer(Modifier.height(bottomInset))
}
}
}
}
}
}
@Composable
private fun BreadcrumbChip(label: String, isLast: Boolean, onClick: () -> Unit) {
if (isLast) {
Surface(shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.primaryContainer) {
Text(label, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onPrimaryContainer, maxLines = 1)
}
} else {
TextButton(onClick = onClick, contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp)) {
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, maxLines = 1)
}
}
}
@Composable
private fun ShortcutChip(sc: Shortcut, onClick: () -> Unit) {
Surface(
onClick = onClick,
shape = RoundedCornerShape(14.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.height(72.dp).width(80.dp),
) {
Column(
modifier = Modifier.fillMaxSize().padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(sc.icon, null, Modifier.size(24.dp), tint = MaterialTheme.colorScheme.onSecondaryContainer)
Spacer(Modifier.height(4.dp))
Text(sc.label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSecondaryContainer, maxLines = 1)
}
}
}
@Composable
private fun LocalFolderItem(entry: LocalEntry, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Box(
modifier = Modifier
.size(46.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFF1B5E20).copy(alpha = 0.12f)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Folder, null, Modifier.size(26.dp), tint = Color(0xFF2E7D32))
}
Column(modifier = Modifier.weight(1f)) {
Text(entry.file.name, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.Medium)
if (entry.childCount > 0) {
Text("${entry.childCount} subfolder${if (entry.childCount == 1) "" else "s"}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Icon(Icons.Default.ChevronRight, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f))
}
HorizontalDivider(modifier = Modifier.padding(start = 76.dp), thickness = 0.5.dp)
}
@@ -1,22 +1,40 @@
package com.syncflow.ui.browser
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
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.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.domain.model.RemoteFile
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -30,79 +48,205 @@ fun RemoteBrowserDialog(
LaunchedEffect(accountId, initialPath) { vm.init(accountId, initialPath) }
val state by vm.state.collectAsState()
var searchQuery by remember { mutableStateOf("") }
var searchActive by remember { mutableStateOf(false) }
var showNewFolderDialog by remember { mutableStateOf(false) }
val breadcrumbState = rememberLazyListState()
val scope = rememberCoroutineScope()
// Auto-scroll breadcrumbs to end when path changes
val segments = state.currentPath.trimEnd('/').split('/').filter { it.isNotEmpty() }
LaunchedEffect(state.currentPath) {
scope.launch { breadcrumbState.animateScrollToItem(maxOf(0, segments.size)) }
searchQuery = ""
searchActive = false
}
val filtered = if (searchQuery.isBlank()) state.entries
else state.entries.filter { it.name.contains(searchQuery, ignoreCase = true) }
val currentFolderName = state.currentPath.trimEnd('/').substringAfterLast('/').ifBlank { "Root" }
if (showNewFolderDialog) {
NewFolderDialog(
onConfirm = { name ->
vm.createFolder(name)
showNewFolderDialog = false
},
onDismiss = { showNewFolderDialog = false },
)
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
) {
Surface(
modifier = Modifier
.fillMaxWidth(0.95f)
.fillMaxHeight(0.85f),
shape = MaterialTheme.shapes.extraLarge,
tonalElevation = 6.dp,
) {
Column {
// Title bar
val view = LocalView.current
val density = LocalDensity.current
var topInset by remember { mutableStateOf(0.dp) }
var bottomInset by remember { mutableStateOf(56.dp) }
DisposableEffect(view) {
ViewCompat.setOnApplyWindowInsetsListener(view) { _, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
with(density) {
topInset = bars.top.toDp()
bottomInset = maxOf(bars.bottom.toDp(), 56.dp)
}
insets
}
ViewCompat.requestApplyInsets(view)
onDispose { ViewCompat.setOnApplyWindowInsetsListener(view, null) }
}
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) {
// ── Top bar ──────────────────────────────────────────────────
TopAppBar(
title = {
Column {
Text("Choose remote folder", style = MaterialTheme.typography.titleMedium)
Text(
state.currentPath,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
navigationIcon = {
IconButton(onClick = { if (!vm.navigateUp()) onDismiss() }) {
Icon(Icons.Default.ArrowBack, null)
Icon(Icons.AutoMirrored.Filled.ArrowBack, null)
}
},
title = {
if (searchActive) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
placeholder = { Text("Search in folder…") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = {}),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
),
)
} else {
Text("Choose Folder", style = MaterialTheme.typography.titleMedium)
}
},
actions = {
// Select current folder
TextButton(onClick = { onSelect(state.currentPath) }) {
Text("Select here")
IconButton(onClick = { searchActive = !searchActive; if (!searchActive) searchQuery = "" }) {
Icon(if (searchActive) Icons.Default.Close else Icons.Default.Search, "Search")
}
IconButton(onClick = { showNewFolderDialog = true }) {
Icon(Icons.Default.CreateNewFolder, "New Folder")
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface),
)
// ── Breadcrumbs ──────────────────────────────────────────────
Surface(tonalElevation = 1.dp) {
LazyRow(
state = breadcrumbState,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
item {
BreadcrumbChip(
label = "",
isLast = segments.isEmpty(),
onClick = { vm.navigateToBreadcrumb("/") },
)
}
itemsIndexed(segments) { idx, seg ->
Icon(Icons.Default.ChevronRight, null, Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
val segPath = "/" + segments.take(idx + 1).joinToString("/")
BreadcrumbChip(
label = seg,
isLast = idx == segments.lastIndex,
onClick = { vm.navigateToBreadcrumb(segPath) },
)
}
}
}
HorizontalDivider()
when {
state.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
state.error != null -> Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Default.ErrorOutline, null, Modifier.size(48.dp), tint = MaterialTheme.colorScheme.error)
Text(state.error!!, color = MaterialTheme.colorScheme.error)
FilledTonalButton(onClick = { vm.retry() }) { Text("Retry") }
// ── Content ──────────────────────────────────────────────────
Box(modifier = Modifier.weight(1f)) {
when {
state.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
state.error != null -> Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(Icons.Default.CloudOff, null, Modifier.size(56.dp), tint = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(12.dp))
Text(state.error!!, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(16.dp))
FilledTonalButton(onClick = vm::retry) {
Icon(Icons.Default.Refresh, null, Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("Retry")
}
}
filtered.isEmpty() && searchQuery.isBlank() -> Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(Icons.Default.FolderOpen, null, Modifier.size(56.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
Spacer(Modifier.height(12.dp))
Text("This folder is empty", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text("You can still select it", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f))
}
filtered.isEmpty() -> Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(Icons.Default.SearchOff, null, Modifier.size(56.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
Spacer(Modifier.height(12.dp))
Text("No results for \"$searchQuery\"", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
items(filtered, key = { it.path }) { entry ->
FolderItem(
file = entry,
onClick = {
if (entry.isDirectory) vm.navigateTo(entry.path)
else onSelect(entry.path.substringBeforeLast('/').ifBlank { "/" })
},
)
}
item { Spacer(Modifier.height(8.dp)) }
}
}
state.entries.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Default.FolderOff, null, Modifier.size(48.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
Text("Empty folder", color = MaterialTheme.colorScheme.onSurfaceVariant)
TextButton(onClick = { onSelect(state.currentPath) }) { Text("Select this folder anyway") }
}
}
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
items(state.entries, key = { it.path }) { entry ->
BrowserEntry(
file = entry,
onClick = {
if (entry.isDirectory) vm.navigateTo(entry.path)
else onSelect(entry.path.substringBeforeLast('/').ifBlank { "/" })
},
onSelectFolder = if (entry.isDirectory) ({ onSelect(entry.path) }) else null,
}
// ── Select button ────────────────────────────────────────────
Surface(tonalElevation = 4.dp, modifier = Modifier.fillMaxWidth()) {
Column {
Button(
onClick = { onSelect(state.currentPath) },
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 12.dp)
.height(52.dp),
shape = RoundedCornerShape(14.dp),
) {
Icon(Icons.Default.CheckCircle, null, Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text(
"Select \"$currentFolderName\"",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
HorizontalDivider(modifier = Modifier.padding(start = 56.dp))
}
Spacer(Modifier.height(bottomInset))
}
}
}
@@ -111,44 +255,119 @@ fun RemoteBrowserDialog(
}
@Composable
private fun BrowserEntry(
file: RemoteFile,
onClick: () -> Unit,
onSelectFolder: (() -> Unit)?,
) {
private fun BreadcrumbChip(label: String, isLast: Boolean, onClick: () -> Unit) {
if (isLast) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.primaryContainer,
) {
Text(
label,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onPrimaryContainer,
maxLines = 1,
)
}
} else {
TextButton(
onClick = onClick,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp),
) {
Text(
label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
)
}
}
}
@Composable
private fun FolderItem(file: RemoteFile, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Icon(
imageVector = if (file.isDirectory) Icons.Default.Folder else Icons.Default.InsertDriveFile,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = if (file.isDirectory) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(14.dp))
// Colored icon badge
Box(
modifier = Modifier
.size(46.dp)
.clip(RoundedCornerShape(12.dp))
.background(
if (file.isDirectory) Color(0xFF1B5E20).copy(alpha = 0.12f)
else Color(0xFF0D47A1).copy(alpha = 0.10f)
),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = if (file.isDirectory) Icons.Default.Folder else Icons.Default.InsertDriveFile,
contentDescription = null,
modifier = Modifier.size(26.dp),
tint = if (file.isDirectory) Color(0xFF2E7D32) else Color(0xFF1565C0),
)
}
Column(modifier = Modifier.weight(1f)) {
Text(file.name, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
file.name,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = if (file.isDirectory) FontWeight.Medium else FontWeight.Normal,
)
if (!file.isDirectory) {
Text(file.sizeBytes.formatBytes(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
file.sizeBytes.formatBytes(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (onSelectFolder != null) {
IconButton(onClick = onSelectFolder, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.CheckCircleOutline, "Select this folder", Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
}
} else {
Icon(Icons.Default.ChevronRight, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
if (file.isDirectory) {
Icon(Icons.Default.ChevronRight, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f))
}
}
HorizontalDivider(modifier = Modifier.padding(start = 76.dp), thickness = 0.5.dp)
}
@Composable
private fun NewFolderDialog(onConfirm: (String) -> Unit, onDismiss: () -> Unit) {
var name by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.CreateNewFolder, null) },
title = { Text("New Folder") },
text = {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Folder name") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { if (name.isNotBlank()) onConfirm(name.trim()) }),
)
},
confirmButton = {
TextButton(onClick = { if (name.isNotBlank()) onConfirm(name.trim()) }, enabled = name.isNotBlank()) {
Text("Create")
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
private fun Long.formatBytes(): String = when {
this < 1024 -> "${this}B"
this < 1_048_576 -> "${"%.1f".format(this / 1024.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"
this < 1024 -> "${this} B"
this < 1_048_576 -> "${"%.1f".format(this / 1024.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"
}
@@ -57,6 +57,27 @@ class RemoteBrowserViewModel @Inject constructor(
return true
}
fun navigateToBreadcrumb(path: String) {
val stack = _state.value.pathStack
val idx = stack.lastIndexOf(path)
val newStack = if (idx >= 0) stack.take(idx + 1) else listOf(path)
loadJob?.cancel()
_state.update { it.copy(currentPath = path, pathStack = newStack, isLoading = true, entries = emptyList(), error = null) }
loadJob = loadPath(_state.value.accountId, path)
}
fun createFolder(name: String) {
val s = _state.value
val newPath = if (s.currentPath.trimEnd('/') == "") "/$name" else "${s.currentPath.trimEnd('/')}/$name"
viewModelScope.launch {
val account = accountRepository.getAccount(s.accountId) ?: return@launch
val provider = runCatching { providerFactory.create(account) }.getOrElse { return@launch }
provider.createDirectory(newPath)
.onSuccess { retry() }
.onFailure { e -> _state.update { it.copy(error = "Could not create folder: ${e.message}") } }
}
}
fun retry() {
val s = _state.value
if (s.accountId == -1L) return
@@ -1,7 +1,12 @@
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -20,11 +25,12 @@ import androidx.core.content.FileProvider
import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.data.db.entities.SyncFileStateEntity
import kotlinx.coroutines.launch
import java.io.File
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun FilesScreen(
modifier: Modifier = Modifier,
@@ -33,9 +39,16 @@ fun FilesScreen(
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 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) }
BackHandler(enabled = isSelectionMode) { vm.clearSelection() }
LaunchedEffect(Unit) {
vm.fileAction.collect { action ->
@@ -45,15 +58,18 @@ fun FilesScreen(
val uri = FileProvider.getUriForFile(
context, "${context.packageName}.fileprovider", action.file
)
val mimeType = action.file.name.mimeType()
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)
setDataAndType(uri, mimeType)
// ClipData is required so FLAG_GRANT_READ_URI_PERMISSION
// propagates to whichever app the system chooser picks.
clipData = ClipData.newRawUri("", uri)
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)
}
)
context.startActivity(intent)
} catch (e: Exception) {
snackbarHostState.showSnackbar("Cannot open file: ${e.message}")
}
@@ -66,6 +82,7 @@ fun FilesScreen(
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(
@@ -77,6 +94,25 @@ fun FilesScreen(
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)
}
@@ -84,9 +120,62 @@ fun FilesScreen(
}
}
if (showDeleteSelectedDialog) {
AlertDialog(
onDismissRequest = { showDeleteSelectedDialog = false },
icon = { Icon(Icons.Default.Delete, contentDescription = null) },
title = { Text("Delete $selectedCount file${if (selectedCount != 1) "s" else ""}?") },
text = { Text("Selected files will be removed from this device.") },
confirmButton = {
TextButton(onClick = {
vm.deleteSelected()
showDeleteSelectedDialog = false
}) { Text("Delete", color = MaterialTheme.colorScheme.error) }
},
dismissButton = {
TextButton(onClick = { showDeleteSelectedDialog = false }) { Text("Cancel") }
},
)
}
Box(modifier = modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
if (pairs.size > 1) {
// Selection toolbar
if (isSelectionMode) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
tonalElevation = 3.dp,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = { vm.clearSelection() }) {
Icon(Icons.Default.Close, contentDescription = "Clear selection")
}
Text(
"$selectedCount selected",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
IconButton(onClick = { vm.shareSelected() }) {
Icon(Icons.Default.Share, contentDescription = "Share selected")
}
IconButton(onClick = { showDeleteSelectedDialog = true }) {
Icon(
Icons.Default.Delete, contentDescription = "Delete selected",
tint = MaterialTheme.colorScheme.error,
)
}
}
}
HorizontalDivider()
}
// Pair tabs
if (pairs.size > 1 && !isSelectionMode) {
ScrollableTabRow(
selectedTabIndex = pairs.indexOfFirst { it.id == selectedPair?.id }.coerceAtLeast(0),
edgePadding = 16.dp,
@@ -116,24 +205,26 @@ fun FilesScreen(
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,
)
if (!isSelectionMode) {
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,
)
}
}
}
@@ -146,7 +237,7 @@ fun FilesScreen(
if (idx < 0) "" else f.relativePath.substring(0, idx)
}
grouped.forEach { (dir, dirFiles) ->
if (dir.isNotEmpty()) {
if (dir.isNotEmpty() && !isSelectionMode) {
item(key = "dir_$dir") {
Row(
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
@@ -167,12 +258,16 @@ fun FilesScreen(
}
}
items(dirFiles, key = { "${it.syncPairId}_${it.relativePath}" }) { file ->
FileRow(file = file, isInSubDir = dir.isNotEmpty(), vm = vm)
FileRow(
file = file,
isInSubDir = dir.isNotEmpty() && !isSelectionMode,
isSelectionMode = isSelectionMode,
isSelected = vm.fileKey(file) in selectedKeys,
vm = vm,
)
HorizontalDivider(
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f),
modifier = Modifier.padding(
start = if (dir.isNotEmpty()) 38.dp else 16.dp
),
modifier = Modifier.padding(start = if (dir.isNotEmpty() && !isSelectionMode) 38.dp else 16.dp),
)
}
}
@@ -182,6 +277,32 @@ fun FilesScreen(
}
}
// Download progress
if (isDownloading) {
Box(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter),
) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
tonalElevation = 4.dp,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
Text("Downloading for preview…", style = MaterialTheme.typography.bodySmall)
}
}
}
}
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter),
@@ -211,8 +332,15 @@ private fun FilesEmptyState(icon: ImageVector, title: String, subtitle: String)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun FileRow(file: SyncFileStateEntity, isInSubDir: Boolean, vm: FilesViewModel) {
private fun FileRow(
file: SyncFileStateEntity,
isInSubDir: Boolean,
isSelectionMode: Boolean,
isSelected: Boolean,
vm: FilesViewModel,
) {
val name = file.relativePath.substringAfterLast('/')
val timeFmt = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
val syncedAt = file.lastSyncedAt.atZone(ZoneId.systemDefault()).let { timeFmt.format(it) }
@@ -224,10 +352,7 @@ private fun FileRow(file: SyncFileStateEntity, isInSubDir: Boolean, vm: FilesVie
if (showRenameDialog) {
RenameDialog(
currentName = name,
onConfirm = { newName ->
vm.renameFile(file, newName)
showRenameDialog = false
},
onConfirm = { newName -> vm.renameFile(file, newName); showRenameDialog = false },
onDismiss = { showRenameDialog = false },
)
}
@@ -249,89 +374,104 @@ private fun FileRow(file: SyncFileStateEntity, isInSubDir: Boolean, vm: FilesVie
)
}
Row(
Surface(
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
else MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth()
.padding(
.combinedClickable(
onClick = {
if (isSelectionMode) vm.toggleSelection(file) else menuExpanded = true
},
onLongClick = { vm.toggleSelection(file) },
),
) {
Row(
modifier = Modifier.padding(
start = if (isInSubDir) 22.dp else 0.dp,
top = 10.dp,
bottom = 10.dp,
end = 0.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.size(32.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(contentAlignment = Alignment.Center) {
Icon(
fileIcon(name), contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
if (isSelectionMode) {
Checkbox(
checked = isSelected,
onCheckedChange = { vm.toggleSelection(file) },
modifier = Modifier.padding(horizontal = 4.dp),
)
}
}
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 = {
} else {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.size(32.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
Icons.Default.Delete, contentDescription = null,
tint = MaterialTheme.colorScheme.error,
fileIcon(name), contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
)
},
onClick = { menuExpanded = false; showDeleteDialog = true },
}
}
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 },
)
}
}
}
}
}
}
@Composable
private fun RenameDialog(
currentName: String,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
) {
private fun RenameDialog(currentName: String, onConfirm: (String) -> Unit, onDismiss: () -> Unit) {
var newName by remember { mutableStateOf(currentName) }
AlertDialog(
onDismissRequest = onDismiss,
@@ -350,15 +490,12 @@ private fun RenameDialog(
TextButton(
onClick = {
val trimmed = newName.trim()
if (trimmed.isNotBlank() && trimmed != currentName) onConfirm(trimmed)
else onDismiss()
if (trimmed.isNotBlank() && trimmed != currentName) onConfirm(trimmed) else onDismiss()
},
enabled = newName.isNotBlank(),
) { Text("Rename") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@@ -369,22 +506,16 @@ private fun String.mimeType(): String {
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(".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(".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(".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(".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
name.endsWith(".txt", ignoreCase = true) || name.endsWith(".md", ignoreCase = true) -> Icons.Default.TextSnippet
else -> Icons.Default.InsertDriveFile
}
@@ -1,17 +1,21 @@
package com.syncflow.ui.files
import android.content.Context
import android.media.MediaScannerConnection
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 kotlinx.coroutines.flow.update
import timber.log.Timber
import java.io.File
import javax.inject.Inject
@@ -19,6 +23,7 @@ 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()
}
@@ -27,6 +32,8 @@ sealed class FileAction {
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() {
@@ -50,22 +57,42 @@ class FilesViewModel @Inject constructor(
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 openFile(file: SyncFileStateEntity) {
val resolved = resolveFile(file) ?: return
viewModelScope.launch { _fileAction.emit(FileAction.Open(resolved)) }
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) ?: return
viewModelScope.launch { _fileAction.emit(FileAction.Share(resolved)) }
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)
val resolved = resolveFile(file, emitErrorIfMissing = false)
resolved?.delete()
fileStateDao.delete(file.syncPairId, file.relativePath)
} catch (e: Exception) {
@@ -85,7 +112,6 @@ class FilesViewModel @Inject constructor(
_fileAction.emit(FileAction.Error("Rename failed"))
return@launch
}
// Update DB: delete old state; the next sync will re-detect as a new upload
fileStateDao.delete(file.syncPairId, file.relativePath)
} catch (e: Exception) {
Timber.e(e, "Rename failed: ${file.relativePath}")
@@ -94,12 +120,110 @@ class FilesViewModel @Inject constructor(
}
}
private fun resolveFile(file: SyncFileStateEntity): File? {
fun isSelected(file: SyncFileStateEntity): Boolean = fileKey(file) in _selectedKeys.value
fun toggleSelection(file: SyncFileStateEntity) {
val key = fileKey(file)
_selectedKeys.update { if (key in it) it - key else it + key }
}
fun clearSelection() { _selectedKeys.value = emptySet() }
fun deleteSelected() {
viewModelScope.launch {
val toDelete = files.value.filter { isSelected(it) }
toDelete.forEach { file ->
try {
resolveFile(file, emitErrorIfMissing = false)?.delete()
fileStateDao.delete(file.syncPairId, file.relativePath)
} catch (e: Exception) {
Timber.e(e, "Bulk delete failed: ${file.relativePath}")
}
}
clearSelection()
}
}
fun shareSelected() {
viewModelScope.launch {
val toShare = files.value.filter { isSelected(it) }
val resolved = toShare.mapNotNull { resolveFile(it, emitErrorIfMissing = false) }
if (resolved.isEmpty()) {
_fileAction.emit(FileAction.Error("No local files available to share"))
return@launch
}
_fileAction.emit(FileAction.ShareMultiple(resolved))
}
}
fun fileKey(file: SyncFileStateEntity) = "${file.syncPairId}:${file.relativePath}"
// ── Download-then-open/share ──────────────────────────────────────────────
private fun downloadAndOpen(file: SyncFileStateEntity) {
viewModelScope.launch {
downloadToCache(file)?.let { cached ->
_fileAction.emit(FileAction.Open(cached))
}
}
}
private fun downloadAndShare(file: SyncFileStateEntity) {
viewModelScope.launch {
downloadToCache(file)?.let { cached ->
_fileAction.emit(FileAction.Share(cached))
}
}
}
private suspend fun downloadToCache(file: SyncFileStateEntity): File? {
val pair = selectedPair.value ?: run {
_fileAction.emit(FileAction.Error("No sync pair selected"))
return null
}
val account = accountRepository.getAccount(pair.accountId) ?: run {
_fileAction.emit(FileAction.Error("Cloud account not found"))
return null
}
val provider = providerFactory.create(account)
val fileName = file.relativePath.substringAfterLast('/')
val cacheFile = File(context.cacheDir, "syncflow_open/$fileName")
cacheFile.parentFile?.mkdirs()
_isDownloading.value = true
return try {
cacheFile.outputStream().use { out ->
provider.downloadFile("${pair.remotePath}/${file.relativePath}", out) { }.getOrThrow()
}
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()) {
viewModelScope.launch { _fileAction.emit(FileAction.Error("File not found on device")) }
if (emitErrorIfMissing) {
viewModelScope.launch { _fileAction.emit(FileAction.Error("File not found on device")) }
}
return null
}
return f
@@ -247,12 +247,12 @@ private fun EmptyState(modifier: Modifier = Modifier, onAdd: () -> Unit) {
private val SyncStatus.accentColor: Color
@Composable get() = when (this) {
SyncStatus.SUCCESS -> MaterialTheme.colorScheme.primary
SyncStatus.SYNCING -> MaterialTheme.colorScheme.secondary
SyncStatus.FAILED -> MaterialTheme.colorScheme.error
SyncStatus.CONFLICT,
SyncStatus.PARTIAL -> MaterialTheme.colorScheme.tertiary
SyncStatus.IDLE -> MaterialTheme.colorScheme.outline
SyncStatus.SUCCESS -> Color(0xFF2E7D32) // green — done, healthy
SyncStatus.SYNCING -> Color(0xFF1565C0) // blue — in progress
SyncStatus.FAILED -> Color(0xFFC62828) // red — error
SyncStatus.PARTIAL -> Color(0xFFE65100) // orange — some files failed
SyncStatus.CONFLICT -> Color(0xFFF9A825) // amber — needs resolution
SyncStatus.IDLE -> MaterialTheme.colorScheme.outline
}
private fun String.toDisplayPath(): String {
@@ -2,27 +2,28 @@ package com.syncflow.ui.theme
import androidx.compose.ui.graphics.Color
// Primary — indigo
val Indigo600 = Color(0xFF4F46E5)
val Indigo900 = Color(0xFF312E81)
val Indigo100 = Color(0xFFE0E7FF)
val Indigo50 = Color(0xFFEEF2FF)
// Primary — deep red (Passbolt-inspired)
val Red900 = Color(0xFF7F0000)
val Red700 = Color(0xFFB71C1C)
val Red500 = Color(0xFFEF5350)
val Red100 = Color(0xFFFFCDD2)
val Red50 = Color(0xFFFFEBEE)
// Secondary — teal
val Teal600 = Color(0xFF0D9488)
val Teal100 = Color(0xFFCCFBF1)
// Secondary — deep orange
val Orange700 = Color(0xFFE64A19)
val Orange100 = Color(0xFFFBE9E7)
// Tertiary — amber
val Amber500 = Color(0xFFF59E0B)
val Amber100 = Color(0xFFFEF3C7)
val Amber500 = Color(0xFFFFB300)
val Amber100 = Color(0xFFFFF8E1)
// Neutrals
val Slate50 = Color(0xFFF8FAFC)
val Slate100 = Color(0xFFF1F5F9)
val Slate200 = Color(0xFFE2E8F0)
val Slate600 = Color(0xFF475569)
val Slate900 = Color(0xFF0F172A)
val Gray50 = Color(0xFFF8F9FA)
val Gray100 = Color(0xFFF3F4F6)
val Gray200 = Color(0xFFE5E7EB)
val Gray600 = Color(0xFF6B7280)
val Gray900 = Color(0xFF111827)
// Semantic
val GreenSuccess = Color(0xFF16A34A)
val RedError = Color(0xFFDC2626)
val GreenSuccess = Color(0xFF2E7D32)
val RedError = Color(0xFFEF5350)
@@ -13,41 +13,43 @@ import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat
private val LightColors = lightColorScheme(
primary = Indigo600,
primary = Red700,
onPrimary = Color.White,
primaryContainer = Indigo100,
onPrimaryContainer = Indigo900,
secondary = Teal600,
primaryContainer = Red50,
onPrimaryContainer = Red900,
secondary = Orange700,
onSecondary = Color.White,
secondaryContainer = Teal100,
secondaryContainer = Orange100,
tertiary = Amber500,
tertiaryContainer = Amber100,
background = Slate50,
background = Gray50,
surface = Color.White,
surfaceVariant = Slate100,
onSurfaceVariant = Slate600,
surfaceVariant = Gray100,
onSurface = Gray900,
onSurfaceVariant = Gray600,
error = RedError,
errorContainer = Color(0xFFFEE2E2),
outline = Slate200,
errorContainer = Red50,
outline = Gray200,
)
private val DarkColors = darkColorScheme(
primary = Color(0xFF818CF8),
onPrimary = Indigo900,
primaryContainer = Color(0xFF3730A3),
onPrimaryContainer = Indigo100,
secondary = Color(0xFF2DD4BF),
onSecondary = Color(0xFF003731),
secondaryContainer = Color(0xFF00504A),
primary = Red500,
onPrimary = Color.White,
primaryContainer = Red900,
onPrimaryContainer = Red100,
secondary = Color(0xFFFF7043),
onSecondary = Color.White,
secondaryContainer = Color(0xFF4E1500),
tertiary = Amber500,
tertiaryContainer = Color(0xFF92400E),
background = Color(0xFF0F0F1A),
surface = Color(0xFF1A1A2E),
surfaceVariant = Color(0xFF252538),
onSurfaceVariant = Color(0xFF94A3B8),
error = Color(0xFFF87171),
errorContainer = Color(0xFF7F1D1D),
outline = Color(0xFF334155),
tertiaryContainer = Color(0xFF3E2700),
background = Color(0xFF0F0F0F),
surface = Color(0xFF1C1C1C),
surfaceVariant = Color(0xFF2A2A2A),
onSurface = Color(0xFFEAEAEA),
onSurfaceVariant = Color(0xFF9E9E9E),
error = Color(0xFFFF5252),
errorContainer = Color(0xFF5C0000),
outline = Color(0xFF3D3D3D),
)
private val AppTypography = Typography(
@@ -13,14 +13,20 @@ import android.os.Looper
import android.provider.DocumentsContract
import androidx.core.app.NotificationCompat
import androidx.work.ExistingWorkPolicy
import androidx.work.WorkInfo
import androidx.work.WorkManager
import kotlinx.coroutines.flow.first
import com.syncflow.MainActivity
import com.syncflow.R
import com.syncflow.data.db.SyncFileStateDao
import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.toDomain
import com.syncflow.domain.model.ScheduleType
import com.syncflow.domain.sync.LocalAccessor
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import java.io.File
import javax.inject.Inject
@@ -33,10 +39,18 @@ class FileWatchService : Service() {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
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 debounceJobs = mutableMapOf<Long, Job>()
// Persistent monitors that watch WorkManager for ANY sync (manual, catchup, onchange)
// so the cooldown is set regardless of who triggered the sync.
private val syncMonitorJobs = mutableMapOf<Long, Job>()
// After a sync completes, suppress FileObserver events for this long.
private val syncCooldownUntil = mutableMapOf<Long, Long>()
companion object {
const val CHANNEL_WATCH = "sync_watching"
@@ -75,7 +89,7 @@ class FileWatchService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
private suspend fun refresh() {
private suspend fun refresh() = refreshMutex.withLock {
clearWatchers()
val pairs = syncPairDao.getEnabled().filter { it.scheduleType == ScheduleType.ON_CHANGE }
@@ -106,7 +120,7 @@ class FileWatchService : Service() {
}
}
val count = fileObservers.size + contentObservers.size
val count = fileObservers.keys.size + contentObservers.size
updateNotification(count)
if (count == 0) {
@@ -138,73 +152,189 @@ class FileWatchService : Service() {
Timber.w("FileWatchService: path does not exist for pair $pairId: $path")
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)")
startSyncMonitor(pairId)
scope.launch { catchupScan(pairId, dir, wifiOnly, chargingOnly) }
}
// Watches WorkManager for ANY sync tagged sync_$pairId (manual, catchup, onchange).
// Sets cooldown while running and for 60s after, so FileObserver events from our
// own file writes never trigger a re-sync regardless of what started the sync.
private fun startSyncMonitor(pairId: Long) {
syncMonitorJobs[pairId]?.cancel()
syncMonitorJobs[pairId] = scope.launch {
var wasSyncing = false
WorkManager.getInstance(applicationContext)
.getWorkInfosByTagFlow("sync_$pairId")
.collect { infos ->
val isSyncing = infos.any {
it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED
}
if (isSyncing) {
Timber.d("FileWatchService: sync active for pair $pairId — cooldown extended")
syncCooldownUntil[pairId] = System.currentTimeMillis() + 120_000
wasSyncing = true
} else if (wasSyncing) {
Timber.d("FileWatchService: sync finished for pair $pairId — 60s settle cooldown")
syncCooldownUntil[pairId] = System.currentTimeMillis() + 60_000
wasSyncing = false
}
}
}
}
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, 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 {
@Suppress("DEPRECATION")
object : FileObserver(path, mask) {
override fun onEvent(event: Int, p: String?) = onChangeDetected(pairId, wifiOnly, chargingOnly)
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[pairId] = observer
Timber.d("FileWatchService: watching filesystem path for pair $pairId: $path")
// Check if anything changed while the service was not running
scope.launch { catchupScan(pairId, dir, wifiOnly, chargingOnly) }
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 pairEntity = syncPairDao.getById(pairId) ?: return
val pair = pairEntity.toDomain()
// Use the same accessor + filters as SyncEngine so hidden/excluded/size-filtered files
// don't appear as "new" in the catchup scan and trigger a perpetual sync loop.
val accessor = if (pair.localPath.startsWith("content://"))
LocalAccessor.Saf(Uri.parse(pair.localPath), contentResolver)
else
LocalAccessor.JavaFile(dir)
val current = accessor.walkFiles(pair)
val hasNew = current.any { (rel, _) -> rel !in known }
val hasModified = current.any { (rel, mtime) ->
val hasModified = current.any { (rel, info) ->
val s = known[rel]; s != null && s.localModifiedAt != null &&
s.localModifiedAt.toEpochMilli() != mtime
s.localModifiedAt.epochSecond != info.lastModifiedMs / 1000
}
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,
SyncWorker.buildOneTimeRequest(pairId, wifiOnly, chargingOnly),
)
.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) {
// 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] = scope.launch {
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)
if (pair == null || !pair.isEnabled) return@launch
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)
.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() {
fileObservers.values.forEach { it.stopWatching() }
fileObservers.values.flatten().forEach { it.stopWatching() }
fileObservers.clear()
contentObservers.values.forEach { contentResolver.unregisterContentObserver(it) }
contentObservers.clear()
debounceJobs.values.forEach { it.cancel() }
debounceJobs.clear()
syncMonitorJobs.values.forEach { it.cancel() }
syncMonitorJobs.clear()
syncCooldownUntil.clear()
}
private fun ensureChannel() {
@@ -219,7 +349,7 @@ class FileWatchService : Service() {
}
}
private fun buildNotification(count: Int): Notification {
private fun buildNotification(count: Int, overrideText: String? = null): Notification {
val tapIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP },
@@ -228,7 +358,7 @@ class FileWatchService : Service() {
return NotificationCompat.Builder(this, CHANNEL_WATCH)
.setContentTitle("SyncFlow")
.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…"
)
.setSmallIcon(R.drawable.ic_sync)
@@ -242,4 +372,10 @@ class FileWatchService : Service() {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
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 account = accountRepository.getAccount(pair.accountId) ?: return Result.failure()
val silent = inputData.getBoolean(KEY_SILENT, false)
ensureChannels()
setForeground(buildForegroundInfo(pair.name, "Syncing…"))
@@ -47,7 +49,15 @@ class SyncWorker @AssistedInject constructor(
val provider = providerFactory.create(account)
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(
id = pairId.toInt() + RESULT_ID_OFFSET,
channelId = CHANNEL_ALERTS,
@@ -55,28 +65,28 @@ class SyncWorker @AssistedInject constructor(
text = result.error.message ?: "Unknown error",
priority = NotificationCompat.PRIORITY_DEFAULT,
)
} else if (pair.notifyOnComplete && result.error == null) {
val lines = buildList {
} else if (!silent && pair.notifyOnComplete && result.error == null) {
val fullLines = buildList {
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.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 ""}")
}
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(
id = pairId.toInt() + RESULT_ID_OFFSET,
channelId = CHANNEL_COMPLETE,
title = "${pair.name}Changes synced",
text = if (lines.isEmpty()) summary else lines.first(),
title = "${pair.name}Synced",
text = if (fullLines.isEmpty()) summary else fullLines.first(),
bigText = summary,
priority = NotificationCompat.PRIORITY_LOW,
)
}
Result.success()
Result.success(workDataOf(KEY_RESULT_SUMMARY to resultSummary))
} catch (e: Exception) {
Timber.e(e, "SyncWorker failed for pair $pairId")
if (pair.notifyOnError) {
if (!silent && pair.notifyOnError) {
notify(
id = pairId.toInt() + RESULT_ID_OFFSET,
channelId = CHANNEL_ALERTS,
@@ -146,19 +156,21 @@ class SyncWorker @AssistedInject constructor(
companion object {
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 RESULT_ID_OFFSET = 2000
private const val CHANNEL_PROGRESS = "sync_progress"
private const val CHANNEL_COMPLETE = "sync_complete"
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()
.setRequiredNetworkType(if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
.setRequiresCharging(chargingOnly)
.build()
return OneTimeWorkRequestBuilder<SyncWorker>()
.setInputData(workDataOf(KEY_PAIR_ID to pairId))
.setInputData(workDataOf(KEY_PAIR_ID to pairId, KEY_SILENT to silent))
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.addTag("sync_$pairId")
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:type="radial"
android:gradientRadius="80%"
android:centerX="0.35"
android:centerY="0.3"
android:startColor="#1C1124"
android:centerColor="#0E0A18"
android:endColor="#060408"/>
</shape>
@@ -1,95 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Subtle inner glow -->
<path
android:pathData="M54,54m-30,0a30,30 0 1,0 60,0a30,30 0 1,0 -60,0"
android:fillColor="#0AFFFFFF"/>
<!-- ═══ Arrow 1: curving RIGHT (top) — coral gradient ═══ -->
<!-- Curved shaft via quadratic bezier: left→ dips up → right -->
<path
android:pathData="M28,40 Q54,22 80,36"
android:fillColor="#00000000"
android:strokeWidth="7"
android:strokeLineCap="round"
android:strokeLineJoin="round">
<aapt:attr name="android:strokeColor">
<gradient android:type="linear"
android:startX="28" android:startY="36"
android:endX="80" android:endY="36"
android:startColor="#FF6B6B"
android:endColor="#FFB347"/>
</aapt:attr>
</path>
<!-- Arrowhead at (80,36) pointing right/slightly-down -->
<path android:pathData="M76,28 L72,44 L88,38 Z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear"
android:startX="72" android:startY="36"
android:endX="88" android:endY="36"
android:startColor="#FFB347"
android:endColor="#FF8C42"/>
</aapt:attr>
</path>
<!-- ═══ Arrow 2: curving LEFT (middle) — silver/white ═══ -->
<!-- Curved shaft: right→ dips down → left -->
<path
android:pathData="M80,56 Q54,74 28,60"
android:fillColor="#00000000"
android:strokeWidth="7"
android:strokeLineCap="round"
android:strokeLineJoin="round">
<aapt:attr name="android:strokeColor">
<gradient android:type="linear"
android:startX="80" android:startY="60"
android:endX="28" android:endY="60"
android:startColor="#D0D8EE"
android:endColor="#8892AA"/>
</aapt:attr>
</path>
<!-- Arrowhead at (28,60) pointing left/slightly-down -->
<path android:pathData="M32,52 L36,68 L20,62 Z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear"
android:startX="36" android:startY="60"
android:endX="20" android:endY="60"
android:startColor="#D0D8EE"
android:endColor="#8892AA"/>
</aapt:attr>
</path>
<!-- ═══ Arrow 3: curving RIGHT (bottom) — teal gradient ═══ -->
<!-- Curved shaft: left→ dips up → right -->
<path
android:pathData="M28,74 Q54,58 80,72"
android:fillColor="#00000000"
android:strokeWidth="7"
android:strokeLineCap="round"
android:strokeLineJoin="round">
<aapt:attr name="android:strokeColor">
<gradient android:type="linear"
android:startX="28" android:startY="72"
android:endX="80" android:endY="72"
android:startColor="#4DD0E1"
android:endColor="#00BCD4"/>
</aapt:attr>
</path>
<!-- Arrowhead at (80,72) pointing right/slightly-up -->
<path android:pathData="M76,64 L72,80 L88,74 Z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear"
android:startX="72" android:startY="72"
android:endX="88" android:endY="72"
android:startColor="#00BCD4"
android:endColor="#00ACC1"/>
</aapt:attr>
</path>
</vector>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#2196F3</color>
<color name="ic_launcher_background">#050E05</color>
</resources>
+1
View File
@@ -3,4 +3,5 @@
<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>
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.23
VERSION_CODE=24
VERSION_NAME=1.0.52
VERSION_CODE=53