Compare commits

...

13 Commits

Author SHA1 Message Date
amir 683169e8b7 v1.0.54: ARCHIVE delete behavior + storage root upload-only allowance
New delete behavior option: "Archive deleted" — when a file is deleted
from the phone in a TWO_WAY pair, it moves to _Deleted/<path> on remote
instead of being permanently deleted from the backup.

Also allows storage root (/storage/emulated/0) for UPLOAD_ONLY pairs so
whole-phone backup syncs work; only blocks root when sync direction would
write files locally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 01:06:28 +00:00
amir 77d56ee6be v1.0.53: block storage-root sync paths
Android 11+ denies writes to /storage/emulated/0 directly. SyncEngine
now catches this early and returns FAILED with an actionable message
instead of silently logging PARTIAL. LocalBrowserDialog disables the
Select button at the storage root with an inline warning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 00:56:45 +00:00
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
40 changed files with 905 additions and 317 deletions
@@ -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
@@ -84,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()
}
}
@@ -58,6 +58,7 @@ enum class ConflictStrategy(val label: String) {
enum class DeleteBehavior(val label: String, val description: String) {
MIRROR("Mirror deletions", "Delete on target when deleted on source"),
KEEP("Keep deleted files", "Never delete — only add/update"),
ARCHIVE("Archive deleted", "Move files deleted from phone to _Deleted/ folder on remote"),
}
enum class ScheduleType(val label: String) {
@@ -34,6 +34,17 @@ class SyncEngine @Inject constructor(
@ApplicationContext private val context: Context,
) {
suspend fun sync(pair: SyncPair, provider: CloudProvider): SyncResult {
if (!pair.localPath.startsWith("content://") &&
pair.syncDirection != SyncDirection.UPLOAD_ONLY) {
val canonical = runCatching { File(pair.localPath).canonicalPath }.getOrElse { pair.localPath }
if (canonical == "/storage/emulated/0") {
val msg = "Local folder is the storage root — Android blocks writes here. Use Upload Only direction, or select a subfolder."
syncPairDao.updateSyncResult(pair.id, Instant.now(), SyncStatus.FAILED, 0)
logEvent(pair.id, SyncEventType.SYNC_FAILED, null, msg, 0)
return SyncResult(failedFiles = 1, error = Exception(msg))
}
}
syncPairDao.updateStatus(pair.id, SyncStatus.SYNCING)
logEvent(pair.id, SyncEventType.SYNC_STARTED, null, null, 0)
@@ -62,13 +73,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 +118,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 +130,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,18 +155,32 @@ 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")
fileStateDao.delete(pair.id, rel)
logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "remote", 0)
if (pair.deleteBehavior == DeleteBehavior.ARCHIVE) {
val archivePath = "${pair.remotePath}/_Deleted/$rel"
runCatching {
ensureRemoteDirs(provider, "${pair.remotePath}/_Deleted", rel)
provider.moveFile("${pair.remotePath}/$rel", archivePath).getOrThrow()
}.onFailure { e -> Timber.e(e, "SyncEngine: ARCHIVE failed for $rel") }
fileStateDao.delete(pair.id, rel)
logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "archived", 0)
} else {
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)
}
SyncDecision.CONFLICT -> {
@@ -203,10 +246,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 +282,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 +309,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,362 @@
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 ────────────────────────────────────────────
val isStorageRoot = currentPath.absolutePath == STORAGE_ROOT.absolutePath
Surface(tonalElevation = 4.dp, modifier = Modifier.fillMaxWidth()) {
Column {
if (isStorageRoot) {
Text(
"Android blocks writes to the storage root — please open a subfolder first",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
)
}
Button(
onClick = { onSelect(currentPath.absolutePath) },
enabled = !isStorageRoot,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = 4.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
@@ -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 {
@@ -20,9 +20,13 @@ 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
@@ -35,11 +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()
// 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"
@@ -78,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 }
@@ -142,11 +153,41 @@ class FileWatchService : Service() {
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
@@ -185,51 +226,85 @@ class FileWatchService : Service() {
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")
// 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)
// Update notification while sync is in progress
updateNotificationDynamic("Syncing: ${pair.name}")
// Wait for completion and show result in the persistent notification
scope.launch {
try {
val info = WorkManager.getInstance(applicationContext)
.getWorkInfoByIdFlow(req.id)
.first { it?.state?.isFinished == true }
// 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 ""}"
@@ -239,8 +314,11 @@ class FileWatchService : Service() {
updateNotificationDynamic("$watching")
}
delay(12_000)
updateNotificationDynamic(null) // revert to default watching text
updateNotificationDynamic(null)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
syncCooldownUntil[pairId] = System.currentTimeMillis() + 60_000
updateNotificationDynamic(null)
}
}
@@ -254,6 +332,9 @@ class FileWatchService : Service() {
contentObservers.clear()
debounceJobs.values.forEach { it.cancel() }
debounceJobs.clear()
syncMonitorJobs.values.forEach { it.cancel() }
syncMonitorJobs.clear()
syncCooldownUntil.clear()
}
private fun ensureChannel() {
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,20 +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">
<!-- Deep purple-black base, matching the knot reference icon's dark background -->
<path android:pathData="M0,0 H108 V108 H0 Z">
<aapt:attr name="android:fillColor">
<gradient android:type="radial"
android:gradientRadius="76"
android:centerX="54" android:centerY="44"
android:startColor="#1E1628"
android:endColor="#080610"/>
</aapt:attr>
</path>
</vector>
@@ -1,99 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SyncFlow icon foreground (108x108dp, safe zone 72dp centred at 54,54).
Design: two thick tube-style sync arcs that form a circular refresh symbol,
inspired by the braided knot icon's color palette:
Arc 1 (left side, CW): coral #E8665A -> orange #E8A040
Arc 2 (right side, CW): steel blue #4A7FD4 -> purple #7B5EA7
Each arc has a thin inner highlight to give a 3D glossy tube look.
Geometry: circle centred at (54,54), radius 27.
Arc 1: 100 deg to 260 deg CW (160 deg, through west/left)
start (49.31, 80.59) end (49.31, 27.41)
Arc 2: 280 deg to 80 deg CW (160 deg, through east/right)
start (58.69, 27.41) end (58.69, 80.59)
20-degree gaps at top (~260-280) and bottom (~80-100).
Highlights at r=23 (inner edge):
Arc 1 highlight: (49.99, 76.64) to (49.99, 31.36)
Arc 2 highlight: (58.01, 31.36) to (58.01, 76.64)
-->
<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">
<!-- Arc 1 shadow (offset slightly down-right for depth) -->
<path
android:pathData="M 50.31,82.59 A 27,27 0 0,1 50.31,29.41"
android:fillColor="#00000000"
android:strokeWidth="9"
android:strokeLineCap="round"
android:strokeColor="#44000000"/>
<!-- Arc 2 shadow -->
<path
android:pathData="M 59.69,29.41 A 27,27 0 0,1 59.69,82.59"
android:fillColor="#00000000"
android:strokeWidth="9"
android:strokeLineCap="round"
android:strokeColor="#44000000"/>
<!-- Arc 1: left side, coral to orange -->
<path
android:pathData="M 49.31,80.59 A 27,27 0 0,1 49.31,27.41"
android:fillColor="#00000000"
android:strokeWidth="9"
android:strokeLineCap="round">
<aapt:attr name="android:strokeColor">
<gradient android:type="linear"
android:startX="49.31" android:startY="80.59"
android:endX="49.31" android:endY="27.41"
android:startColor="#E8665A"
android:endColor="#E8A040"/>
</aapt:attr>
</path>
<!-- Arc 2: right side, blue to purple -->
<path
android:pathData="M 58.69,27.41 A 27,27 0 0,1 58.69,80.59"
android:fillColor="#00000000"
android:strokeWidth="9"
android:strokeLineCap="round">
<aapt:attr name="android:strokeColor">
<gradient android:type="linear"
android:startX="58.69" android:startY="27.41"
android:endX="58.69" android:endY="80.59"
android:startColor="#4A7FD4"
android:endColor="#7B5EA7"/>
</aapt:attr>
</path>
<!-- Arc 1 inner highlight (glossy tube sheen) -->
<path
android:pathData="M 49.99,76.64 A 23,23 0 0,1 49.99,31.36"
android:fillColor="#00000000"
android:strokeWidth="2.5"
android:strokeLineCap="round"
android:strokeColor="#55FFC8B0"/>
<!-- Arc 2 inner highlight -->
<path
android:pathData="M 58.01,31.36 A 23,23 0 0,1 58.01,76.64"
android:fillColor="#00000000"
android:strokeWidth="2.5"
android:strokeLineCap="round"
android:strokeColor="#55B0C8FF"/>
<!-- Arrowhead at end of Arc 1 (260 deg, top-left area, pointing right-up) -->
<path android:pathData="M 42.13,32.74 L 49.31,27.41 L 40.73,24.86 Z"
android:fillColor="#E8A040"/>
<!-- Arrowhead at end of Arc 2 (80 deg, bottom-right area, pointing left-down) -->
<path android:pathData="M 67.27,83.14 L 58.69,80.59 L 65.87,75.26 Z"
android:fillColor="#7B5EA7"/>
</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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.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: 24 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: 20 KiB

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>
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.27
VERSION_CODE=28
VERSION_NAME=1.0.54
VERSION_CODE=55