Compare commits

...

7 Commits

Author SHA1 Message Date
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
27 changed files with 277 additions and 190 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()
}
}
@@ -62,13 +62,28 @@ class SyncEngine @Inject constructor(
else
LocalAccessor.JavaFile(File(localPath))
private suspend fun performSync(pair: SyncPair, provider: CloudProvider): SyncResult {
private suspend fun performSync(
pair: SyncPair,
provider: CloudProvider,
isRetry: Boolean = false,
): SyncResult {
val accessor = makeAccessor(pair.localPath)
val knownStates = fileStateDao.getForPair(pair.id).associateBy { it.relativePath }
var knownStates = fileStateDao.getForPair(pair.id).associateBy { it.relativePath }
val remoteFiles = provider.listFiles(pair.remotePath).getOrThrow()
.associateBy { it.path.removePrefix(pair.remotePath).trimStart('/') }
val localFiles = accessor.walkFiles(pair)
// Self-healing: if every known-state path is absent from the current local scan but
// the local folder does have files, the localPath was changed without clearing state.
// The stale records would cause every old file to look like "DELETE_REMOTE" and every
// new file to re-upload indefinitely. Wipe and retry once as a fresh initial sync.
if (!isRetry && knownStates.isNotEmpty() && localFiles.isNotEmpty() &&
knownStates.keys.none { it in localFiles }) {
Timber.w("SyncEngine: stale folder states detected for pair ${pair.id} — resetting")
fileStateDao.deleteForPair(pair.id)
return performSync(pair, provider, isRetry = true)
}
val allPaths = (localFiles.keys + remoteFiles.keys + knownStates.keys).toSet()
val hasPriorSyncState = knownStates.isNotEmpty()
val semaphore = Semaphore(4)
@@ -92,11 +107,10 @@ class SyncEngine @Inject constructor(
when (decision) {
SyncDecision.UPLOAD -> {
var uploadedRemoteFile: RemoteFile? = null
val bytes = runCatching {
ensureRemoteDirs(provider, pair.remotePath, rel)
accessor.openInputStream(rel)?.use { stream ->
uploadedRemoteFile = provider.uploadFile(stream, "${pair.remotePath}/$rel", local!!.sizeBytes) { }.getOrThrow()
provider.uploadFile(stream, "${pair.remotePath}/$rel", local!!.sizeBytes) { }.getOrThrow()
}
local!!.sizeBytes
}.getOrElse { e ->
@@ -105,8 +119,12 @@ class SyncEngine @Inject constructor(
return@withPermit FileOutcome(failed = 1)
}
logEvent(pair.id, SyncEventType.FILE_UPLOADED, rel, null, bytes)
// Don't store remote metadata from upload response — the server (Nextcloud etc.)
// may change mtime/etag during post-upload processing. Leaving remoteModifiedAt
// null forces the SKIP reconciliation on the next sync to fill it in from the
// directory listing, which is the same source all future syncs will use.
FileOutcome(uploaded = 1, bytesTransferred = bytes,
newState = buildState(pair.id, rel, local!!, remoteAfterTransfer = uploadedRemoteFile))
newState = buildState(pair.id, rel, local!!, remoteAfterTransfer = null))
}
SyncDecision.DOWNLOAD -> {
val bytes = runCatching {
@@ -131,13 +149,15 @@ class SyncEngine @Inject constructor(
storeLocalMtime = false))
}
SyncDecision.DELETE_LOCAL -> {
accessor.delete(rel)
val deleted = accessor.delete(rel)
if (!deleted) Timber.w("SyncEngine: DELETE_LOCAL failed (silent) for $rel")
fileStateDao.delete(pair.id, rel)
logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "local", 0)
FileOutcome(deleted = 1)
}
SyncDecision.DELETE_REMOTE -> {
provider.deleteFile("${pair.remotePath}/$rel")
runCatching { provider.deleteFile("${pair.remotePath}/$rel") }
.onFailure { e -> Timber.e(e, "SyncEngine: DELETE_REMOTE failed for $rel") }
fileStateDao.delete(pair.id, rel)
logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "remote", 0)
FileOutcome(deleted = 1)
@@ -268,21 +288,15 @@ internal fun syncDecide(
}
!localExists && remoteExists -> when {
known == null -> if (!hasPriorSyncState) {
// Initial sync: no history at all — remote files are new, download them.
known == null -> {
// No state record: could be a new remote file OR a file whose state was lost.
// Downloading is always safer than deleting — if the user deleted the local
// copy intentionally, the state record will still exist (known != null) and
// the else-branch below correctly deletes the remote copy.
when (direction) {
SyncDirection.DOWNLOAD_ONLY, SyncDirection.TWO_WAY -> SyncDecision.DOWNLOAD
else -> SyncDecision.SKIP
}
} else {
// Pair has been synced before but this file has no state record
// (e.g. uploaded before state-tracking was fixed). Treat the same
// as a known remote-deletion: apply mirror/keep behavior.
when {
deleteBehavior == DeleteBehavior.KEEP -> SyncDecision.SKIP
direction == SyncDirection.UPLOAD_ONLY || direction == SyncDirection.TWO_WAY -> SyncDecision.DELETE_REMOTE
else -> SyncDecision.SKIP
}
}
else -> when {
deleteBehavior == DeleteBehavior.KEEP -> SyncDecision.SKIP
@@ -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() {
@@ -1,25 +1,45 @@
<?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">
<!-- Dark charcoal background, matching Avast-style dark icon bg -->
<path android:pathData="M0,0 H108 V108 H0 Z"
android:fillColor="#1F1F2E"/>
<!-- Dark space background -->
<path
android:pathData="M0,0 H108 V108 H0 Z"
android:fillColor="#050F05"/>
<!-- Very subtle inner glow -->
<path android:pathData="M0,0 H108 V108 H0 Z"
android:fillAlpha="0.25">
<aapt:attr name="android:fillColor">
<gradient android:type="radial"
android:gradientRadius="60"
android:centerX="54" android:centerY="50"
android:startColor="#3D3A50"
android:endColor="#00000000"/>
</aapt:attr>
</path>
<!-- Subtle dark green center glow -->
<path
android:pathData="M54,54 A40,40 0 1,0 54.01,54 Z"
android:fillColor="#0D1F0D"
android:fillAlpha="0.9"/>
<!-- Stars -->
<path android:fillColor="#FFFFFF" android:fillAlpha="0.9"
android:pathData="M18,12 A1.2,1.2 0 1,0 18.01,12 Z"/>
<path android:fillColor="#FFFFFF" android:fillAlpha="0.7"
android:pathData="M88,18 A0.9,0.9 0 1,0 88.01,18 Z"/>
<path android:fillColor="#FFFFFF" android:fillAlpha="0.8"
android:pathData="M12,55 A1.0,1.0 0 1,0 12.01,55 Z"/>
<path android:fillColor="#FFFFFF" android:fillAlpha="0.6"
android:pathData="M95,40 A0.8,0.8 0 1,0 95.01,40 Z"/>
<path android:fillColor="#FFFFFF" android:fillAlpha="0.9"
android:pathData="M25,90 A1.1,1.1 0 1,0 25.01,90 Z"/>
<path android:fillColor="#FFFFFF" android:fillAlpha="0.7"
android:pathData="M82,88 A0.9,0.9 0 1,0 82.01,88 Z"/>
<path android:fillColor="#FFFFFF" android:fillAlpha="0.5"
android:pathData="M96,72 A0.8,0.8 0 1,0 96.01,72 Z"/>
<path android:fillColor="#FFFFFF" android:fillAlpha="0.8"
android:pathData="M8,80 A1.0,1.0 0 1,0 8.01,80 Z"/>
<path android:fillColor="#FFFFFF" android:fillAlpha="0.6"
android:pathData="M70,8 A0.9,0.9 0 1,0 70.01,8 Z"/>
<path android:fillColor="#FFFFFF" android:fillAlpha="0.7"
android:pathData="M40,100 A0.8,0.8 0 1,0 40.01,100 Z"/>
<path android:fillColor="#AAFFAA" android:fillAlpha="0.5"
android:pathData="M92,94 A1.0,1.0 0 1,0 92.01,94 Z"/>
<path android:fillColor="#AAAAFF" android:fillAlpha="0.4"
android:pathData="M5,25 A0.8,0.8 0 1,0 5.01,25 Z"/>
</vector>
@@ -1,102 +1,117 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SyncFlow icon foreground.
Design: three bold teardrop "speed streak" shapes in Avast color palette
(teal, red, yellow) converging on a white cloud in the centre.
Each teardrop has a pointed tail (far from cloud) and a wide rounded head
(near the cloud), like motion streaks flying into the sync point.
Safe zone: 18-90dp band. Cloud centred at (54, 55).
-->
<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">
<!-- SHADOW layer under teardrops for depth -->
<!--
Four thick ribbons in an interlocked pinwheel knot.
Each ribbon sweeps 210 degrees clockwise on a radius-18 circle centered at (54,54).
Each ribbon is drawn as: base (width 12) + highlight stripe (width 5).
Over/under order: Blue under Green, Green under Red, Red under Orange, Orange under Blue tip.
Arc start/end points (radius 18 from center 54,54):
Blue: start 270deg (54,36) end 120deg (45,70)
Green: start 90deg (54,72) end 300deg (63,38)
Red: start 0deg (72,54) end 210deg (39,45)
Orange: start 180deg (36,54) end 30deg (69,63)
-->
<!-- Blue ribbon base (goes under Green start and Orange end) -->
<path
android:strokeColor="#1565C0"
android:strokeWidth="12"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 54,36 A 18,18 0 1,1 45,70"/>
<!-- Blue ribbon highlight stripe -->
<path
android:strokeColor="#90CAF9"
android:strokeWidth="5"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 54,36 A 18,18 0 1,1 45,70"/>
<!-- Green ribbon base (over Blue start, under Red end) -->
<path
android:strokeColor="#2E7D32"
android:strokeWidth="12"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 54,72 A 18,18 0 1,1 63,38"/>
<!-- Green ribbon highlight stripe -->
<path
android:strokeColor="#A5D6A7"
android:strokeWidth="5"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 54,72 A 18,18 0 1,1 63,38"/>
<!-- Red ribbon base (over Green start, under Orange end) -->
<path
android:strokeColor="#C62828"
android:strokeWidth="12"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 72,54 A 18,18 0 1,1 39,45"/>
<!-- Red ribbon highlight stripe -->
<path
android:strokeColor="#EF9A9A"
android:strokeWidth="5"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 72,54 A 18,18 0 1,1 39,45"/>
<!-- Orange ribbon base (over Red start, under Blue tip) -->
<path
android:strokeColor="#E65100"
android:strokeWidth="12"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 36,54 A 18,18 0 1,1 69,63"/>
<!-- Orange ribbon highlight stripe -->
<path
android:strokeColor="#FFCC80"
android:strokeWidth="5"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 36,54 A 18,18 0 1,1 69,63"/>
<!-- Redraw Blue start cap on top so it goes OVER Orange end -->
<path
android:strokeColor="#1565C0"
android:strokeWidth="12"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 54,36 A 18,18 0 0,1 62,37.5"/>
<path
android:strokeColor="#90CAF9"
android:strokeWidth="5"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 54,36 A 18,18 0 0,1 62,37.5"/>
<!-- Black circle behind center sync icon -->
<path
android:pathData="M 54,28 C 42,28 30,36 32,50 C 22,55 22,70 34,72 L 74,72 C 84,72 88,62 82,55 C 86,43 76,33 66,35 C 62,30 58,28 54,28 Z"
android:fillColor="#000000"
android:fillAlpha="0.20"
android:translateY="2.5"/>
android:pathData="M 45,54 A 9,9 0 1,0 63,54 A 9,9 0 1,0 45,54 Z"/>
<!-- TEAL teardrop: enters from upper-left, tail at (22,22), head near cloud top-left -->
<!-- Teardrop shape: pointed at tail, fat elliptical head, rotated ~45 deg into centre -->
<!-- White sync ring -->
<path
android:pathData="M 35.5,26.5
C 30,21 22,22 22,22
C 22,22 27,30 32.5,35.5
C 36,38 40,40 43,42
C 40,39 36,32 35.5,26.5 Z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear"
android:startX="22" android:startY="22"
android:endX="43" android:endY="42"
android:startColor="#00BFA5"
android:endColor="#26D6C0"/>
</aapt:attr>
</path>
<!-- RED teardrop: enters from upper-right, tail at (86,22), head near cloud top-right -->
<path
android:pathData="M 72.5,26.5
C 78,21 86,22 86,22
C 86,22 81,30 75.5,35.5
C 72,38 68,40 65,42
C 68,39 72,32 72.5,26.5 Z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear"
android:startX="86" android:startY="22"
android:endX="65" android:endY="42"
android:startColor="#E53935"
android:endColor="#EF6558"/>
</aapt:attr>
</path>
<!-- YELLOW teardrop: enters from bottom-centre, tail at (54,88), head near cloud base -->
<path
android:pathData="M 48,75
C 45,82 47,88 54,88
C 61,88 63,82 60,75
C 58,71 56,68 54,66
C 52,68 50,71 48,75 Z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear"
android:startX="54" android:startY="88"
android:endX="54" android:endY="66"
android:startColor="#F9A825"
android:endColor="#FFD740"/>
</aapt:attr>
</path>
<!-- CLOUD body (white, centred at 54,50) -->
<path
android:pathData="
M 36,62
A 9,9 0 0,1 36,44
A 9,9 0 0,1 45,36
A 12,12 0 0,1 66,37
A 8,8 0 0,1 74,48
A 8,8 0 0,1 68,62
Z"
android:fillColor="#FFFFFF"/>
<!-- Teal highlight on cloud top-left edge -->
<path
android:pathData="M 36,53 A 9,9 0 0,1 41,38"
android:strokeColor="#FFFFFF"
android:strokeWidth="2.5"
android:fillColor="#00000000"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeColor="#4000BFA5"/>
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"/>
<!-- Red highlight on cloud top-right edge -->
<!-- Up arrow (pointing up) -->
<path
android:pathData="M 63,37 A 8,8 0 0,1 73,48"
android:fillColor="#00000000"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeColor="#40E53935"/>
android:fillColor="#FFFFFF"
android:pathData="M 54,46.5 L 57,50.5 L 51,50.5 Z"/>
<!-- Down arrow (pointing down) -->
<path
android:fillColor="#FFFFFF"
android:pathData="M 54,61.5 L 51,57.5 L 57,57.5 Z"/>
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 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: 7.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: 3.8 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: 3.7 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: 13 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: 13 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: 29 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: 28 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: 50 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: 48 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>
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.28
VERSION_CODE=29
VERSION_NAME=1.0.39
VERSION_CODE=40