Compare commits

...

1 Commits

Author SHA1 Message Date
amir ddb558263f v1.0.75: exclude Android app-private trees (Android/data,media,obb)
Build & Release APK / build (push) Successful in 12m49s
Scoped storage (Android 11+) lets a SAF grant LIST another app's
Android/media|data|obb dir but not OPEN the files, so every transfer there
failed and the pair was stuck reporting Partial forever (65 failures under
Android/media/com.whatsapp/ on Zahra). These hold app-managed data, not user
content, so exclude the whole subtree on both sides via path-prefix match in
isExcludedPath. ExcludePathTest covers the new prefixes + case-insensitivity.
2026-06-07 13:01:05 +00:00
3 changed files with 31 additions and 3 deletions
@@ -433,6 +433,15 @@ private val ALWAYS_IGNORED_SEGMENTS = setOf(".thumbnails")
private val ALWAYS_IGNORED_PREFIXES = listOf(".trashed-", ".pending-")
private val ALWAYS_IGNORED_SUFFIXES = listOf(".sfpart")
/**
* App-private storage trees. On Android 11+ (scoped storage) another app's SAF grant can LIST
* these directories but cannot OPEN the files inside, so every transfer fails and the pair is
* stuck reporting "Partial" forever (e.g. Android/media/com.whatsapp/...). They hold app-managed
* data, not user content worth syncing, so they are excluded entirely. Matched case-insensitively
* against the full relative path so the whole subtree is ignored on both sides.
*/
private val ALWAYS_IGNORED_PATH_PREFIXES = listOf("android/data/", "android/media/", "android/obb/")
/**
* True if [rel] should be excluded from sync entirely. Applied symmetrically to the local walk,
* the remote listing, and known state so an excluded path is never uploaded, downloaded, or
@@ -441,7 +450,10 @@ private val ALWAYS_IGNORED_SUFFIXES = listOf(".sfpart")
* existing local-walk semantics in LocalAccessor.
*/
internal fun isExcludedPath(rel: String, pair: SyncPair): Boolean {
val segments = rel.replace('\\', '/').split('/').filter { it.isNotEmpty() }
val normalized = rel.replace('\\', '/')
val lower = normalized.lowercase()
if (ALWAYS_IGNORED_PATH_PREFIXES.any { lower.startsWith(it) }) return true
val segments = normalized.split('/').filter { it.isNotEmpty() }
if (segments.isEmpty()) return false
for (seg in segments) {
if (seg in ALWAYS_IGNORED_SEGMENTS) return true
@@ -57,6 +57,22 @@ class ExcludePathTest {
assertTrue(isExcludedPath("a/b/.photo.jpg.sfpart", p))
}
@Test fun `android app-private trees are excluded (scoped-storage unreadable)`() {
val p = pair()
assertTrue(isExcludedPath("Android/media/com.whatsapp/WhatsApp/Media/IMG.jpg", p))
assertTrue(isExcludedPath("Android/data/com.foo/files/x.bin", p))
assertTrue(isExcludedPath("Android/obb/com.game/main.obb", p))
assertTrue(isExcludedPath("android/MEDIA/com.x/y.jpg", p)) // case-insensitive
}
@Test fun `non-private Android paths are not excluded`() {
val p = pair()
// A user folder literally named "Android" at a deeper level is fine; only the
// top-level app-private trees are blocked.
assertFalse(isExcludedPath("DCIM/Android/holiday.jpg", p))
assertFalse(isExcludedPath("Pictures/android-wallpaper.png", p))
}
@Test fun `normal media files are not excluded`() {
val p = pair()
assertFalse(isExcludedPath("DCIM/Camera/IMG_0001.jpg", p))
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.74
VERSION_CODE=74
VERSION_NAME=1.0.75
VERSION_CODE=75