Compare commits

...

6 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 03:08:44 +00:00
amir 742f634084 v1.0.26: fix multi-selection reactivity, redesign icon, security review
Fix multi-selection: selectedKeys exposed as StateFlow, collected in
FilesScreen so checkboxes and highlights update correctly on every tap.
fileKey() made public so UI can check membership without ViewModel calls.

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 02:45:43 +00:00
8 changed files with 201 additions and 72 deletions
@@ -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)
@@ -126,7 +141,9 @@ 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)
@@ -203,10 +220,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 +256,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
@@ -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)
@@ -1,5 +1,6 @@
package com.syncflow.ui.files
import android.content.ClipData
import android.content.Intent
import android.net.Uri
import android.webkit.MimeTypeMap
@@ -39,8 +40,9 @@ fun FilesScreen(
val selectedPair by vm.selectedPair.collectAsState()
val files by vm.files.collectAsState()
val isDownloading by vm.isDownloading.collectAsState()
val isSelectionMode by vm.isSelectionMode.collectAsState()
val selectedCount by vm.selectedCount.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()
@@ -56,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}")
}
@@ -77,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(
@@ -252,12 +258,11 @@ fun FilesScreen(
}
}
items(dirFiles, key = { "${it.syncPairId}_${it.relativePath}" }) { file ->
val selected = vm.isSelected(file)
FileRow(
file = file,
isInSubDir = dir.isNotEmpty() && !isSelectionMode,
isSelectionMode = isSelectionMode,
isSelected = selected,
isSelected = vm.fileKey(file) in selectedKeys,
vm = vm,
)
HorizontalDivider(
@@ -1,6 +1,7 @@
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
@@ -60,6 +61,7 @@ class FilesViewModel @Inject constructor(
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 }
@@ -70,6 +72,8 @@ class FilesViewModel @Inject constructor(
fun openFile(file: SyncFileStateEntity) {
val resolved = resolveFile(file, emitErrorIfMissing = false)
if (resolved != null) {
// Ensure MediaStore knows about this file so gallery apps can open it
MediaScannerConnection.scanFile(context, arrayOf(resolved.absolutePath), null, null)
viewModelScope.launch { _fileAction.emit(FileAction.Open(resolved)) }
} else {
downloadAndOpen(file)
@@ -152,7 +156,7 @@ class FilesViewModel @Inject constructor(
}
}
private fun fileKey(file: SyncFileStateEntity) = "${file.syncPairId}:${file.relativePath}"
fun fileKey(file: SyncFileStateEntity) = "${file.syncPairId}:${file.relativePath}"
// ── Download-then-open/share ──────────────────────────────────────────────
@@ -191,6 +195,7 @@ class FilesViewModel @Inject constructor(
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}")
@@ -23,6 +23,8 @@ import com.syncflow.data.db.SyncPairDao
import com.syncflow.domain.model.ScheduleType
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 +37,16 @@ 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>()
// After a watcher-triggered sync completes, suppress FileObserver events for this long
// to stop the feedback loop: sync writes files → FileObserver fires → another sync → repeat.
private val syncCooldownUntil = mutableMapOf<Long, Long>()
companion object {
const val CHANNEL_WATCH = "sync_watching"
@@ -78,7 +85,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,6 +149,9 @@ 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)")
scope.launch { catchupScan(pairId, dir, wifiOnly, chargingOnly) }
@@ -200,36 +210,66 @@ class FileWatchService : Service() {
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 +279,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 +297,7 @@ class FileWatchService : Service() {
contentObservers.clear()
debounceJobs.values.forEach { it.cancel() }
debounceJobs.clear()
syncCooldownUntil.clear()
}
private fun ensureChannel() {
@@ -1,10 +1,12 @@
<?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.5"
android:centerY="0.4"
android:startColor="#1B2A3B"
android:endColor="#06090E"/>
</shape>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Pure black background -->
<path android:pathData="M0,0 H108 V108 H0 Z"
android:fillColor="#000000"/>
</vector>
@@ -1,50 +1,84 @@
<?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">
<!--
Circular sync icon (AutoSync-style): two 170° arcs forming a clockwise
rotation. Arc 1 sweeps over the bottom (right→bottom→left), Arc 2 over the
top (left→top→right). Arrowheads at the 9-o'clock and 3-o'clock gaps.
Circle radius 26, center (54,54).
Four thick arcs arranged as an interlocked pinwheel.
Each arc sweeps ~210 degrees, rounded caps, radius 18 from center (54,54).
Draw order creates natural over/under at the four crossing points:
blue under green, green under red, red under orange, orange under blue (re-draw blue tip).
Arc endpoints computed at radius 18, sweep 210 deg clockwise:
start angle end angle start point end point
270 (top) 120 (54, 36) (45, 70)
0 (right) 210 (72, 54) (39, 45)
90 (bot) 300 (54, 72) (63, 38)
180 (left) 390=30 (36, 54) (69, 63)
-->
<!-- Arc 1: right → bottom → left (CW, small arc 170°) -->
<!-- Blue — starts at top, sweeps clockwise to lower-left -->
<path
android:pathData="M 79.9,56.3 A 26,26 0 0,1 28.1,56.3"
android:strokeColor="#2979FF"
android:strokeWidth="8.5"
android:fillColor="#00000000"
android:strokeWidth="9"
android:strokeLineCap="round">
<aapt:attr name="android:strokeColor">
<gradient android:type="linear"
android:startX="79.9" android:startY="56.3"
android:endX="28.1" android:endY="56.3"
android:startColor="#40C4FF"
android:endColor="#00E5FF"/>
</aapt:attr>
</path>
<!-- Arrowhead at left side (175°), pointing upward -->
<path android:pathData="M 23.9,65.7 L 28.1,56.3 L 33.9,64.8 Z" android:fillColor="#00E5FF"/>
android:strokeLineCap="round"
android:pathData="M 54,36 A 18,18 0 1,1 45,70"/>
<!-- Arc 2: left → top → right (CW, small arc 170°) -->
<!-- Green — starts at bottom, sweeps clockwise to upper-right -->
<path
android:pathData="M 28.1,51.7 A 26,26 0 0,1 79.9,51.7"
android:strokeColor="#00C853"
android:strokeWidth="8.5"
android:fillColor="#00000000"
android:strokeWidth="9"
android:strokeLineCap="round">
<aapt:attr name="android:strokeColor">
<gradient android:type="linear"
android:startX="28.1" android:startY="51.7"
android:endX="79.9" android:endY="51.7"
android:startColor="#00E5FF"
android:endColor="#40C4FF"/>
</aapt:attr>
</path>
<!-- Arrowhead at right side (355°), pointing downward -->
<path android:pathData="M 84.1,42.3 L 79.9,51.7 L 74.1,43.2 Z" android:fillColor="#40C4FF"/>
android:strokeLineCap="round"
android:pathData="M 54,72 A 18,18 0 1,1 63,38"/>
<!-- Red — starts at right, sweeps clockwise to lower-left -->
<path
android:strokeColor="#E53935"
android:strokeWidth="8.5"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 72,54 A 18,18 0 1,1 39,45"/>
<!-- Orange — starts at left, sweeps clockwise to upper-right -->
<path
android:strokeColor="#FF6D00"
android:strokeWidth="8.5"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 36,54 A 18,18 0 1,1 69,63"/>
<!-- Re-draw blue start cap on top so it goes OVER orange end -->
<path
android:strokeColor="#2979FF"
android:strokeWidth="8.5"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 54,36 A 18,18 0 0,1 62,37.5"/>
<!-- White sync circle at center -->
<path
android:fillColor="#000000"
android:pathData="M 45,54 A 9,9 0 1,0 63,54 A 9,9 0 1,0 45,54 Z"/>
<!-- Sync ring -->
<path
android:strokeColor="#FFFFFF"
android:strokeWidth="2.5"
android:fillColor="#00000000"
android:pathData="M 46.5,54 A 7.5,7.5 0 1,0 61.5,54 A 7.5,7.5 0 1,0 46.5,54 Z"/>
<!-- Top arrow head (pointing up) -->
<path
android:fillColor="#FFFFFF"
android:pathData="M 54,46.5 L 57,50.5 L 51,50.5 Z"/>
<!-- Bottom arrow head (pointing down) -->
<path
android:fillColor="#FFFFFF"
android:pathData="M 54,61.5 L 51,57.5 L 57,57.5 Z"/>
</vector>
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.25
VERSION_CODE=26
VERSION_NAME=1.0.31
VERSION_CODE=32