Compare commits

..

3 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
27 changed files with 138 additions and 110 deletions
@@ -13,6 +13,8 @@ import okhttp3.*
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okio.BufferedSink
import okio.source
import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory import org.xmlpull.v1.XmlPullParserFactory
import java.io.InputStream import java.io.InputStream
@@ -84,13 +86,18 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
onProgress: (Long) -> Unit, onProgress: (Long) -> Unit,
): Result<RemoteFile> = runCatching { ): Result<RemoteFile> = runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val bytes = localStream.readBytes() val body = object : RequestBody() {
val body = bytes.toRequestBody("application/octet-stream".toMediaType()) 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() val req = Request.Builder().url(url(remotePath)).put(body).build()
client.newCall(req).execute().use { resp -> client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) throw Exception("Upload HTTP ${resp.code}") if (!resp.isSuccessful) throw Exception("Upload HTTP ${resp.code}")
} }
onProgress(bytes.size.toLong()) onProgress(sizeBytes)
getFileMetadata(remotePath).getOrThrow() getFileMetadata(remotePath).getOrThrow()
} }
} }
@@ -107,11 +107,10 @@ class SyncEngine @Inject constructor(
when (decision) { when (decision) {
SyncDecision.UPLOAD -> { SyncDecision.UPLOAD -> {
var uploadedRemoteFile: RemoteFile? = null
val bytes = runCatching { val bytes = runCatching {
ensureRemoteDirs(provider, pair.remotePath, rel) ensureRemoteDirs(provider, pair.remotePath, rel)
accessor.openInputStream(rel)?.use { stream -> 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 local!!.sizeBytes
}.getOrElse { e -> }.getOrElse { e ->
@@ -120,8 +119,12 @@ class SyncEngine @Inject constructor(
return@withPermit FileOutcome(failed = 1) return@withPermit FileOutcome(failed = 1)
} }
logEvent(pair.id, SyncEventType.FILE_UPLOADED, rel, null, bytes) 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, FileOutcome(uploaded = 1, bytesTransferred = bytes,
newState = buildState(pair.id, rel, local!!, remoteAfterTransfer = uploadedRemoteFile)) newState = buildState(pair.id, rel, local!!, remoteAfterTransfer = null))
} }
SyncDecision.DOWNLOAD -> { SyncDecision.DOWNLOAD -> {
val bytes = runCatching { val bytes = runCatching {
@@ -146,13 +149,15 @@ class SyncEngine @Inject constructor(
storeLocalMtime = false)) storeLocalMtime = false))
} }
SyncDecision.DELETE_LOCAL -> { 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) fileStateDao.delete(pair.id, rel)
logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "local", 0) logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "local", 0)
FileOutcome(deleted = 1) FileOutcome(deleted = 1)
} }
SyncDecision.DELETE_REMOTE -> { 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) fileStateDao.delete(pair.id, rel)
logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "remote", 0) logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "remote", 0)
FileOutcome(deleted = 1) FileOutcome(deleted = 1)
@@ -283,21 +288,15 @@ internal fun syncDecide(
} }
!localExists && remoteExists -> when { !localExists && remoteExists -> when {
known == null -> if (!hasPriorSyncState) { known == null -> {
// Initial sync: no history at all — remote files are new, download them. // 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) { when (direction) {
SyncDirection.DOWNLOAD_ONLY, SyncDirection.TWO_WAY -> SyncDecision.DOWNLOAD SyncDirection.DOWNLOAD_ONLY, SyncDirection.TWO_WAY -> SyncDecision.DOWNLOAD
else -> SyncDecision.SKIP 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 { else -> when {
deleteBehavior == DeleteBehavior.KEEP -> SyncDecision.SKIP deleteBehavior == DeleteBehavior.KEEP -> SyncDecision.SKIP
@@ -20,7 +20,9 @@ import com.syncflow.MainActivity
import com.syncflow.R import com.syncflow.R
import com.syncflow.data.db.SyncFileStateDao import com.syncflow.data.db.SyncFileStateDao
import com.syncflow.data.db.SyncPairDao import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.toDomain
import com.syncflow.domain.model.ScheduleType import com.syncflow.domain.model.ScheduleType
import com.syncflow.domain.sync.LocalAccessor
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@@ -224,21 +226,25 @@ class FileWatchService : Service() {
val known = fileStateDao.getForPair(pairId).associateBy { it.relativePath } val known = fileStateDao.getForPair(pairId).associateBy { it.relativePath }
if (known.isEmpty()) return // Never synced — first sync will be triggered manually if (known.isEmpty()) return // Never synced — first sync will be triggered manually
val current = mutableMapOf<String, Long>() val pairEntity = syncPairDao.getById(pairId) ?: return
dir.walk().filter { it.isFile }.forEach { f -> val pair = pairEntity.toDomain()
current[f.relativeTo(dir).path.replace('\\', '/')] = f.lastModified() // 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 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 && 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 } val hasDeleted = known.keys.any { rel -> rel !in current }
if (hasNew || hasModified || hasDeleted) { if (hasNew || hasModified || hasDeleted) {
Timber.d("FileWatchService: catchup detected changes for pair $pairId, scheduling sync") 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 // Cancel any debounce that started before our startup cooldown was set
debounceJobs[pairId]?.cancel() debounceJobs[pairId]?.cancel()
debounceJobs.remove(pairId) debounceJobs.remove(pairId)
@@ -5,8 +5,41 @@
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<!-- Pure black background --> <!-- Dark space background -->
<path android:pathData="M0,0 H108 V108 H0 Z" <path
android:fillColor="#000000"/> android:pathData="M0,0 H108 V108 H0 Z"
android:fillColor="#050F05"/>
<!-- 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> </vector>
@@ -6,77 +6,110 @@
android:viewportHeight="108"> android:viewportHeight="108">
<!-- <!--
Four thick arcs arranged as an interlocked pinwheel. Four thick ribbons in an interlocked pinwheel knot.
Each arc sweeps ~210 degrees, rounded caps, radius 18 from center (54,54). Each ribbon sweeps 210 degrees clockwise on a radius-18 circle centered at (54,54).
Draw order creates natural over/under at the four crossing points: Each ribbon is drawn as: base (width 12) + highlight stripe (width 5).
blue under green, green under red, red under orange, orange under blue (re-draw blue tip). Over/under order: Blue under Green, Green under Red, Red under Orange, Orange under Blue tip.
Arc endpoints computed at radius 18, sweep 210 deg clockwise: Arc start/end points (radius 18 from center 54,54):
start angle end angle start point end point Blue: start 270deg (54,36) end 120deg (45,70)
270 (top) 120 (54, 36) (45, 70) Green: start 90deg (54,72) end 300deg (63,38)
0 (right) 210 (72, 54) (39, 45) Red: start 0deg (72,54) end 210deg (39,45)
90 (bot) 300 (54, 72) (63, 38) Orange: start 180deg (36,54) end 30deg (69,63)
180 (left) 390=30 (36, 54) (69, 63)
--> -->
<!-- Blue — starts at top, sweeps clockwise to lower-left --> <!-- Blue ribbon base (goes under Green start and Orange end) -->
<path <path
android:strokeColor="#2979FF" android:strokeColor="#1565C0"
android:strokeWidth="8.5" 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:fillColor="#00000000"
android:strokeLineCap="round" android:strokeLineCap="round"
android:pathData="M 54,36 A 18,18 0 1,1 45,70"/> android:pathData="M 54,36 A 18,18 0 1,1 45,70"/>
<!-- Green — starts at bottom, sweeps clockwise to upper-right --> <!-- Green ribbon base (over Blue start, under Red end) -->
<path <path
android:strokeColor="#00C853" android:strokeColor="#2E7D32"
android:strokeWidth="8.5" 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:fillColor="#00000000"
android:strokeLineCap="round" android:strokeLineCap="round"
android:pathData="M 54,72 A 18,18 0 1,1 63,38"/> android:pathData="M 54,72 A 18,18 0 1,1 63,38"/>
<!-- Red — starts at right, sweeps clockwise to lower-left --> <!-- Red ribbon base (over Green start, under Orange end) -->
<path <path
android:strokeColor="#E53935" android:strokeColor="#C62828"
android:strokeWidth="8.5" 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:fillColor="#00000000"
android:strokeLineCap="round" android:strokeLineCap="round"
android:pathData="M 72,54 A 18,18 0 1,1 39,45"/> android:pathData="M 72,54 A 18,18 0 1,1 39,45"/>
<!-- Orange — starts at left, sweeps clockwise to upper-right --> <!-- Orange ribbon base (over Red start, under Blue tip) -->
<path <path
android:strokeColor="#FF6D00" android:strokeColor="#E65100"
android:strokeWidth="8.5" 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:fillColor="#00000000"
android:strokeLineCap="round" android:strokeLineCap="round"
android:pathData="M 36,54 A 18,18 0 1,1 69,63"/> 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 --> <!-- Redraw Blue start cap on top so it goes OVER Orange end -->
<path <path
android:strokeColor="#2979FF" android:strokeColor="#1565C0"
android:strokeWidth="8.5" 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:fillColor="#00000000"
android:strokeLineCap="round" android:strokeLineCap="round"
android:pathData="M 54,36 A 18,18 0 0,1 62,37.5"/> android:pathData="M 54,36 A 18,18 0 0,1 62,37.5"/>
<!-- White sync circle at center --> <!-- Black circle behind center sync icon -->
<path <path
android:fillColor="#000000" 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"/> android:pathData="M 45,54 A 9,9 0 1,0 63,54 A 9,9 0 1,0 45,54 Z"/>
<!-- Sync ring --> <!-- White sync ring -->
<path <path
android:strokeColor="#FFFFFF" android:strokeColor="#FFFFFF"
android:strokeWidth="2.5" android:strokeWidth="2.5"
android:fillColor="#00000000" 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"/> 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) --> <!-- Up arrow (pointing up) -->
<path <path
android:fillColor="#FFFFFF" android:fillColor="#FFFFFF"
android:pathData="M 54,46.5 L 57,50.5 L 51,50.5 Z"/> android:pathData="M 54,46.5 L 57,50.5 L 51,50.5 Z"/>
<!-- Bottom arrow head (pointing down) --> <!-- Down arrow (pointing down) -->
<path <path
android:fillColor="#FFFFFF" android:fillColor="#FFFFFF"
android:pathData="M 54,61.5 L 51,57.5 L 57,57.5 Z"/> android:pathData="M 54,61.5 L 51,57.5 L 57,57.5 Z"/>
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.32 VERSION_NAME=1.0.39
VERSION_CODE=33 VERSION_CODE=40