From ae2c775639a254866a0b4afdce886cb86cf285d3 Mon Sep 17 00:00:00 2001 From: Alex Li Date: Fri, 21 Aug 2026 22:43:35 +0800 Subject: [PATCH 1/4] fix: classify Android change notifications by MediaProvider flags on API 30+ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MediaProvider tags row notifications with NOTIFY_INSERT/UPDATE/DELETE flags. Use them as the authoritative change type instead of the 30-second date heuristic. This also fixes inserts of rows that are not visible to the app yet — such as files pushed via adb push or desktop drag-and-drop, which stay pending and hidden — being misreported as deletations because the classification query cannot find the row. Verified on emulators: API 36 (push/insert/update/delete all classified correctly, one callback per change) and API 28 (legacy inference path intact). #1443 --- CHANGELOG.md | 1 + .../core/PhotoManagerNotifyChannel.kt | 107 ++++++++++++------ 2 files changed, 76 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a72f941..76b6c85c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ To know more about breaking changes, see the [Migration Guide][]. - Fix Android save/copy operations leaving visible incomplete MediaStore items when the write fails (#1434). - Fix Android thumbnail requests not deterministically releasing Glide resources (#1436). - Fix Android path-based saves leaking file descriptors by not closing inspection streams (#1438). +- Fix Android change notifications reporting inserts of not-yet-published rows (e.g. files pushed via `adb push` or desktop drag-and-drop) as deletions (#1443). ## 3.12.0 diff --git a/android/src/main/kotlin/com/fluttercandies/photo_manager/core/PhotoManagerNotifyChannel.kt b/android/src/main/kotlin/com/fluttercandies/photo_manager/core/PhotoManagerNotifyChannel.kt index 94bdf3f8..cee48c63 100644 --- a/android/src/main/kotlin/com/fluttercandies/photo_manager/core/PhotoManagerNotifyChannel.kt +++ b/android/src/main/kotlin/com/fluttercandies/photo_manager/core/PhotoManagerNotifyChannel.kt @@ -127,54 +127,97 @@ class PhotoManagerNotifyChannel( } override fun onChange(selfChange: Boolean, uri: Uri?) { + // Dispatch target on frameworks below API 30, which have no + // change-kind flags; the type is inferred from the row state. + handleOnChange(uri, null) + } + + override fun onChange(selfChange: Boolean, uri: Uri?, flags: Int) { + // Dispatch target on API 30+, where MediaProvider tags row + // notifications with the kind of change. Untagged notifications + // (flags without any kind bit, e.g. plain + // ContentResolver#notifyChange calls) fall back to the row-state + // inference used pre-30. + val kind = flags and ( + ContentResolver.NOTIFY_INSERT + or ContentResolver.NOTIFY_UPDATE + or ContentResolver.NOTIFY_DELETE + ) + handleOnChange( + uri, + when (kind) { + ContentResolver.NOTIFY_INSERT -> "insert" + ContentResolver.NOTIFY_UPDATE -> "update" + ContentResolver.NOTIFY_DELETE -> "delete" + else -> null + }, + ) + } + + private fun handleOnChange(uri: Uri?, authoritativeType: String?) { if (uri == null) { return } val last = uri.lastPathSegment val id = last?.toLongOrNull() - if (id != null) { // insert or update - val cursor = safeQuery( - allUri, - arrayOf(DATE_ADDED, DATE_MODIFIED, MEDIA_TYPE), - "$_ID = ?", - arrayOf(id.toString()) - ) - cursor?.use { - if (!cursor.moveToNext()) { - // If the ID not have item, make it as deleted. - onOuterChange(uri, "delete", id, null, type) + if (id == null) { // collection-level change + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + if (uri == this.uri) { + onOuterChange(uri, "insert", null, null, type) return } - // Find date to determine insert or update. - val addTimestampSecond = cursor.getLong(cursor.getColumnIndex(DATE_ADDED)) - val currentTimeMillis = System.currentTimeMillis() + } + onOuterChange(uri, "delete", null, null, type) + return + } + + if (authoritativeType == "delete") { + // The row is gone; no point in querying it. + onOuterChange(uri, "delete", id, null, type) + return + } - val diffTime = currentTimeMillis / 1000 - addTimestampSecond + val cursor = safeQuery( + allUri, + arrayOf(DATE_ADDED, DATE_MODIFIED, MEDIA_TYPE), + "$_ID = ?", + arrayOf(id.toString()) + ) + cursor?.use { + if (!it.moveToNext()) { + // The row is not visible to this app. On Android 10+, + // rows inserted by other packages — such as files pushed + // via `adb push` or desktop drag-and-drop to an emulator + // — stay pending and hidden until their owner publishes + // them (#1443). An INSERT flag still proves an insert, + // but every other invisible outcome (a trashed row, a + // still-pending row being updated, a real deletion with + // an untagged notification) is reported as "delete", + // matching the historical behavior for missing rows. + val typeString = + if (authoritativeType == "insert") "insert" else "delete" + onOuterChange(uri, typeString, id, null, type) + return + } + // Find date to determine insert or update for untagged events. + val typeString = authoritativeType ?: run { + val addTimestampSecond = it.getLong(it.getColumnIndex(DATE_ADDED)) // Within 30s, it is considered to be inserted, if it is exceeded, it is considered to be changed - val typeString = if (diffTime < 30) { + if (System.currentTimeMillis() / 1000 - addTimestampSecond < 30) { "insert" } else { "update" } - // get Type - val type = cursor.getInt(cursor.getColumnIndex(MEDIA_TYPE)) - val (gId, gName) = getGalleryIdAndName(id, type) - - if (gId == null || gName == null) { - return - } - onOuterChange(uri, typeString, id, gId, type) - } - } else { // delete - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - if (uri == this.uri) { - onOuterChange(uri, "insert", null, null, type) - return - } } - onOuterChange(uri, "delete", null, null, type) + // get Type + val mediaType = it.getInt(it.getColumnIndex(MEDIA_TYPE)) + val (gId, _) = getGalleryIdAndName(id, mediaType) + + // The gallery may fail to resolve in racy cases; galleryId + // is optional in the payload, so still deliver the event. + onOuterChange(uri, typeString, id, gId, mediaType) } } From 675845f4e4d503613e1c0c69e7beeb254ee04a3d Mon Sep 17 00:00:00 2001 From: Alex Li Date: Sat, 22 Aug 2026 00:55:20 +0800 Subject: [PATCH 2/4] ci: commit analysis_options exclude lists generated by the Flutter tool Flutter stable (3.44+) auto-appends an analyzer 'exclude' section (build/android/ios/web/windows/macos/linux) to analysis_options.yaml during 'flutter pub get'. The Runnable workflow's format check runs 'git diff --exit-code .' right after pub get, so every PR and main itself have failed since the 3.47 stable rollout. Committing the generated section keeps the tree clean; also applies to the example options where the 3.44 tool writes the same section. --- analysis_options.yaml | 8 ++++++++ example/analysis_options.yaml | 9 +++++++++ 2 files changed, 17 insertions(+) diff --git a/analysis_options.yaml b/analysis_options.yaml index 67c449a3..987fe78d 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -17,6 +17,14 @@ analyzer: prefer_single_quotes: warning require_trailing_commas: warning invalid_annotation_target: false + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** linter: rules: diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml index 35715ce5..8be9bb29 100644 --- a/example/analysis_options.yaml +++ b/example/analysis_options.yaml @@ -1,3 +1,12 @@ +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: ../analysis_options.yaml linter: From e75380eb27e90737523a56ea7d6beaccbff91e4b Mon Sep 17 00:00:00 2001 From: Alex Li Date: Sat, 22 Aug 2026 01:08:29 +0800 Subject: [PATCH 3/4] ci: upgrade example toolchain for Flutter 3.47 dependency validation Flutter stable now enforces minimum versions at build time: Gradle 8.14 (was 8.13), AGP 8.11.1 (was 8.9.3), Kotlin 2.2.20 (was 2.1.20). The Runnable workflow's Android jobs build the example without the bypass flag, so they fail on every PR since the 3.47 rollout. Bump the wrapper, plugin and Kotlin versions accordingly, and commit the migrator-generated minSdkVersion/android.newDsl changes so the tree matches what the Flutter tool writes. Verified locally: flutter build apk --release (no bypass) and ./gradlew photo_manager:test both pass. --- example/android/app/build.gradle | 2 +- example/android/gradle.properties | 4 ++++ example/android/gradle/wrapper/gradle-wrapper.properties | 2 +- example/android/settings.gradle | 8 ++++---- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 350ffc62..9b682232 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -34,7 +34,7 @@ android { defaultConfig { applicationId "com.fluttercandies.photo_manager_example" - minSdkVersion 21 + minSdkVersion flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName diff --git a/example/android/gradle.properties b/example/android/gradle.properties index 18f971d6..44a2c63e 100644 --- a/example/android/gradle.properties +++ b/example/android/gradle.properties @@ -2,3 +2,7 @@ org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryErro android.useAndroidX=true android.enableJetifier=true kotlin.code.style=official +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index ed4c299a..6514f919 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/example/android/settings.gradle b/example/android/settings.gradle index 7272d69b..c216c0f9 100644 --- a/example/android/settings.gradle +++ b/example/android/settings.gradle @@ -18,10 +18,10 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.9.3" apply false - id "com.android.library" version "8.9.3" apply false - id "org.jetbrains.kotlin.android" version "2.1.20" apply false - id "org.jetbrains.kotlin.kapt" version "2.1.20" apply false + id "com.android.application" version "8.11.1" apply false + id "com.android.library" version "8.11.1" apply false + id "org.jetbrains.kotlin.android" version "2.2.20" apply false + id "org.jetbrains.kotlin.kapt" version "2.2.20" apply false } include ":app" From 59a66a0c1ca90b3e42ecfa695d3689e3926d9485 Mon Sep 17 00:00:00 2001 From: Alex Li Date: Sat, 22 Aug 2026 12:36:29 +0800 Subject: [PATCH 4/4] style: keep include on the first line in example analysis_options Reorder the migrator-generated analyzer exclude section so the file keeps the conventional shape: include first, one blank line between sections. The Flutter tool accepts this order and does not rewrite it. --- example/analysis_options.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml index 8be9bb29..203a9e70 100644 --- a/example/analysis_options.yaml +++ b/example/analysis_options.yaml @@ -1,3 +1,5 @@ +include: ../analysis_options.yaml + analyzer: exclude: - build/** @@ -7,7 +9,6 @@ analyzer: - windows/** - macos/** - linux/** -include: ../analysis_options.yaml linter: rules: