Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2022-08-27 18:45:29 +02:00
commit d0719f4ebd
282 changed files with 3242 additions and 1331 deletions

3
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1,3 @@
github: deckerst
liberapay: deckerst
custom: https://www.paypal.me/ThibaultDeckersFr

View file

@ -17,7 +17,7 @@ jobs:
# Available versions may lag behind https://github.com/flutter/flutter.git
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.3.0-0.0.pre'
flutter-version: '3.3.0-0.5.pre'
channel: 'beta'
- name: Clone the repository.

View file

@ -19,7 +19,7 @@ jobs:
# Available versions may lag behind https://github.com/flutter/flutter.git
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.3.0-0.0.pre'
flutter-version: '3.3.0-0.5.pre'
channel: 'beta'
# Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1):
@ -56,15 +56,15 @@ jobs:
rm release.keystore.asc
mkdir outputs
(cd scripts/; ./apply_flavor_play.sh)
flutter build appbundle -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_3.3.0-0.0.pre.sksl.json
flutter build appbundle -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_3.3.0-0.5.pre.sksl.json
cp build/app/outputs/bundle/playRelease/*.aab outputs
flutter build apk -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_3.3.0-0.0.pre.sksl.json
flutter build apk -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_3.3.0-0.5.pre.sksl.json
cp build/app/outputs/apk/play/release/*.apk outputs
(cd scripts/; ./apply_flavor_huawei.sh)
flutter build apk -t lib/main_huawei.dart --flavor huawei --bundle-sksl-path shaders_3.3.0-0.0.pre.sksl.json
flutter build apk -t lib/main_huawei.dart --flavor huawei --bundle-sksl-path shaders_3.3.0-0.5.pre.sksl.json
cp build/app/outputs/apk/huawei/release/*.apk outputs
(cd scripts/; ./apply_flavor_izzy.sh)
flutter build apk -t lib/main_izzy.dart --flavor izzy --split-per-abi --bundle-sksl-path shaders_3.3.0-0.0.pre.sksl.json
flutter build apk -t lib/main_izzy.dart --flavor izzy --split-per-abi --bundle-sksl-path shaders_3.3.0-0.5.pre.sksl.json
cp build/app/outputs/apk/izzy/release/*.apk outputs
rm $AVES_STORE_FILE
env:

View file

@ -4,6 +4,32 @@ All notable changes to this project will be documented in this file.
## <a id="unreleased"></a>[Unreleased]
## <a id="v1.6.12"></a>[v1.6.12] - 2022-08-27
### Added
- Viewer: optional gesture to show previous/next item
- Albums / Countries / Tags: live title filter
- option to hide confirmation message after moving items to the bin
- Collection / Info: edit description via Exif / IPTC / XMP
- Info: read XMP from HEIC on Android >=11
- Collection: support HEIC motion photos on Android >=11
- Search: `recently added` filter
- Dutch translation (thanks Martijn Fabrie, Koen Koppens)
### Changed
- status and navigation bar transparency
- default snack bar timeout to 3s
- upgraded Flutter to beta v3.3.0-0.5.pre
### Fixed
- storage volume setup despite faulty volume on Android <11
- storage volume setup when launched right after device boot
- tiling PNG images
- widget image sizing in some cases
## <a id="v1.6.11"></a>[v1.6.11] - 2022-07-26
### Added
@ -31,7 +57,7 @@ All notable changes to this project will be documented in this file.
- slideshow
- set wallpaper from any media
- optional dynamic accent color on Android 12+
- optional dynamic accent color on Android >=12
- Search: date/dimension/size field equality (undocumented)
- support Android 13 (API 33)
- Turkish translation (thanks metezd)
@ -126,7 +152,7 @@ All notable changes to this project will be documented in this file.
### Fixed
- app launch despite faulty storage volumes on Android 11+
- app launch despite faulty storage volumes on Android >=11
## <a id="v1.6.2"></a>[v1.6.2] - 2022-03-07
@ -311,7 +337,7 @@ All notable changes to this project will be documented in this file.
- Info: improved display for PNG text metadata, XMP and others
- Export: output format selection
- Search: added raw filter
- Support modifying files in the Download folder on Android 11+
- Support modifying files in the Download folder on Android >=11
### Changed
@ -321,7 +347,7 @@ All notable changes to this project will be documented in this file.
### Fixed
- hide root album of hidden path
- gesture & spacing handling for Android 10+ navigation gestures
- gesture & spacing handling for Android >=10 navigation gestures
- renaming was leaving behind obsolete items in some cases
- speeding up videos on Xiaomi devices
@ -384,7 +410,7 @@ All notable changes to this project will be documented in this file.
- Map & Stats from selection
- Map: item browsing, rotation control
- Navigation menu customization
- shortcut support on older devices (API < 26)
- shortcut support on older devices (API <26)
- support Android 12/S (API 31)
## [v1.4.8] - 2021-08-08
@ -398,7 +424,7 @@ All notable changes to this project will be documented in this file.
### Fixed
- auto album identification and naming
- opening HEIC images from downloads content URI on Android R+
- opening HEIC images from downloads content URI on Android >=11
## [v1.4.7] - 2021-08-06 [YANKED]
@ -602,7 +628,7 @@ All notable changes to this project will be documented in this file.
- Viewer: support for multi-track HEIF
- Viewer: export image (including multipage TIFF/HEIF and images embedded in XMP)
- Info: show owner app (Android Q and up)
- Info: show owner app (Android >=10)
- listen to Media Store changes
### Changed
@ -629,7 +655,7 @@ upgraded libtiff to 4.2.0 for TIFF decoding
### Fixed
- prevent scrolling when using Android Q style gesture navigation
- prevent scrolling when using Android 10 style gesture navigation
## [v1.3.1] - 2021-01-04

View file

@ -93,7 +93,7 @@ At this stage this project does *not* accept PRs, except for translations.
### Translations
If you want to translate this app in your language and share the result, [there is a guide](https://github.com/deckerst/aves/wiki/Contributing-to-Translations). English, Korean and French are already handled by me. Russian, German, Spanish, Portuguese, Indonesian, Japanese, Italian, Chinese & Turkish are handled by generous volunteers.
If you want to translate this app in your language and share the result, [there is a guide](https://github.com/deckerst/aves/wiki/Contributing-to-Translations). English, Korean and French are already handled by me. Russian, German, Spanish, Portuguese, Indonesian, Japanese, Italian, Chinese, Turkish & Dutch are handled by generous volunteers.
### Donations

View file

@ -12,8 +12,8 @@ This change eventually prevents building the app with Flutter v3.0.2.
android:installLocation="auto">
<!--
Scoped storage on Android Q is inconvenient because users need to confirm edition on each individual file.
So we request `WRITE_EXTERNAL_STORAGE` until Q (29), and enable `requestLegacyExternalStorage`
Scoped storage on Android 10 is inconvenient because users need to confirm edition on each individual file.
So we request `WRITE_EXTERNAL_STORAGE` until Android 10 (API 29), and enable `requestLegacyExternalStorage`
-->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
@ -31,25 +31,25 @@ This change eventually prevents building the app with Flutter v3.0.2.
<!-- to show foreground service progress via notification -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- to access media with original metadata with scoped storage (Android Q+) -->
<!-- to access media with original metadata with scoped storage (Android >=10) -->
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<!-- TODO TLAD remove this permission when this is fixed: https://github.com/flutter/flutter/issues/42451 -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- for API < 26 -->
<!-- for API <26 -->
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
<!-- allow install on API 19, but Google Maps is from API 20 -->
<uses-sdk tools:overrideLibrary="io.flutter.plugins.googlemaps" />
<!-- from Android R, we should define <queries> to make other apps visible to this app -->
<!-- from Android 11, we should define <queries> to make other apps visible to this app -->
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
</intent>
<!--
from Android R, `url_launcher` method `canLaunchUrl()` will return false,
from Android 11, `url_launcher` method `canLaunchUrl()` will return false,
if appropriate intents are not declared, cf https://pub.dev/packages/url_launcher#configuration=
-->
<!-- to open https URLs -->

View file

@ -285,7 +285,7 @@ open class MainActivity : FlutterActivity() {
val filters = intent.getStringArrayExtra(EXTRA_KEY_FILTERS_ARRAY)?.toList()
if (filters != null) return filters
// fallback for shortcuts created on API < 26
// fallback for shortcuts created on API <26
val filterString = intent.getStringExtra(EXTRA_KEY_FILTERS_STRING)
if (filterString != null) {
return filterString.split(EXTRA_STRING_ARRAY_SEPARATOR)

View file

@ -102,6 +102,7 @@ class ScreenSaverService : DreamService() {
MethodChannel(messenger, MediaFetchHandler.CHANNEL).setMethodCallHandler(MediaFetchHandler(this))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
// - need ContextWrapper
MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this))
// - need Service

View file

@ -36,6 +36,7 @@ class WallpaperActivity : FlutterActivity() {
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
MethodChannel(messenger, MediaFetchHandler.CHANNEL).setMethodCallHandler(MediaFetchHandler(this))
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
// - need ContextWrapper
MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this))
MethodChannel(messenger, WallpaperHandler.CHANNEL).setMethodCallHandler(WallpaperHandler(this))

View file

@ -17,7 +17,7 @@ class Coresult internal constructor(private val call: MethodCall, private val me
try {
methodResult.success(result)
} catch (e: Exception) {
MainActivity.notifyError("failed to reply success for method=${call.method}, result=$result, exception=$e")
MainActivity.notifyError("failed to reply success for method=${call.method}, result=$result, exception=\n${e.stackTraceToString()}")
}
}
}
@ -27,7 +27,7 @@ class Coresult internal constructor(private val call: MethodCall, private val me
try {
methodResult.error(errorCode, errorMessage, errorDetails)
} catch (e: Exception) {
MainActivity.notifyError("failed to reply error for method=${call.method}, errorCode=$errorCode, errorMessage=$errorMessage, errorDetails=$errorDetails, exception=$e")
MainActivity.notifyError("failed to reply error for method=${call.method}, errorCode=$errorCode, errorMessage=$errorMessage, errorDetails=$errorDetails, exception=\n${e.stackTraceToString()}")
}
}
}
@ -37,7 +37,7 @@ class Coresult internal constructor(private val call: MethodCall, private val me
try {
methodResult.notImplemented()
} catch (e: Exception) {
MainActivity.notifyError("failed to reply notImplemented for method=${call.method}, exception=$e")
MainActivity.notifyError("failed to reply notImplemented for method=${call.method}, exception=\n${e.stackTraceToString()}")
}
}
}

View file

@ -15,7 +15,11 @@ import android.util.Log
import androidx.exifinterface.media.ExifInterface
import com.drew.metadata.file.FileTypeDirectory
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.metadata.*
import deckers.thibault.aves.metadata.ExifInterfaceHelper
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.metadata.PixyMetaHelper
import deckers.thibault.aves.metadata.metadataextractor.Helper
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface
@ -284,7 +288,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = MetadataExtractorHelper.safeRead(input)
val metadata = Helper.safeRead(input)
metadataMap["mimeType"] = metadata.getDirectoriesOfType(FileTypeDirectory::class.java).joinToString { dir ->
if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) {
dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)

View file

@ -12,10 +12,10 @@ import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.metadata.MetadataExtractorHelper
import deckers.thibault.aves.metadata.MultiPage
import deckers.thibault.aves.metadata.XMP
import deckers.thibault.aves.metadata.XMP.getSafeStructField
import deckers.thibault.aves.metadata.XMPPropName
import deckers.thibault.aves.metadata.metadataextractor.Helper
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.provider.ContentImageProvider
import deckers.thibault.aves.model.provider.ImageProvider
@ -118,7 +118,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
retriever.embeddedPicture?.let { bytes ->
var embedMimeType: String? = null
bytes.inputStream().use { input ->
MetadataExtractorHelper.readMimeType(input)?.let { embedMimeType = it }
Helper.readMimeType(input)?.let { embedMimeType = it }
}
embedMimeType?.let { mime ->
copyEmbeddedBytes(result, mime, displayName, bytes.inputStream())
@ -140,26 +140,34 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val displayName = call.argument<String>("displayName")
val dataPropPath = call.argument<String>("propPath")
val dataProp = call.argument<List<Any>>("propPath")
val embedMimeType = call.argument<String>("propMimeType")
if (mimeType == null || uri == null || dataPropPath == null || embedMimeType == null) {
if (mimeType == null || uri == null || dataProp == null || embedMimeType == null) {
result.error("extractXmpDataProp-args", "missing arguments", null)
return
}
val props = dataProp.mapNotNull {
when (it) {
is List<*> -> XMPPropName(it.first() as String, it.last() as String)
is Int -> it
else -> null
}
}
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = MetadataExtractorHelper.safeRead(input)
val metadata = Helper.safeRead(input)
// data can be large and stored in "Extended XMP",
// which is returned as a second XMP directory
val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java)
try {
val embedBytes: ByteArray = if (!dataPropPath.contains('/')) {
val propNs = XMP.namespaceForPropPath(dataPropPath)
xmpDirs.mapNotNull { it.xmpMeta.getPropertyBase64(propNs, dataPropPath) }.first()
val embedBytes: ByteArray = if (props.size == 1) {
val prop = props.first() as XMPPropName
xmpDirs.mapNotNull { it.xmpMeta.getPropertyBase64(prop.nsUri, prop.toString()) }.first()
} else {
xmpDirs.mapNotNull { it.xmpMeta.getSafeStructField(dataPropPath) }.first().let {
xmpDirs.mapNotNull { it.xmpMeta.getSafeStructField(props) }.first().let {
XMPUtils.decodeBase64(it.value)
}
}
@ -167,7 +175,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
copyEmbeddedBytes(result, embedMimeType, displayName, embedBytes.inputStream())
return
} catch (e: XMPException) {
result.error("extractXmpDataProp-xmp", "failed to read XMP directory for uri=$uri prop=$dataPropPath", e.message)
result.error("extractXmpDataProp-xmp", "failed to read XMP directory for uri=$uri prop=$dataProp", e.message)
return
}
}
@ -179,7 +187,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
Log.w(LOG_TAG, "failed to extract file from XMP", e)
}
}
result.error("extractXmpDataProp-empty", "failed to extract file from XMP uri=$uri prop=$dataPropPath", null)
result.error("extractXmpDataProp-empty", "failed to extract file from XMP uri=$uri prop=$dataProp", null)
}
private fun copyEmbeddedBytes(result: MethodChannel.Result, mimeType: String, displayName: String?, embeddedByteStream: InputStream) {

View file

@ -1,16 +1,14 @@
package deckers.thibault.aves.channel.calls
import android.annotation.SuppressLint
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.Log
import androidx.exifinterface.media.ExifInterface
import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.XMPMeta
import com.adobe.internal.xmp.XMPMetaFactory
import com.adobe.internal.xmp.options.SerializeOptions
import com.adobe.internal.xmp.properties.XMPPropertyInfo
@ -44,38 +42,40 @@ import deckers.thibault.aves.metadata.Metadata.DIR_EXIF_GEOTIFF
import deckers.thibault.aves.metadata.Metadata.DIR_PNG_TEXTUAL_DATA
import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
import deckers.thibault.aves.metadata.Metadata.isFlippedForExifCode
import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_ITXT_DIR_NAME
import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_LAST_MODIFICATION_TIME_FORMAT
import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_TIME_DIR_NAME
import deckers.thibault.aves.metadata.MetadataExtractorHelper.containsGeoTiffTags
import deckers.thibault.aves.metadata.MetadataExtractorHelper.extractGeoKeys
import deckers.thibault.aves.metadata.MetadataExtractorHelper.extractPngProfile
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getDateDigitizedMillis
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getDateModifiedMillis
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getDateOriginalMillis
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeBoolean
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeRational
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
import deckers.thibault.aves.metadata.MetadataExtractorHelper.isPngTextDir
import deckers.thibault.aves.metadata.XMP.doesPropExist
import deckers.thibault.aves.metadata.XMP.getPropArrayItemValues
import deckers.thibault.aves.metadata.XMP.getSafeDateMillis
import deckers.thibault.aves.metadata.XMP.getSafeInt
import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText
import deckers.thibault.aves.metadata.XMP.getSafeString
import deckers.thibault.aves.metadata.XMP.isMotionPhoto
import deckers.thibault.aves.metadata.XMP.isPanorama
import deckers.thibault.aves.metadata.metadataextractor.Helper
import deckers.thibault.aves.metadata.metadataextractor.Helper.PNG_ITXT_DIR_NAME
import deckers.thibault.aves.metadata.metadataextractor.Helper.PNG_LAST_MODIFICATION_TIME_FORMAT
import deckers.thibault.aves.metadata.metadataextractor.Helper.PNG_TIME_DIR_NAME
import deckers.thibault.aves.metadata.metadataextractor.Helper.containsGeoTiffTags
import deckers.thibault.aves.metadata.metadataextractor.Helper.extractGeoKeys
import deckers.thibault.aves.metadata.metadataextractor.Helper.extractPngProfile
import deckers.thibault.aves.metadata.metadataextractor.Helper.getDateDigitizedMillis
import deckers.thibault.aves.metadata.metadataextractor.Helper.getDateModifiedMillis
import deckers.thibault.aves.metadata.metadataextractor.Helper.getDateOriginalMillis
import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeBoolean
import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeDateMillis
import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeInt
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.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.TIFF_EXTENSION_PATTERN
import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface
import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor
import deckers.thibault.aves.utils.MimeTypes.isHeic
import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.UriUtils.tryParseId
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
@ -83,6 +83,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.json.JSONObject
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets
import java.text.DecimalFormat
@ -106,6 +107,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
"hasContentResolverProp" -> ioScope.launch { safe(call, result, ::hasContentResolverProp) }
"getContentResolverProp" -> ioScope.launch { safe(call, result, ::getContentResolverProp) }
"getDate" -> ioScope.launch { safe(call, result, ::getDate) }
"getDescription" -> ioScope.launch { safe(call, result, ::getDescription) }
else -> result.notImplemented()
}
}
@ -123,18 +125,43 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
var foundExif = false
var foundXmp = false
fun processXmp(xmpMeta: XMPMeta, dirMap: MutableMap<String, String>) {
try {
for (prop in xmpMeta) {
if (prop is XMPPropertyInfo) {
val path = prop.path
if (path?.isNotEmpty() == true) {
val value = if (XMP.isDataPath(path)) "[skipped]" else prop.value
if (value?.isNotEmpty() == true) {
dirMap[path] = value
}
}
}
}
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
}
// remove this stat as it is not actual XMP data
dirMap.remove(XmpDirectory().getTagName(XmpDirectory.TAG_XMP_VALUE_COUNT))
// add schema prefixes for namespace resolution
val prefixes = XMPMetaFactory.getSchemaRegistry().prefixes
dirMap["schemaRegistryPrefixes"] = JSONObject(prefixes).toString()
}
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = MetadataExtractorHelper.safeRead(input)
val metadata = Helper.safeRead(input)
foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 }
foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
val uuidDirCount = HashMap<String, Int>()
val dirByName = metadata.directories.filter {
(it.tagCount > 0 || it.errorCount > 0)
&& it !is FileTypeDirectory
&& it !is AviDirectory
}.groupBy { dir -> dir.name }
for (dirEntry in dirByName) {
val baseDirName = dirEntry.key
@ -262,23 +289,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
if (dir is XmpDirectory) {
try {
for (prop in dir.xmpMeta) {
if (prop is XMPPropertyInfo) {
val path = prop.path
if (path?.isNotEmpty() == true) {
val value = if (XMP.isDataPath(path)) "[skipped]" else prop.value
if (value?.isNotEmpty() == true) {
dirMap[path] = value
}
}
}
}
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
}
// remove this stat as it is not actual XMP data
dirMap.remove(dir.getTagName(XmpDirectory.TAG_XMP_VALUE_COUNT))
processXmp(dir.xmpMeta, dirMap)
}
if (dir is Mp4UuidBoxDirectory) {
@ -356,6 +367,13 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
XMP.checkHeic(context, uri, mimeType, foundXmp) { xmpMeta ->
val thisDirName = XmpDirectory().name
val dirMap = metadataMap[thisDirName] ?: HashMap()
metadataMap[thisDirName] = dirMap
processXmp(xmpMeta, dirMap)
}
if (isVideo(mimeType)) {
// this is used as fallback when the video metadata cannot be found on the Dart side
// and to identify whether there is an accessible cover image
@ -409,9 +427,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
// - XMP / photoshop:DateCreated
// - PNG / TIME / LAST_MODIFICATION_TIME
// - Video / METADATA_KEY_DATE
// set `KEY_XMP_TITLE_DESCRIPTION` from these fields (by precedence):
// set `KEY_XMP_TITLE` from this field:
// - XMP / dc:title
// - XMP / dc:description
// set `KEY_XMP_SUBJECTS` from these fields (by precedence):
// - XMP / dc:subject
// - IPTC / keywords
@ -447,12 +464,51 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
) {
var flags = (metadataMap[KEY_FLAGS] ?: 0) as Int
var foundExif = false
var foundXmp = false
fun processXmp(xmpMeta: XMPMeta) {
try {
if (xmpMeta.doesPropExist(XMP.DC_SUBJECT_PROP_NAME)) {
val values = xmpMeta.getPropArrayItemValues(XMP.DC_SUBJECT_PROP_NAME)
metadataMap[KEY_XMP_SUBJECTS] = values.joinToString(XMP_SUBJECTS_SEPARATOR)
}
xmpMeta.getSafeLocalizedText(XMP.DC_TITLE_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE] = it }
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
xmpMeta.getSafeDateMillis(XMP.XMP_CREATE_DATE_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it }
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
xmpMeta.getSafeDateMillis(XMP.PS_DATE_CREATED_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it }
}
}
xmpMeta.getSafeInt(XMP.XMP_RATING_PROP_NAME) { metadataMap[KEY_RATING] = it }
if (!metadataMap.containsKey(KEY_RATING)) {
xmpMeta.getSafeInt(XMP.MS_RATING_PROP_NAME) { percentRating ->
// values of 1,25,50,75,99% correspond to 1,2,3,4,5 stars
val standardRating = (percentRating / 25f).roundToInt() + 1
metadataMap[KEY_RATING] = standardRating
}
}
// identification of panorama (aka photo sphere)
if (xmpMeta.isPanorama()) {
flags = flags or MASK_IS_360
}
// identification of motion photo
if (xmpMeta.isMotionPhoto()) {
flags = flags or MASK_IS_MULTIPAGE or MASK_IS_MOTION_PHOTO
}
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
}
}
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = MetadataExtractorHelper.safeRead(input)
val metadata = Helper.safeRead(input)
foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 }
foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
// File type
for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) {
@ -491,7 +547,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
dir.getSafeInt(ExifDirectoryBase.TAG_ORIENTATION) {
val orientation = it
if (isFlippedForExifCode(orientation)) flags = flags or MASK_IS_FLIPPED
if (isFlippedForExifCode(orientation)) {
flags = flags or MASK_IS_FLIPPED
}
metadataMap[KEY_ROTATION_DEGREES] = getRotationDegreesForExifCode(orientation)
}
}
@ -506,47 +564,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
// XMP
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
val xmpMeta = dir.xmpMeta
try {
if (xmpMeta.doesPropertyExist(XMP.DC_SCHEMA_NS, XMP.DC_SUBJECT_PROP_NAME)) {
val count = xmpMeta.countArrayItems(XMP.DC_SCHEMA_NS, XMP.DC_SUBJECT_PROP_NAME)
val values = (1 until count + 1).map { xmpMeta.getArrayItem(XMP.DC_SCHEMA_NS, XMP.DC_SUBJECT_PROP_NAME, it).value }
metadataMap[KEY_XMP_SUBJECTS] = values.joinToString(XMP_SUBJECTS_SEPARATOR)
}
xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.DC_TITLE_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it }
if (!metadataMap.containsKey(KEY_XMP_TITLE_DESCRIPTION)) {
xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.DC_DESCRIPTION_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it }
}
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
xmpMeta.getSafeDateMillis(XMP.XMP_SCHEMA_NS, XMP.XMP_CREATE_DATE_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it }
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
xmpMeta.getSafeDateMillis(XMP.PHOTOSHOP_SCHEMA_NS, XMP.PS_DATE_CREATED_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it }
}
}
xmpMeta.getSafeInt(XMP.XMP_SCHEMA_NS, XMP.XMP_RATING_PROP_NAME) { metadataMap[KEY_RATING] = it }
if (!metadataMap.containsKey(KEY_RATING)) {
xmpMeta.getSafeInt(XMP.MICROSOFTPHOTO_SCHEMA_NS, XMP.MS_RATING_PROP_NAME) { percentRating ->
// values of 1,25,50,75,99% correspond to 1,2,3,4,5 stars
val standardRating = (percentRating / 25f).roundToInt() + 1
metadataMap[KEY_RATING] = standardRating
}
}
// identification of panorama (aka photo sphere)
if (xmpMeta.isPanorama()) {
flags = flags or MASK_IS_360
}
// identification of motion photo
if (xmpMeta.isMotionPhoto()) {
flags = flags or MASK_IS_MULTIPAGE
}
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
}
}
metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp)
// XMP fallback to IPTC
if (!metadataMap.containsKey(KEY_XMP_SUBJECTS)) {
@ -574,7 +592,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
MimeTypes.GIF -> {
// identification of animated GIF
if (metadata.containsDirectoryOfType(GifAnimationDirectory::class.java)) flags = flags or MASK_IS_ANIMATED
if (metadata.containsDirectoryOfType(GifAnimationDirectory::class.java)) {
flags = flags or MASK_IS_ANIMATED
}
}
MimeTypes.WEBP -> {
// identification of animated WEBP
@ -587,7 +607,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
MimeTypes.TIFF -> {
// identification of GeoTIFF
for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
if (dir.containsGeoTiffTags()) flags = flags or MASK_IS_GEOTIFF
if (dir.containsGeoTiffTags()) {
flags = flags or MASK_IS_GEOTIFF
}
}
}
}
@ -634,6 +656,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp)
if (mimeType == MimeTypes.TIFF && MultiPage.isMultiPageTiff(context, uri)) flags = flags or MASK_IS_MULTIPAGE
metadataMap[KEY_FLAGS] = flags
@ -718,7 +742,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = MetadataExtractorHelper.safeRead(input)
val metadata = Helper.safeRead(input)
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
foundExif = true
dir.getSafeRational(ExifDirectoryBase.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it.numerator.toDouble() / it.denominator }
@ -768,7 +792,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = MetadataExtractorHelper.safeRead(input)
val metadata = Helper.safeRead(input)
val fields = HashMap<Int, Any?>()
for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
if (dir.containsGeoTiffTags()) {
@ -801,16 +825,20 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null || sizeBytes == null) {
val isMotionPhoto = call.argument<Boolean>("isMotionPhoto")
if (mimeType == null || uri == null || sizeBytes == null || isMotionPhoto == null) {
result.error("getMultiPageInfo-args", "missing arguments", null)
return
}
val pages: ArrayList<FieldMap>? = when (mimeType) {
MimeTypes.HEIC, MimeTypes.HEIF -> MultiPage.getHeicTracks(context, uri)
MimeTypes.JPEG -> MultiPage.getMotionPhotoPages(context, uri, mimeType, sizeBytes = sizeBytes)
MimeTypes.TIFF -> MultiPage.getTiffPages(context, uri)
else -> null
val pages: ArrayList<FieldMap>? = if (isMotionPhoto) {
MultiPage.getMotionPhotoPages(context, uri, mimeType, sizeBytes = sizeBytes)
} else {
when (mimeType) {
MimeTypes.HEIC, MimeTypes.HEIF -> MultiPage.getHeicTracks(context, uri)
MimeTypes.TIFF -> MultiPage.getTiffPages(context, uri)
else -> null
}
}
if (pages?.isEmpty() == true) {
result.error("getMultiPageInfo-empty", "failed to get pages for mimeType=$mimeType uri=$uri", null)
@ -828,25 +856,29 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
return
}
var foundXmp = false
val fields: FieldMap = hashMapOf()
fun processXmp(xmpMeta: XMPMeta) {
try {
xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_LEFT_PROP_NAME) { fields["croppedAreaLeft"] = it }
xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_TOP_PROP_NAME) { fields["croppedAreaTop"] = it }
xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_WIDTH_PROP_NAME) { fields["croppedAreaWidth"] = it }
xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_HEIGHT_PROP_NAME) { fields["croppedAreaHeight"] = it }
xmpMeta.getSafeInt(XMP.GPANO_FULL_PANO_WIDTH_PROP_NAME) { fields["fullPanoWidth"] = it }
xmpMeta.getSafeInt(XMP.GPANO_FULL_PANO_HEIGHT_PROP_NAME) { fields["fullPanoHeight"] = it }
xmpMeta.getSafeString(XMP.GPANO_PROJECTION_TYPE_PROP_NAME) { fields["projectionType"] = it }
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
}
}
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = MetadataExtractorHelper.safeRead(input)
val fields: FieldMap = hashMapOf(
"projectionType" to XMP.GPANO_PROJECTION_TYPE_DEFAULT,
)
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
val xmpMeta = dir.xmpMeta
xmpMeta.getSafeInt(XMP.GPANO_SCHEMA_NS, XMP.GPANO_CROPPED_AREA_LEFT_PROP_NAME) { fields["croppedAreaLeft"] = it }
xmpMeta.getSafeInt(XMP.GPANO_SCHEMA_NS, XMP.GPANO_CROPPED_AREA_TOP_PROP_NAME) { fields["croppedAreaTop"] = it }
xmpMeta.getSafeInt(XMP.GPANO_SCHEMA_NS, XMP.GPANO_CROPPED_AREA_WIDTH_PROP_NAME) { fields["croppedAreaWidth"] = it }
xmpMeta.getSafeInt(XMP.GPANO_SCHEMA_NS, XMP.GPANO_CROPPED_AREA_HEIGHT_PROP_NAME) { fields["croppedAreaHeight"] = it }
xmpMeta.getSafeInt(XMP.GPANO_SCHEMA_NS, XMP.GPANO_FULL_PANO_WIDTH_PROP_NAME) { fields["fullPanoWidth"] = it }
xmpMeta.getSafeInt(XMP.GPANO_SCHEMA_NS, XMP.GPANO_FULL_PANO_HEIGHT_PROP_NAME) { fields["fullPanoHeight"] = it }
xmpMeta.getSafeString(XMP.GPANO_SCHEMA_NS, XMP.GPANO_PROJECTION_TYPE_PROP_NAME) { fields["projectionType"] = it }
}
result.success(fields)
return
val metadata = Helper.safeRead(input)
foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp)
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
@ -856,7 +888,15 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
}
}
result.error("getPanoramaInfo-empty", "failed to get info for mimeType=$mimeType uri=$uri", null)
XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp)
if (fields.isEmpty()) {
result.error("getPanoramaInfo-empty", "failed to get info for mimeType=$mimeType uri=$uri", null)
} else {
fields["projectionType"] = fields["projectionType"] ?: XMP.GPANO_PROJECTION_TYPE_DEFAULT
result.success(fields)
}
}
private fun getIptc(call: MethodCall, result: MethodChannel.Result) {
@ -892,13 +932,23 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
return
}
var foundXmp = false
val xmpStrings = mutableListOf<String>()
fun processXmp(xmpMeta: XMPMeta) {
try {
xmpStrings.add(XMPMetaFactory.serializeToString(xmpMeta, xmpSerializeOptions))
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
}
}
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = MetadataExtractorHelper.safeRead(input)
val xmpStrings = metadata.getDirectoriesOfType(XmpDirectory::class.java).mapNotNull { XMPMetaFactory.serializeToString(it.xmpMeta, xmpSerializeOptions) }
result.success(xmpStrings.toMutableList())
return
val metadata = Helper.safeRead(input)
foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp)
}
} catch (e: Exception) {
result.error("getXmp-exception", "failed to read XMP for mimeType=$mimeType uri=$uri", e.message)
@ -912,7 +962,13 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
result.success(null)
XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp)
if (xmpStrings.isEmpty()) {
result.success(null)
} else {
result.success(xmpStrings)
}
}
private fun hasContentResolverProp(call: MethodCall, result: MethodChannel.Result) {
@ -942,48 +998,12 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
return
}
var contentUri: Uri = uri
if (StorageUtils.isMediaStoreContentUri(uri)) {
uri.tryParseId()?.let { id ->
contentUri = when {
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
else -> uri
}
contentUri = StorageUtils.getOriginalUri(context, contentUri)
}
}
val projection = arrayOf(prop)
val cursor: Cursor?
try {
cursor = context.contentResolver.query(contentUri, projection, null, null, null)
val value = context.queryContentResolverProp(uri, mimeType, prop)
result.success(value?.toString())
} catch (e: Exception) {
// throws SQLiteException when the requested prop is not a known column
result.error("getContentResolverProp-query", "failed to query for contentUri=$contentUri", e.message)
return
result.error("getContentResolverProp-query", "failed to query prop for uri=$uri", e.message)
}
if (cursor == null || !cursor.moveToFirst()) {
result.error("getContentResolverProp-cursor", "failed to get cursor for contentUri=$contentUri", null)
return
}
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
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get value for key=$prop", e)
}
cursor.close()
result.success(value?.toString())
}
private fun getDate(call: MethodCall, result: MethodChannel.Result) {
@ -1000,7 +1020,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = MetadataExtractorHelper.safeRead(input)
val metadata = Helper.safeRead(input)
val tag = when (field) {
ExifInterface.TAG_DATETIME -> ExifIFD0Directory.TAG_DATETIME
ExifInterface.TAG_DATETIME_DIGITIZED -> ExifSubIFDDirectory.TAG_DATETIME_DIGITIZED
@ -1052,6 +1072,58 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
result.success(dateMillis)
}
// return description from these fields (by precedence):
// - XMP / dc:description
// - IPTC / caption-abstract
// - Exif / ImageDescription
private fun getDescription(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getDescription-args", "missing arguments", null)
return
}
var description: String? = null
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = Helper.safeRead(input)
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
val xmpMeta = dir.xmpMeta
try {
if (xmpMeta.doesPropExist(XMP.DC_DESCRIPTION_PROP_NAME)) {
xmpMeta.getSafeLocalizedText(XMP.DC_DESCRIPTION_PROP_NAME) { description = it }
}
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
}
}
if (description == null) {
for (dir in metadata.getDirectoriesOfType(IptcDirectory::class.java)) {
dir.getSafeString(IptcDirectory.TAG_CAPTION) { description = it }
}
}
if (description == null) {
for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
dir.getSafeString(ExifIFD0Directory.TAG_IMAGE_DESCRIPTION) { description = it }
}
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
} catch (e: AssertionError) {
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
}
}
result.success(description)
}
companion object {
private val LOG_TAG = LogUtils.createTag<MetadataFetchHandler>()
const val CHANNEL = "deckers.thibault/aves/metadata_fetch"
@ -1100,7 +1172,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
private const val KEY_LATITUDE = "latitude"
private const val KEY_LONGITUDE = "longitude"
private const val KEY_XMP_SUBJECTS = "xmpSubjects"
private const val KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription"
private const val KEY_XMP_TITLE = "xmpTitle"
private const val KEY_RATING = "rating"
private const val MASK_IS_ANIMATED = 1 shl 0
@ -1108,6 +1180,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
private const val MASK_IS_GEOTIFF = 1 shl 2
private const val MASK_IS_360 = 1 shl 3
private const val MASK_IS_MULTIPAGE = 1 shl 4
private const val MASK_IS_MOTION_PHOTO = 1 shl 5
private const val XMP_SUBJECTS_SEPARATOR = ";"
// overlay metadata

View file

@ -56,7 +56,7 @@ class ThumbnailFetcher internal constructor(
try {
if (!customFetch && (width == defaultSize || height == defaultSize) && !isFlipped) {
// Fetch low quality thumbnails when size is not specified.
// As of Android R, the Media Store content resolver may return a thumbnail
// As of Android 11, the Media Store content resolver may return a thumbnail
// that is automatically rotated according to EXIF orientation, but not flipped,
// so we skip this step for flipped entries.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@ -108,7 +108,7 @@ class ThumbnailFetcher internal constructor(
} else {
@Suppress("deprecation")
var bitmap = MediaStore.Images.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Images.Thumbnails.MINI_KIND, null)
// from Android Q, returned thumbnail is already rotated according to EXIF orientation
// from Android 10, returned thumbnail is already rotated according to EXIF orientation
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && bitmap != null) {
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
}

View file

@ -3,7 +3,6 @@ package deckers.thibault.aves.channel.calls.window
import android.app.Activity
import android.os.Build
import android.view.WindowManager
import deckers.thibault.aves.utils.LogUtils
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
@ -60,8 +59,4 @@ class ActivityWindowHandler(private val activity: Activity) : WindowHandler(acti
}
result.success(true)
}
companion object {
private val LOG_TAG = LogUtils.createTag<ActivityWindowHandler>()
}
}

View file

@ -88,7 +88,7 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
error("requestMediaFileAccess-unsupported", "media file bulk access is not allowed before Android R", null)
error("requestMediaFileAccess-unsupported", "media file bulk access is not allowed before Android 11", null)
return
}

View file

@ -8,17 +8,25 @@ import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
import android.util.Log
import com.adobe.internal.xmp.XMPMeta
import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.metadata.XMP.countPropArrayItems
import deckers.thibault.aves.metadata.XMP.doesPropExist
import deckers.thibault.aves.metadata.XMP.getSafeLong
import deckers.thibault.aves.metadata.XMP.getSafeStructField
import deckers.thibault.aves.metadata.metadataextractor.Helper
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.indexOfBytes
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import java.io.DataInputStream
object MultiPage {
private val LOG_TAG = LogUtils.createTag<MultiPage>()
private val heicMotionPhotoVideoStartIndicator = byteArrayOf(0x00, 0x00, 0x00, 0x18) + "ftypmp42".toByteArray()
// page info
private const val KEY_MIME_TYPE = "mimeType"
private const val KEY_HEIGHT = "height"
@ -103,9 +111,11 @@ object MultiPage {
)
)
// add video tracks from the appended video
for (i in 0 until extractor.trackCount) {
if (extractor.trackCount > 0) {
// only consider the first track to represent the appended video
val trackIndex = 0
try {
val format = extractor.getTrackFormat(i)
val format = extractor.getTrackFormat(trackIndex)
format.getString(MediaFormat.KEY_MIME)?.let { mime ->
if (MimeTypes.isVideo(mime)) {
val track: FieldMap = hashMapOf(
@ -123,7 +133,7 @@ object MultiPage {
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get motion photo track information for uri=$uri, track num=$i", e)
Log.w(LOG_TAG, "failed to get motion photo track information for uri=$uri, track num=$trackIndex", e)
}
}
}
@ -138,30 +148,53 @@ object MultiPage {
}
fun getMotionPhotoOffset(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Long? {
if (MimeTypes.isHeic(mimeType)) {
// XMP in HEIC motion photos (as taken with a Samsung Camera v12.0.01.50) indicates an `Item:Length` of 68 bytes for the video.
// This item does not contain the video itself, but only some kind of metadata (no doc, no spec),
// so we ignore the `Item:Length` and look instead for the MP4 marker bytes indicating the start of the video.
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val bytes = ByteArray(sizeBytes.toInt())
DataInputStream(input).use {
it.readFully(bytes)
}
val index = bytes.indexOfBytes(heicMotionPhotoVideoStartIndicator)
if (index != -1) {
return sizeBytes - index
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get motion photo offset from uri=$uri", e)
}
}
var offsetFromEnd: Long? = null
var foundXmp = false
fun processXmp(xmpMeta: XMPMeta) {
if (xmpMeta.doesPropExist(XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME)) {
// `GCamera` motion photo
xmpMeta.getSafeLong(XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME) { offsetFromEnd = it }
} else if (xmpMeta.doesPropExist(XMP.CONTAINER_DIRECTORY_PROP_NAME)) {
// `Container` motion photo
val count = xmpMeta.countPropArrayItems(XMP.CONTAINER_DIRECTORY_PROP_NAME)
if (count == 2) {
// expect the video to be the second item
val i = 2
val mime = xmpMeta.getSafeStructField(listOf(XMP.CONTAINER_DIRECTORY_PROP_NAME, i, XMP.CONTAINER_ITEM_PROP_NAME, XMP.CONTAINER_ITEM_MIME_PROP_NAME))?.value
val length = xmpMeta.getSafeStructField(listOf(XMP.CONTAINER_DIRECTORY_PROP_NAME, i, XMP.CONTAINER_ITEM_PROP_NAME, XMP.CONTAINER_ITEM_LENGTH_PROP_NAME))?.value
if (MimeTypes.isVideo(mime) && length != null) {
offsetFromEnd = length.toLong()
}
}
}
}
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = MetadataExtractorHelper.safeRead(input)
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
var offsetFromEnd: Long? = null
val xmpMeta = dir.xmpMeta
if (xmpMeta.doesPropertyExist(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME)) {
// GCamera motion photo
xmpMeta.getSafeLong(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME) { offsetFromEnd = it }
} else if (xmpMeta.doesPropertyExist(XMP.CONTAINER_SCHEMA_NS, XMP.CONTAINER_DIRECTORY_PROP_NAME)) {
// Container motion photo
val count = xmpMeta.countArrayItems(XMP.CONTAINER_SCHEMA_NS, XMP.CONTAINER_DIRECTORY_PROP_NAME)
if (count == 2) {
// expect the video to be the second item
val i = 2
val mime = xmpMeta.getSafeStructField("${XMP.CONTAINER_DIRECTORY_PROP_NAME}[$i]/${XMP.CONTAINER_ITEM_PROP_NAME}/${XMP.CONTAINER_ITEM_MIME_PROP_NAME}")?.value
val length = xmpMeta.getSafeStructField("${XMP.CONTAINER_DIRECTORY_PROP_NAME}[$i]/${XMP.CONTAINER_ITEM_PROP_NAME}/${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}")?.value
if (MimeTypes.isVideo(mime) && length != null) {
offsetFromEnd = length.toLong()
}
}
}
return offsetFromEnd
}
val metadata = Helper.safeRead(input)
foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp)
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get motion photo offset from uri=$uri", e)
@ -170,7 +203,10 @@ object MultiPage {
} catch (e: AssertionError) {
Log.w(LOG_TAG, "failed to get motion photo offset from uri=$uri", e)
}
return null
XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp)
return offsetFromEnd
}
fun getTiffPages(context: Context, uri: Uri): ArrayList<FieldMap> {
@ -214,4 +250,4 @@ object MultiPage {
}
return null
}
}
}

View file

@ -1,12 +1,20 @@
package deckers.thibault.aves.metadata
import android.content.Context
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.Log
import com.adobe.internal.xmp.XMPError
import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.XMPMeta
import com.adobe.internal.xmp.XMPMetaFactory
import com.adobe.internal.xmp.properties.XMPProperty
import deckers.thibault.aves.metadata.metadataextractor.SafeXmpReader
import deckers.thibault.aves.utils.ContextUtils.queryContentResolverProp
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils
import java.util.*
object XMP {
@ -14,74 +22,65 @@ object XMP {
// standard namespaces
// cf com.adobe.internal.xmp.XMPConst
const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/"
const val MICROSOFTPHOTO_SCHEMA_NS = "http://ns.microsoft.com/photo/1.0/"
const val PHOTOSHOP_SCHEMA_NS = "http://ns.adobe.com/photoshop/1.0/"
const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/"
private const val XMP_GIMG_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/g/img/"
private const val DC_NS_URI = "http://purl.org/dc/elements/1.1/"
private const val MICROSOFTPHOTO_NS_URI = "http://ns.microsoft.com/photo/1.0/"
private const val PHOTOSHOP_NS_URI = "http://ns.adobe.com/photoshop/1.0/"
private const val XMP_NS_URI = "http://ns.adobe.com/xap/1.0/"
// other namespaces
private const val GAUDIO_SCHEMA_NS = "http://ns.google.com/photos/1.0/audio/"
const val GCAMERA_SCHEMA_NS = "http://ns.google.com/photos/1.0/camera/"
private const val GDEPTH_SCHEMA_NS = "http://ns.google.com/photos/1.0/depthmap/"
private const val GIMAGE_SCHEMA_NS = "http://ns.google.com/photos/1.0/image/"
const val CONTAINER_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/"
private const val CONTAINER_ITEM_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/item/"
private const val CONTAINER_NS_URI = "http://ns.google.com/photos/1.0/container/"
private const val CONTAINER_ITEM_NS_URI = "http://ns.google.com/photos/1.0/container/item/"
private const val GAUDIO_NS_URI = "http://ns.google.com/photos/1.0/audio/"
private const val GCAMERA_NS_URI = "http://ns.google.com/photos/1.0/camera/"
private const val GDEPTH_NS_URI = "http://ns.google.com/photos/1.0/depthmap/"
private const val GIMAGE_NS_URI = "http://ns.google.com/photos/1.0/image/"
private const val GPANO_NS_URI = "http://ns.google.com/photos/1.0/panorama/"
private const val PMTM_NS_URI = "http://www.hdrsoft.com/photomatix_settings01"
const val DC_DESCRIPTION_PROP_NAME = "dc:description"
const val DC_SUBJECT_PROP_NAME = "dc:subject"
const val DC_TITLE_PROP_NAME = "dc:title"
const val MS_RATING_PROP_NAME = "MicrosoftPhoto:Rating"
const val PS_DATE_CREATED_PROP_NAME = "photoshop:DateCreated"
const val XMP_CREATE_DATE_PROP_NAME = "xmp:CreateDate"
const val XMP_RATING_PROP_NAME = "xmp:Rating"
val DC_SUBJECT_PROP_NAME = XMPPropName(DC_NS_URI, "subject")
val DC_DESCRIPTION_PROP_NAME = XMPPropName(DC_NS_URI, "description")
val DC_TITLE_PROP_NAME = XMPPropName(DC_NS_URI, "title")
val MS_RATING_PROP_NAME = XMPPropName(MICROSOFTPHOTO_NS_URI, "Rating")
val PS_DATE_CREATED_PROP_NAME = XMPPropName(PHOTOSHOP_NS_URI, "DateCreated")
val XMP_CREATE_DATE_PROP_NAME = XMPPropName(XMP_NS_URI, "CreateDate")
val XMP_RATING_PROP_NAME = XMPPropName(XMP_NS_URI, "Rating")
private const val GENERIC_LANG = ""
private const val SPECIFIC_LANG = "en-US"
private val schemas = hashMapOf(
"Container" to CONTAINER_SCHEMA_NS,
"GAudio" to GAUDIO_SCHEMA_NS,
"GDepth" to GDEPTH_SCHEMA_NS,
"GImage" to GIMAGE_SCHEMA_NS,
"Item" to CONTAINER_ITEM_SCHEMA_NS,
"xmp" to XMP_SCHEMA_NS,
"xmpGImg" to XMP_GIMG_SCHEMA_NS,
)
fun namespaceForPropPath(propPath: String) = schemas[propPath.split(":")[0]]
// embedded media data properties
// cf https://developers.google.com/depthmap-metadata
// cf https://developers.google.com/vr/reference/cardboard-camera-vr-photo-format
private val knownDataPaths = listOf("GAudio:Data", "GImage:Data", "GDepth:Data", "GDepth:Confidence")
private val knownDataProps = listOf(
XMPPropName(GAUDIO_NS_URI, "Data"),
XMPPropName(GIMAGE_NS_URI, "Data"),
XMPPropName(GDEPTH_NS_URI, "Data"),
XMPPropName(GDEPTH_NS_URI, "Confidence"),
)
fun isDataPath(path: String) = knownDataPaths.contains(path)
fun isDataPath(path: String) = knownDataProps.map { it.toString() }.any { path == it }
// motion photo
const val GCAMERA_VIDEO_OFFSET_PROP_NAME = "GCamera:MicroVideoOffset"
const val CONTAINER_DIRECTORY_PROP_NAME = "Container:Directory"
const val CONTAINER_ITEM_PROP_NAME = "Container:Item"
const val CONTAINER_ITEM_LENGTH_PROP_NAME = "Item:Length"
const val CONTAINER_ITEM_MIME_PROP_NAME = "Item:Mime"
val GCAMERA_VIDEO_OFFSET_PROP_NAME = XMPPropName(GCAMERA_NS_URI, "MicroVideoOffset")
val CONTAINER_DIRECTORY_PROP_NAME = XMPPropName(CONTAINER_NS_URI, "Directory")
val CONTAINER_ITEM_PROP_NAME = XMPPropName(CONTAINER_NS_URI, "Item")
val CONTAINER_ITEM_LENGTH_PROP_NAME = XMPPropName(CONTAINER_ITEM_NS_URI, "Length")
val CONTAINER_ITEM_MIME_PROP_NAME = XMPPropName(CONTAINER_ITEM_NS_URI, "Mime")
// panorama
// cf https://developers.google.com/streetview/spherical-metadata
const val GPANO_SCHEMA_NS = "http://ns.google.com/photos/1.0/panorama/"
private const val PMTM_SCHEMA_NS = "http://www.hdrsoft.com/photomatix_settings01"
const val GPANO_CROPPED_AREA_HEIGHT_PROP_NAME = "GPano:CroppedAreaImageHeightPixels"
const val GPANO_CROPPED_AREA_WIDTH_PROP_NAME = "GPano:CroppedAreaImageWidthPixels"
const val GPANO_CROPPED_AREA_LEFT_PROP_NAME = "GPano:CroppedAreaLeftPixels"
const val GPANO_CROPPED_AREA_TOP_PROP_NAME = "GPano:CroppedAreaTopPixels"
const val GPANO_FULL_PANO_HEIGHT_PROP_NAME = "GPano:FullPanoHeightPixels"
const val GPANO_FULL_PANO_WIDTH_PROP_NAME = "GPano:FullPanoWidthPixels"
const val GPANO_PROJECTION_TYPE_PROP_NAME = "GPano:ProjectionType"
val GPANO_CROPPED_AREA_HEIGHT_PROP_NAME = XMPPropName(GPANO_NS_URI, "CroppedAreaImageHeightPixels")
val GPANO_CROPPED_AREA_WIDTH_PROP_NAME = XMPPropName(GPANO_NS_URI, "CroppedAreaImageWidthPixels")
val GPANO_CROPPED_AREA_LEFT_PROP_NAME = XMPPropName(GPANO_NS_URI, "CroppedAreaLeftPixels")
val GPANO_CROPPED_AREA_TOP_PROP_NAME = XMPPropName(GPANO_NS_URI, "CroppedAreaTopPixels")
val GPANO_FULL_PANO_HEIGHT_PROP_NAME = XMPPropName(GPANO_NS_URI, "FullPanoHeightPixels")
val GPANO_FULL_PANO_WIDTH_PROP_NAME = XMPPropName(GPANO_NS_URI, "FullPanoWidthPixels")
val GPANO_PROJECTION_TYPE_PROP_NAME = XMPPropName(GPANO_NS_URI, "ProjectionType")
const val GPANO_PROJECTION_TYPE_DEFAULT = "equirectangular"
private const val PMTM_IS_PANO360 = "pmtm:IsPano360"
private val PMTM_IS_PANO360_PROP_NAME = XMPPropName(PMTM_NS_URI, "IsPano360")
// `GPano:ProjectionType` is required by spec but it is sometimes missing, assuming default
// `GPano:FullPanoHeightPixels` is required by spec but it is sometimes missing (e.g. Samsung Camera app panorama mode)
@ -93,22 +92,38 @@ object XMP {
GPANO_FULL_PANO_WIDTH_PROP_NAME,
)
// as of `metadata-extractor` v2.18.0, XMP is not discovered in HEIC images,
// so we fall back to the native content resolver, if possible
fun checkHeic(context: Context, uri: Uri, mimeType: String, foundXmp: Boolean, processXmp: (xmpMeta: XMPMeta) -> Unit) {
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)
if (xmpBytes is ByteArray) {
val xmpMeta = XMPMetaFactory.parseFromBuffer(xmpBytes, SafeXmpReader.PARSE_OPTIONS)
processXmp(xmpMeta)
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get XMP by content resolver for mimeType=$mimeType uri=$uri", e)
}
}
}
// extensions
fun XMPMeta.isMotionPhoto(): Boolean {
try {
// GCamera motion photo
if (doesPropertyExist(GCAMERA_SCHEMA_NS, GCAMERA_VIDEO_OFFSET_PROP_NAME)) return true
if (doesPropExist(GCAMERA_VIDEO_OFFSET_PROP_NAME)) return true
// Container motion photo
if (doesPropertyExist(CONTAINER_SCHEMA_NS, CONTAINER_DIRECTORY_PROP_NAME)) {
val count = countArrayItems(CONTAINER_SCHEMA_NS, CONTAINER_DIRECTORY_PROP_NAME)
if (doesPropExist(CONTAINER_DIRECTORY_PROP_NAME)) {
val count = countPropArrayItems(CONTAINER_DIRECTORY_PROP_NAME)
if (count == 2) {
var hasImage = false
var hasVideo = false
for (i in 1 until count + 1) {
val mime = getSafeStructField("$CONTAINER_DIRECTORY_PROP_NAME[$i]/$CONTAINER_ITEM_PROP_NAME/$CONTAINER_ITEM_MIME_PROP_NAME")?.value
val length = getSafeStructField("$CONTAINER_DIRECTORY_PROP_NAME[$i]/$CONTAINER_ITEM_PROP_NAME/$CONTAINER_ITEM_LENGTH_PROP_NAME")?.value
val mime = getSafeStructField(listOf(CONTAINER_DIRECTORY_PROP_NAME, i, CONTAINER_ITEM_PROP_NAME, CONTAINER_ITEM_MIME_PROP_NAME))?.value
val length = getSafeStructField(listOf(CONTAINER_DIRECTORY_PROP_NAME, i, CONTAINER_ITEM_PROP_NAME, CONTAINER_ITEM_LENGTH_PROP_NAME))?.value
hasImage = hasImage || MimeTypes.isImage(mime) && length != null
hasVideo = hasVideo || MimeTypes.isVideo(mime) && length != null
}
@ -130,7 +145,7 @@ object XMP {
fun XMPMeta.isPanorama(): Boolean {
// Google
try {
if (gpanoRequiredProps.all { doesPropertyExist(GPANO_SCHEMA_NS, it) }) return true
if (gpanoRequiredProps.all { doesPropExist(it) }) return true
} catch (e: XMPException) {
if (e.errorCode != XMPError.BADSCHEMA) {
// `BADSCHEMA` code is reported when we check a property
@ -141,7 +156,7 @@ object XMP {
// Photomatix
try {
if (getPropertyString(PMTM_SCHEMA_NS, PMTM_IS_PANO360) == "Yes") return true
if (getPropertyString(PMTM_IS_PANO360_PROP_NAME.nsUri, PMTM_IS_PANO360_PROP_NAME.toString()) == "Yes") return true
} catch (e: XMPException) {
if (e.errorCode != XMPError.BADSCHEMA) {
// `BADSCHEMA` code is reported when we check a property
@ -153,7 +168,24 @@ object XMP {
return false
}
fun XMPMeta.getSafeInt(schema: String, propName: String, save: (value: Int) -> Unit) {
fun XMPMeta.doesPropExist(prop: XMPPropName): Boolean {
return doesPropertyExist(prop.nsUri, prop.toString())
}
fun XMPMeta.countPropArrayItems(prop: XMPPropName): Int {
return countArrayItems(prop.nsUri, prop.toString())
}
fun XMPMeta.getPropArrayItemValues(prop: XMPPropName): List<String> {
val schema = prop.nsUri
val propName = prop.toString()
val count = countArrayItems(schema, propName)
return (1 until count + 1).map { getArrayItem(schema, propName, it).value }
}
fun XMPMeta.getSafeInt(prop: XMPPropName, save: (value: Int) -> Unit) {
val schema = prop.nsUri
val propName = prop.toString()
try {
if (doesPropertyExist(schema, propName)) {
val item = getPropertyInteger(schema, propName)
@ -167,7 +199,9 @@ object XMP {
}
}
fun XMPMeta.getSafeLong(schema: String, propName: String, save: (value: Long) -> Unit) {
fun XMPMeta.getSafeLong(prop: XMPPropName, save: (value: Long) -> Unit) {
val schema = prop.nsUri
val propName = prop.toString()
try {
if (doesPropertyExist(schema, propName)) {
val item = getPropertyLong(schema, propName)
@ -181,7 +215,9 @@ object XMP {
}
}
fun XMPMeta.getSafeString(schema: String, propName: String, save: (value: String) -> Unit) {
fun XMPMeta.getSafeString(prop: XMPPropName, save: (value: String) -> Unit) {
val schema = prop.nsUri
val propName = prop.toString()
try {
if (doesPropertyExist(schema, propName)) {
val item = getPropertyString(schema, propName)
@ -195,7 +231,9 @@ object XMP {
}
}
fun XMPMeta.getSafeLocalizedText(schema: String, propName: String, acceptBlank: Boolean = true, save: (value: String) -> Unit) {
fun XMPMeta.getSafeLocalizedText(prop: XMPPropName, acceptBlank: Boolean = true, save: (value: String) -> Unit) {
val schema = prop.nsUri
val propName = prop.toString()
try {
if (doesPropertyExist(schema, propName)) {
val item = getLocalizedText(schema, propName, GENERIC_LANG, SPECIFIC_LANG)
@ -209,7 +247,9 @@ object XMP {
}
}
fun XMPMeta.getSafeDateMillis(schema: String, propName: String, save: (value: Long) -> Unit) {
fun XMPMeta.getSafeDateMillis(prop: XMPPropName, save: (value: Long) -> Unit) {
val schema = prop.nsUri
val propName = prop.toString()
try {
if (doesPropertyExist(schema, propName)) {
val item = getPropertyDate(schema, propName)
@ -226,20 +266,38 @@ object XMP {
}
}
// e.g. 'Container:Directory[42]/Container:Item/Item:Mime'
fun XMPMeta.getSafeStructField(path: String): XMPProperty? {
val separator = path.lastIndexOf("/")
if (separator != -1) {
val structName = path.substring(0, separator)
val structNs = namespaceForPropPath(structName)
val fieldName = path.substring(separator + 1)
val fieldNs = namespaceForPropPath(fieldName)
try {
return getStructField(structNs, structName, fieldNs, fieldName)
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to get XMP struct field for path=$path", e)
// e.g. path 'Container:Directory[42]/Container:Item/Item:Mime' matches:
// - structNs: "http://ns.google.com/photos/1.0/container/"
// - structName: "Container:Directory[42]/Container:Item"
// - fieldNs: "http://ns.google.com/photos/1.0/container/item/"
// - fieldName: "Item:Mime"
fun XMPMeta.getSafeStructField(props: List<Any>): XMPProperty? {
if (props.size >= 2) {
val structFirst = props.first()
val field = props.last()
if (structFirst is XMPPropName && field is XMPPropName) {
val structName = props.take(props.size - 1).mapIndexed { index, prop ->
when (prop) {
is XMPPropName -> "${if (index == 0) "" else "/"}$prop"
is Int -> "[$prop]"
else -> null
}
}.filterNotNull().joinToString("")
val fieldName = field.toString()
try {
return getStructField(structFirst.nsUri, structName, field.nsUri, fieldName)
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to get XMP struct field for props=$props", e)
}
}
}
return null
}
}
}
class XMPPropName(val nsUri: String, private val prop: String) {
private fun resolve(): String = "${XMPMetaFactory.getSchemaRegistry().getNamespacePrefix(nsUri)}$prop"
override fun toString(): String = resolve()
}

View file

@ -1,12 +1,17 @@
package deckers.thibault.aves.metadata
package deckers.thibault.aves.metadata.metadataextractor
import android.util.Log
import com.drew.imaging.FileType
import com.drew.imaging.FileTypeDetector
import com.drew.imaging.ImageMetadataReader
import com.drew.imaging.ImageProcessingException
import com.drew.imaging.jpeg.JpegMetadataReader
import com.drew.imaging.jpeg.JpegSegmentMetadataReader
import com.drew.imaging.mp4.Mp4Reader
import com.drew.imaging.tiff.TiffProcessingException
import com.drew.imaging.tiff.TiffReader
import com.drew.lang.ByteArrayReader
import com.drew.lang.RandomAccessStreamReader
import com.drew.lang.Rational
import com.drew.lang.SequentialByteArrayReader
import com.drew.metadata.Directory
@ -19,14 +24,18 @@ import com.drew.metadata.file.FileTypeDirectory
import com.drew.metadata.iptc.IptcReader
import com.drew.metadata.png.PngDirectory
import com.drew.metadata.xmp.XmpReader
import deckers.thibault.aves.metadata.ExifGeoTiffTags
import deckers.thibault.aves.metadata.GeoTiffKeys
import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.utils.LogUtils
import java.io.BufferedInputStream
import java.io.IOException
import java.io.InputStream
import java.text.SimpleDateFormat
import java.util.*
object MetadataExtractorHelper {
private val LOG_TAG = LogUtils.createTag<MetadataExtractorHelper>()
object Helper {
private val LOG_TAG = LogUtils.createTag<Helper>()
const val PNG_ITXT_DIR_NAME = "PNG-iTXt"
private const val PNG_TEXT_DIR_NAME = "PNG-tEXt"
@ -43,33 +52,42 @@ object MetadataExtractorHelper {
// e.g. "exif [...] 134 [...] 4578696600004949[...]"
private val PNG_RAW_PROFILE_PATTERN = Regex("^\\n(.*?)\\n\\s*(\\d+)\\n(.*)", RegexOption.DOT_MATCHES_ALL)
// providing the stream length is risky, as it may crash if it is incorrect
private const val safeReadStreamLength = -1L
fun readMimeType(input: InputStream): String? {
val bufferedInputStream = if (input is BufferedInputStream) input else BufferedInputStream(input)
return FileTypeDetector.detectFileType(bufferedInputStream).mimeType
}
@Throws(IOException::class, ImageProcessingException::class)
fun safeRead(input: InputStream): com.drew.metadata.Metadata {
val bufferedInputStream = if (input is BufferedInputStream) input else BufferedInputStream(input)
val fileType = FileTypeDetector.detectFileType(bufferedInputStream)
val inputStream = if (input is BufferedInputStream) input else BufferedInputStream(input)
val fileType = FileTypeDetector.detectFileType(inputStream)
val metadata = if (fileType == FileType.Jpeg) {
safeReadJpeg(bufferedInputStream)
} else {
// providing the stream length is risky, as it may crash if it is incorrect
ImageMetadataReader.readMetadata(bufferedInputStream, -1L, fileType)
val metadata = when (fileType) {
FileType.Jpeg -> safeReadJpeg(inputStream)
FileType.Tiff,
FileType.Arw,
FileType.Cr2,
FileType.Nef,
FileType.Orf,
FileType.Rw2 -> safeReadTiff(inputStream)
FileType.Mp4 -> safeReadMp4(inputStream)
else -> ImageMetadataReader.readMetadata(inputStream, safeReadStreamLength, fileType)
}
metadata.addDirectory(FileTypeDirectory(fileType))
return metadata
}
// Some JPEG (and other types?) contain XMP with a preposterous number of `DocumentAncestors`.
// Some JPEG, TIFF, MP4 (and other types?) contain XMP with a preposterous number of `DocumentAncestors`.
// This bloated XMP is unsafely loaded in memory by Adobe's `XMPMetaParser.parseInputSource`
// which easily yields OOM on Android, so we try to detect and strip extended XMP with a modified XMP reader.
private fun safeReadJpeg(input: InputStream): com.drew.metadata.Metadata {
val readers = ArrayList<JpegSegmentMetadataReader>().apply {
addAll(JpegMetadataReader.ALL_READERS.filter { it !is XmpReader })
add(MetadataExtractorSafeXmpReader())
add(SafeXmpReader())
}
val metadata = com.drew.metadata.Metadata()
@ -77,6 +95,21 @@ object MetadataExtractorHelper {
return metadata
}
@Throws(IOException::class, TiffProcessingException::class)
fun safeReadTiff(input: InputStream): com.drew.metadata.Metadata {
val reader = RandomAccessStreamReader(input, RandomAccessStreamReader.DEFAULT_CHUNK_LENGTH, safeReadStreamLength)
val metadata = com.drew.metadata.Metadata()
val handler = SafeExifTiffHandler(metadata, null)
TiffReader().processTiff(reader, handler, 0)
return metadata
}
private fun safeReadMp4(input: InputStream): com.drew.metadata.Metadata {
val metadata = com.drew.metadata.Metadata()
Mp4Reader.extract(input, SafeMp4BoxHandler(metadata))
return metadata
}
// extensions
fun Directory.getSafeString(tag: Int, save: (value: String) -> Unit) {

View file

@ -0,0 +1,28 @@
package deckers.thibault.aves.metadata.metadataextractor
import com.drew.lang.RandomAccessReader
import com.drew.metadata.Directory
import com.drew.metadata.Metadata
import com.drew.metadata.exif.ExifIFD0Directory
import com.drew.metadata.exif.ExifSubIFDDirectory
import com.drew.metadata.exif.ExifTiffHandler
import java.io.IOException
class SafeExifTiffHandler(metadata: Metadata, parentDirectory: Directory?) : ExifTiffHandler(metadata, parentDirectory) {
@Throws(IOException::class)
override fun customProcessTag(
tagOffset: Int,
processedIfdOffsets: MutableSet<Int>?,
tiffHeaderOffset: Int,
reader: RandomAccessReader?,
tagId: Int,
byteCount: Int,
): Boolean {
if (tagId == ExifSubIFDDirectory.TAG_APPLICATION_NOTES && (_currentDirectory is ExifIFD0Directory || _currentDirectory is ExifSubIFDDirectory)) {
SafeXmpReader().extract(reader!!.getNullTerminatedBytes(tagOffset, byteCount), _metadata, _currentDirectory)
return true
}
return super.customProcessTag(tagOffset, processedIfdOffsets, tiffHeaderOffset, reader, tagId, byteCount)
}
}

View file

@ -0,0 +1,22 @@
package deckers.thibault.aves.metadata.metadataextractor
import com.drew.imaging.mp4.Mp4Handler
import com.drew.lang.annotations.NotNull
import com.drew.lang.annotations.Nullable
import com.drew.metadata.Metadata
import com.drew.metadata.mp4.Mp4BoxHandler
import com.drew.metadata.mp4.Mp4BoxTypes
import com.drew.metadata.mp4.Mp4Context
import java.io.IOException
class SafeMp4BoxHandler(metadata: Metadata) : Mp4BoxHandler(metadata) {
@Throws(IOException::class)
override fun processBox(@NotNull type: String, @Nullable payload: ByteArray?, boxSize: Long, context: Mp4Context?): Mp4Handler<*>? {
if (payload != null && type == Mp4BoxTypes.BOX_USER_DEFINED) {
val userBoxHandler = SafeMp4UuidBoxHandler(metadata)
userBoxHandler.processBox(type, payload, boxSize, context)
return this
}
return super.processBox(type, payload, boxSize, context)
}
}

View file

@ -0,0 +1,23 @@
package deckers.thibault.aves.metadata.metadataextractor
import com.drew.imaging.mp4.Mp4Handler
import com.drew.metadata.Metadata
import com.drew.metadata.mp4.Mp4Context
import com.drew.metadata.mp4.media.Mp4UuidBoxHandler
class SafeMp4UuidBoxHandler(metadata: Metadata) : Mp4UuidBoxHandler(metadata) {
override fun processBox(type: String?, payload: ByteArray?, boxSize: Long, context: Mp4Context?): Mp4Handler<*> {
if (payload != null && payload.size >= 16) {
val payloadUuid = payload.copyOfRange(0, 16)
if (payloadUuid.contentEquals(xmpUuid)) {
SafeXmpReader().extract(payload, 16, payload.size - 16, metadata, directory)
return this
}
}
return super.processBox(type, payload, boxSize, context)
}
companion object {
val xmpUuid = byteArrayOf(0xbe.toByte(), 0x7a, 0xcf.toByte(), 0xcb.toByte(), 0x97.toByte(), 0xa9.toByte(), 0x42, 0xe8.toByte(), 0x9c.toByte(), 0x71, 0x99.toByte(), 0x94.toByte(), 0x91.toByte(), 0xe3.toByte(), 0xaf.toByte(), 0xac.toByte())
}
}

View file

@ -1,4 +1,4 @@
package deckers.thibault.aves.metadata
package deckers.thibault.aves.metadata.metadataextractor
import android.util.Log
import com.adobe.internal.xmp.XMPException
@ -19,7 +19,7 @@ import com.drew.metadata.xmp.XmpReader
import deckers.thibault.aves.utils.LogUtils
import java.io.IOException
class MetadataExtractorSafeXmpReader : XmpReader() {
class SafeXmpReader : XmpReader() {
// adapted from `XmpReader` to detect and skip large extended XMP
override fun readJpegSegments(segments: Iterable<ByteArray>, metadata: Metadata, segmentType: JpegSegmentType) {
val preambleLength = XMP_JPEG_PREAMBLE.length
@ -132,13 +132,13 @@ class MetadataExtractorSafeXmpReader : XmpReader() {
}
companion object {
private val LOG_TAG = LogUtils.createTag<MetadataExtractorSafeXmpReader>()
private val LOG_TAG = LogUtils.createTag<SafeXmpReader>()
// arbitrary size to detect extended XMP that may yield an OOM
private const val segmentTypeSizeDangerThreshold = 3 * (1 shl 20) // MB
// tighter node limits for faster loading
private val PARSE_OPTIONS = ParseOptions().setXMPNodesToLimit(
val PARSE_OPTIONS: ParseOptions = ParseOptions().setXMPNodesToLimit(
mapOf(
"photoshop:DocumentAncestors" to 200,
"xmpMM:History" to 200,

View file

@ -22,10 +22,10 @@ import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeLong
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeString
import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
import deckers.thibault.aves.metadata.MetadataExtractorHelper
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeLong
import deckers.thibault.aves.metadata.metadataextractor.Helper
import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeDateMillis
import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeInt
import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeLong
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.UriUtils.tryParseId
@ -161,7 +161,7 @@ class SourceEntry {
try {
Metadata.openSafeInputStream(context, uri, sourceMimeType, sizeBytes)?.use { input ->
val metadata = MetadataExtractorHelper.safeRead(input)
val metadata = Helper.safeRead(input)
// do not switch on specific MIME types, as the reported MIME type could be wrong
// (e.g. PNG registered as JPG)

View file

@ -6,7 +6,7 @@ import android.provider.MediaStore
import android.provider.OpenableColumns
import android.util.Log
import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.metadata.MetadataExtractorHelper
import deckers.thibault.aves.metadata.metadataextractor.Helper
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.SourceEntry
import deckers.thibault.aves.utils.LogUtils
@ -22,7 +22,7 @@ internal class ContentImageProvider : ImageProvider() {
StorageUtils.openInputStream(context, safeUri)?.use { input ->
// `metadata-extractor` is the most reliable, except for `tiff` (false positives, false negatives)
// cf https://github.com/drewnoakes/metadata-extractor/issues/296
MetadataExtractorHelper.readMimeType(input)?.takeIf { it != MimeTypes.TIFF }?.let {
Helper.readMimeType(input)?.takeIf { it != MimeTypes.TIFF }?.let {
extractorMimeType = it
if (extractorMimeType != sourceMimeType) {
Log.d(LOG_TAG, "source MIME type is $sourceMimeType but extracted MIME type is $extractorMimeType for uri=$uri")

View file

@ -70,7 +70,7 @@ abstract class ImageProvider {
callback.onFailure(UnsupportedOperationException("`renameMultiple` is not supported by this image provider"))
}
open fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap<String, Any?>, callback: ImageOpCallback) {
open fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: FieldMap, callback: ImageOpCallback) {
throw UnsupportedOperationException("`scanPostMetadataEdit` is not supported by this image provider")
}
@ -684,7 +684,7 @@ abstract class ImageProvider {
op: ExifOrientationOp,
callback: ImageOpCallback,
) {
val newFields = HashMap<String, Any?>()
val newFields: FieldMap = hashMapOf()
val success = editExif(context, path, uri, mimeType, callback) { exif ->
// when the orientation is not defined, it returns `undefined (0)` instead of the orientation default value `normal (1)`
@ -909,7 +909,7 @@ abstract class ImageProvider {
}
}
val newFields = HashMap<String, Any?>()
val newFields: FieldMap = hashMapOf()
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
}
@ -961,7 +961,7 @@ abstract class ImageProvider {
return
}
val newFields = HashMap<String, Any?>()
val newFields: FieldMap = hashMapOf()
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
}
@ -1008,7 +1008,7 @@ abstract class ImageProvider {
return
}
val newFields = HashMap<String, Any?>()
val newFields: FieldMap = hashMapOf()
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
}

View file

@ -193,7 +193,7 @@ class MediaStoreImageProvider : ImageProvider() {
val dateModifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
val dateTakenColumn = cursor.getColumnIndex(MediaColumns.DATE_TAKEN)
// image & video for API >= Q, only for images for API < Q
// image & video for API >=29, only for images for API <29
val orientationColumn = cursor.getColumnIndex(MediaColumns.ORIENTATION)
// video only
@ -347,7 +347,7 @@ class MediaStoreImageProvider : ImageProvider() {
}
} catch (securityException: SecurityException) {
// even if the app has access permission granted on the containing directory,
// the delete request may yield a `RecoverableSecurityException` on Android 10+
// the delete request may yield a `RecoverableSecurityException` on Android >=10
// when the underlying file no longer exists and this is an orphaned entry in the Media Store
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && contextWrapper is Activity) {
Log.w(LOG_TAG, "caught a security exception when attempting to delete uri=$uri", securityException)
@ -726,7 +726,7 @@ class MediaStoreImageProvider : ImageProvider() {
return scanNewPath(activity, newFile.path, mimeType)
}
override fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap<String, Any?>, callback: ImageOpCallback) {
override fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: FieldMap, callback: ImageOpCallback) {
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, _ ->
val projection = arrayOf(
MediaStore.MediaColumns.DATE_MODIFIED,
@ -876,7 +876,7 @@ class MediaStoreImageProvider : ImageProvider() {
private val VIDEO_PROJECTION = arrayOf(
*BASE_PROJECTION,
MediaColumns.DURATION,
// `ORIENTATION` was only available for images before Android Q
// `ORIENTATION` was only available for images before Android 10
*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) arrayOf(
MediaStore.MediaColumns.ORIENTATION,
) else emptyArray()

View file

@ -2,7 +2,7 @@ package deckers.thibault.aves.utils
import android.os.Build
// compatibility extension for `removeIf` for API < N
// compatibility extension for `removeIf` for API <24
fun <E> MutableList<E>.compatRemoveIf(filter: (t: E) -> Boolean): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
this.removeIf(filter)
@ -17,4 +17,27 @@ fun <E> MutableList<E>.compatRemoveIf(filter: (t: E) -> Boolean): Boolean {
}
return removed
}
}
}
// Boyer-Moore algorithm for pattern searching
fun ByteArray.indexOfBytes(pattern: ByteArray): Int {
val n: Int = this.size
val m: Int = pattern.size
val badChar = Array(256) { 0 }
var i = 0
while (i < m) {
badChar[pattern[i].toUByte().toInt()] = i
i += 1
}
var j: Int = m - 1
var s = 0
while (s <= (n - m)) {
while (j >= 0 && pattern[j] == this[s + j]) {
j -= 1
}
if (j < 0) return s
s += Integer.max(1, j - badChar[this[s + j].toUByte().toInt()])
j = m - 1
}
return -1
}

View file

@ -3,10 +3,17 @@ package deckers.thibault.aves.utils
import android.app.ActivityManager
import android.app.Service
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.provider.MediaStore
import android.util.Log
import deckers.thibault.aves.utils.UriUtils.tryParseId
object ContextUtils {
private val LOG_TAG = LogUtils.createTag<ContextUtils>()
fun Context.resourceUri(resourceId: Int): Uri = with(resources) {
Uri.Builder()
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
@ -22,4 +29,40 @@ object ContextUtils {
@Suppress("deprecation")
return am.getRunningServices(Integer.MAX_VALUE).any { it.service.className == serviceClass.name }
}
fun Context.queryContentResolverProp(uri: Uri, mimeType: String, prop: String): Any? {
var contentUri: Uri = uri
if (StorageUtils.isMediaStoreContentUri(uri)) {
uri.tryParseId()?.let { id ->
contentUri = when {
MimeTypes.isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
MimeTypes.isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
else -> uri
}
contentUri = StorageUtils.getOriginalUri(this, contentUri)
}
}
// 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
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get value for contentUri=$contentUri key=$prop", e)
}
cursor.close()
return value
}
}

View file

@ -98,7 +98,7 @@ object PermissionManager {
segments.volumePath?.let { volumePath ->
val dirSet = dirsPerVolume[volumePath] ?: HashSet()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// request primary directory on volume from Android R
// request primary directory on volume from Android 11
val relativeDir = segments.relativeDir
if (relativeDir != null) {
val dirSegments = relativeDir.split(File.separator).takeWhile { it.isNotEmpty() }
@ -111,11 +111,11 @@ object PermissionManager {
}
} else {
// the requested path is the volume root itself
// which cannot be granted, due to Android R restrictions
// which cannot be granted, due to Android 11 restrictions
dirSet.add("")
}
} else {
// request volume root until Android Q
// request volume root until Android 10
dirSet.add("")
}
dirsPerVolume[volumePath] = dirSet
@ -236,7 +236,7 @@ object PermissionManager {
return dirs
}
// As of Android R, `MediaStore.getDocumentUri` fails if any of the persisted
// As of Android 11, `MediaStore.getDocumentUri` fails if any of the persisted
// URI permissions we hold points to a folder that no longer exists,
// so we should remove these obsolete URIs before proceeding.
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)

View file

@ -82,7 +82,7 @@ object StorageUtils {
}
fun getVolumePaths(context: Context): Array<String> {
if (mStorageVolumePaths == null) {
if (mStorageVolumePaths == null || mStorageVolumePaths!!.isEmpty()) {
mStorageVolumePaths = findVolumePaths(context)
}
return mStorageVolumePaths!!
@ -162,22 +162,27 @@ object StorageUtils {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
lateinit var files: List<File>
var validFiles: Boolean
val retryInterval = 100L
val maxDelay = 1000L
var totalDelay = 0L
do {
// `getExternalFilesDirs` sometimes include `null` when called right after getting read access
// (e.g. on API 30 emulator) so we retry until the file system is ready.
// TODO TLAD It can also include `null` when there is a faulty SD card.
// It can also include `null` when there is a faulty SD card.
val externalFilesDirs = context.getExternalFilesDirs(null)
validFiles = !externalFilesDirs.contains(null)
if (validFiles) {
files = externalFilesDirs.filterNotNull()
} else {
Log.d(LOG_TAG, "External files dirs contain `null`. Retrying...")
totalDelay += retryInterval
try {
Thread.sleep(100)
Thread.sleep(retryInterval)
} catch (e: InterruptedException) {
Log.e(LOG_TAG, "insomnia", e)
}
}
} while (!validFiles)
} while (!validFiles && totalDelay < maxDelay)
paths.addAll(files.mapNotNull(::appSpecificVolumePath))
} else {
// Primary physical SD-CARD (not emulated)
@ -468,7 +473,7 @@ object StorageUtils {
fun requireAccessPermission(context: Context, anyPath: String): Boolean {
if (isAppFile(context, anyPath)) return false
// on Android R, we should always require access permission, even on primary volume
// on Android 11, we should always require access permission, even on primary volume
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) return true
val onPrimaryVolume = anyPath.startsWith(getPrimaryVolumePath(context))
@ -487,7 +492,7 @@ object StorageUtils {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) {
val path = uri.path
path ?: return uri
// from Android R, accessing the original URI for a `file` or `downloads` media content yields a `SecurityException`
// from Android 11, accessing the original URI for a `file` or `downloads` media content yields a `SecurityException`
if (path.startsWith("/external/images/") || path.startsWith("/external/video/")) {
// "Caller must hold ACCESS_MEDIA_LOCATION permission to access original"
if (context.checkSelfPermission(Manifest.permission.ACCESS_MEDIA_LOCATION) == PackageManager.PERMISSION_GRANTED) {
@ -499,7 +504,7 @@ object StorageUtils {
}
// As of Glide v4.12.0, a special loader `QMediaStoreUriLoader` is automatically used
// to work around a bug from Android Q where metadata redaction corrupts HEIC images.
// to work around a bug from Android 10 where metadata redaction corrupts HEIC images.
// This loader relies on `MediaStore.setRequireOriginal` but this yields a `SecurityException`
// for some non image/video content URIs (e.g. `downloads`, `file`)
fun getGlideSafeUri(context: Context, uri: Uri, mimeType: String): Uri {
@ -594,7 +599,7 @@ object StorageUtils {
val effectiveUri = getOriginalUri(context, uri)
return try {
MediaMetadataRetriever().apply {
// on Android S preview, setting the data source works but yields an internal IOException
// on Android 12 preview, setting the data source works but yields an internal IOException
// (`Input file descriptor already original`), whether we provide the original URI or not
setDataSource(context, effectiveUri)
}

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="app_widget_label">Marco de foto</string>
<string name="wallpaper">Fondo de pantalla</string>
<string name="search_shortcut_short_label">Búsqueda</string>
<string name="videos_shortcut_short_label">Videos</string>

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="app_widget_label">Bingkai Foto</string>
<string name="wallpaper">Wallpaper</string>
<string name="search_shortcut_short_label">Cari</string>
<string name="videos_shortcut_short_label">Video</string>

View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<bool name="translucentNavBar">false</bool>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
<!-- API28+, draws next to the notch in fullscreen -->
<item name="android:windowLayoutInDisplayCutoutMode" tools:targetApi="28">shortEdges</item>
</style>
</resources>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="app_widget_label">Foto Lijstje</string>
<string name="wallpaper">Achtergrond</string>
<string name="search_shortcut_short_label">Zoeken</string>
<string name="videos_shortcut_short_label">Videos</string>
<string name="analysis_channel_name">Media indexeren</string>
<string name="analysis_service_description">Indexeren van afdbeeldingen &amp; videos</string>
<string name="analysis_notification_default_title">Indexeren van media</string>
<string name="analysis_notification_action_stop">Stop</string>
</resources>

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="app_widget_label">Фоторамка</string>
<string name="wallpaper">Обои</string>
<string name="search_shortcut_short_label">Поиск</string>
<string name="videos_shortcut_short_label">Видео</string>

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="app_widget_label">Fotoğraf Çerçevesi</string>
<string name="wallpaper">Duvar kağıdı</string>
<string name="search_shortcut_short_label">Arama</string>
<string name="videos_shortcut_short_label">Videolar</string>

View file

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowTranslucentNavigation">@bool/translucentNavBar</item> <!-- API19+, tinted background & request the SYSTEM_UI_FLAG_LAYOUT_STABLE and SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN flags -->
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item> <!-- API28+, draws next to the notch in fullscreen -->
</style>
</resources>

View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<bool name="translucentNavBar">true</bool>
</resources>

View file

@ -1,6 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowTranslucentNavigation">@bool/translucentNavBar</item> <!-- API19+, tinted background & request the SYSTEM_UI_FLAG_LAYOUT_STABLE and SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN flags -->
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
<!-- API28+, draws next to the notch in fullscreen -->
<item name="android:windowLayoutInDisplayCutoutMode" tools:targetApi="28">shortEdges</item>
</style>
</resources>

View file

@ -7,7 +7,7 @@ buildscript {
maven { url 'https://developer.huawei.com/repo/' }
}
dependencies {
classpath 'com.android.tools.build:gradle:7.2.1'
classpath 'com.android.tools.build:gradle:7.2.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// GMS & Firebase Crashlytics (used by some flavors only)
classpath 'com.google.gms:google-services:4.3.13'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 494 KiB

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 KiB

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

View file

@ -0,0 +1,5 @@
In v1.6.12:
- play your HEIC motion photos
- find recently downloaded images with the `recently added` filter
- enjoy the app in Dutch
Full changelog available on GitHub

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 494 KiB

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 339 KiB

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 494 KiB

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 KiB

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 494 KiB

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 KiB

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 494 KiB

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 KiB

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 494 KiB

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 337 KiB

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 494 KiB

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 496 KiB

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 337 KiB

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Some files were not shown because too many files have changed in this diff Show more