diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dfa2d121..137ccfa41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ All notable changes to this project will be documented in this file. ### Fixed - SD card access grant on Android Lollipop +- copying to SD card in some cases ## [v1.7.10] - 2023-01-18 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt index 291de3940..1358fdb69 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt @@ -67,7 +67,7 @@ import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeRational import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeString import deckers.thibault.aves.metadata.metadataextractor.Helper.isPngTextDir import deckers.thibault.aves.model.FieldMap -import deckers.thibault.aves.utils.ContextUtils.queryContentResolverProp +import deckers.thibault.aves.utils.ContextUtils.queryContentPropValue import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes.TIFF_EXTENSION_PATTERN @@ -104,8 +104,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { "getPanoramaInfo" -> ioScope.launch { safe(call, result, ::getPanoramaInfo) } "getIptc" -> ioScope.launch { safe(call, result, ::getIptc) } "getXmp" -> ioScope.launch { safe(call, result, ::getXmp) } - "hasContentResolverProp" -> ioScope.launch { safe(call, result, ::hasContentResolverProp) } - "getContentResolverProp" -> ioScope.launch { safe(call, result, ::getContentResolverProp) } + "hasContentResolverProp" -> ioScope.launch { safe(call, result, ::hasContentProp) } + "getContentResolverProp" -> ioScope.launch { safe(call, result, ::getContentPropValue) } "getDate" -> ioScope.launch { safe(call, result, ::getDate) } "getDescription" -> ioScope.launch { safe(call, result, ::getDescription) } else -> result.notImplemented() @@ -1047,10 +1047,10 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { result.success(xmpStrings) } - private fun hasContentResolverProp(call: MethodCall, result: MethodChannel.Result) { + private fun hasContentProp(call: MethodCall, result: MethodChannel.Result) { val prop = call.argument("prop") if (prop == null) { - result.error("hasContentResolverProp-args", "missing arguments", null) + result.error("hasContentProp-args", "missing arguments", null) return } @@ -1058,27 +1058,27 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { when (prop) { "owner_package_name" -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q else -> { - result.error("hasContentResolverProp-unknown", "unknown property=$prop", null) + result.error("hasContentProp-unknown", "unknown property=$prop", null) return } } ) } - private fun getContentResolverProp(call: MethodCall, result: MethodChannel.Result) { + private fun getContentPropValue(call: MethodCall, result: MethodChannel.Result) { val mimeType = call.argument("mimeType") val uri = call.argument("uri")?.let { Uri.parse(it) } val prop = call.argument("prop") if (mimeType == null || uri == null || prop == null) { - result.error("getContentResolverProp-args", "missing arguments", null) + result.error("getContentPropValue-args", "missing arguments", null) return } try { - val value = context.queryContentResolverProp(uri, mimeType, prop) + val value = context.queryContentPropValue(uri, mimeType, prop) result.success(value?.toString()) } catch (e: Exception) { - result.error("getContentResolverProp-query", "failed to query prop for uri=$uri", e.message) + result.error("getContentPropValue-query", "failed to query prop for uri=$uri", e.message) } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt index 74a9a9a3d..0b7ced26e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt @@ -15,7 +15,7 @@ import deckers.thibault.aves.metadata.Mp4ParserHelper.processBoxes import deckers.thibault.aves.metadata.Mp4ParserHelper.toBytes import deckers.thibault.aves.metadata.metadataextractor.SafeMp4UuidBoxHandler import deckers.thibault.aves.metadata.metadataextractor.SafeXmpReader -import deckers.thibault.aves.utils.ContextUtils.queryContentResolverProp +import deckers.thibault.aves.utils.ContextUtils.queryContentPropValue import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MemoryUtils import deckers.thibault.aves.utils.MimeTypes @@ -130,7 +130,7 @@ object XMP { ) { if (MimeTypes.isHeic(mimeType) && !foundXmp && StorageUtils.isMediaStoreContentUri(uri) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { try { - val xmpBytes = context.queryContentResolverProp(uri, mimeType, MediaStore.MediaColumns.XMP) + val xmpBytes = context.queryContentPropValue(uri, mimeType, MediaStore.MediaColumns.XMP) if (xmpBytes is ByteArray && xmpBytes.size > 0) { val xmpMeta = XMPMetaFactory.parseFromBuffer(xmpBytes, SafeXmpReader.PARSE_OPTIONS) processXmp(xmpMeta) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index 3b29bcf55..440b45edf 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import java.io.File import java.io.OutputStream +import java.io.SyncFailedException import java.util.* import java.util.concurrent.CompletableFuture import kotlin.coroutines.Continuation @@ -512,7 +513,16 @@ class MediaStoreImageProvider : ImageProvider() { targetDir = targetDir, targetDirDocFile = targetDirDocFile, targetNameWithoutExtension = targetNameWithoutExtension, - ) { output: OutputStream -> sourceDocFile.copyTo(output) } + ) { output: OutputStream -> + try { + sourceDocFile.copyTo(output) + } catch (e: SyncFailedException) { + // The copied file is synced after writing, but it consistently fails in some cases + // (e.g. copying to SD card on Xiaomi 2201117PG with Android 11). + // It seems this failure can be safely ignored, as the new file is complete. + Log.w(LOG_TAG, "sync failure after copying from uri=$sourceUri, path=$sourcePath to targetDir=$targetDir", e) + } + } if (!copy) { // delete original entry diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/ContextUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/ContextUtils.kt index a0683f388..df443830d 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/ContextUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/ContextUtils.kt @@ -7,6 +7,7 @@ import android.content.ContentUris import android.content.Context import android.database.Cursor import android.net.Uri +import android.provider.DocumentsContract import android.provider.MediaStore import android.util.Log import deckers.thibault.aves.utils.UriUtils.tryParseId @@ -30,7 +31,13 @@ object ContextUtils { return am.getRunningServices(Integer.MAX_VALUE).any { it.service.className == serviceClass.name } } - fun Context.queryContentResolverProp(uri: Uri, mimeType: String, prop: String): Any? { + // `flag`: `DocumentsContract.Document.FLAG_SUPPORTS_COPY`, etc. + fun Context.queryDocumentProviderFlag(docUri: Uri, flag: Int): Boolean { + val flags = queryContentPropValue(docUri, "", DocumentsContract.Document.COLUMN_FLAGS) as Long? + return if (flags != null) (flags.toInt() and flag) == flag else false + } + + fun Context.queryContentPropValue(uri: Uri, mimeType: String, column: String): Any? { var contentUri: Uri = uri if (StorageUtils.isMediaStoreContentUri(uri)) { uri.tryParseId()?.let { id -> @@ -43,26 +50,26 @@ object ContextUtils { } } - // throws SQLiteException when the requested prop is not a known column - val cursor = contentResolver.query(contentUri, arrayOf(prop), null, null, null) - if (cursor == null || !cursor.moveToFirst()) { - throw Exception("failed to get cursor for contentUri=$contentUri") - } - var value: Any? = null try { - value = when (cursor.getType(0)) { - Cursor.FIELD_TYPE_NULL -> null - Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(0) - Cursor.FIELD_TYPE_FLOAT -> cursor.getFloat(0) - Cursor.FIELD_TYPE_STRING -> cursor.getString(0) - Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(0) - else -> null + val cursor = contentResolver.query(contentUri, arrayOf(column), null, null, null) + if (cursor == null || !cursor.moveToFirst()) { + Log.w(LOG_TAG, "failed to get cursor for contentUri=$contentUri column=$column") + } else { + value = when (cursor.getType(0)) { + Cursor.FIELD_TYPE_NULL -> null + Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(0) + Cursor.FIELD_TYPE_FLOAT -> cursor.getFloat(0) + Cursor.FIELD_TYPE_STRING -> cursor.getString(0) + Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(0) + else -> null + } + cursor.close() } } catch (e: Exception) { - Log.w(LOG_TAG, "failed to get value for contentUri=$contentUri key=$prop", e) + // throws SQLiteException/IllegalArgumentException when the requested prop is not a known column + Log.w(LOG_TAG, "failed to get value for contentUri=$contentUri column=$column", e) } - cursor.close() return value } }