diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a72f9416..76b6c85c6 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/analysis_options.yaml b/analysis_options.yaml index 67c449a32..987fe78d9 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/android/src/main/kotlin/com/fluttercandies/photo_manager/core/PhotoManagerNotifyChannel.kt b/android/src/main/kotlin/com/fluttercandies/photo_manager/core/PhotoManagerNotifyChannel.kt index 94bdf3f8c..cee48c639 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) } } diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml index 35715ce52..203a9e703 100644 --- a/example/analysis_options.yaml +++ b/example/analysis_options.yaml @@ -1,5 +1,15 @@ include: ../analysis_options.yaml +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** + linter: rules: avoid_print: false diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 350ffc626..9b6822322 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 18f971d6b..44a2c63e6 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 ed4c299ad..6514f919f 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 7272d69b3..c216c0f94 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"