diff --git a/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt b/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt index c65c1b5..0cf036b 100644 --- a/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt +++ b/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt @@ -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 diff --git a/app/src/test/kotlin/com/syncflow/domain/sync/ExcludePathTest.kt b/app/src/test/kotlin/com/syncflow/domain/sync/ExcludePathTest.kt index a3fe4f6..93dc661 100644 --- a/app/src/test/kotlin/com/syncflow/domain/sync/ExcludePathTest.kt +++ b/app/src/test/kotlin/com/syncflow/domain/sync/ExcludePathTest.kt @@ -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)) diff --git a/version.properties b/version.properties index c0601a3..b6ee0b7 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=1.0.74 -VERSION_CODE=74 +VERSION_NAME=1.0.75 +VERSION_CODE=75