Merge branch 'develop'
3
.github/FUNDING.yml
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
github: deckerst
|
||||
liberapay: deckerst
|
||||
custom: https://www.paypal.me/ThibaultDeckersFr
|
2
.github/workflows/check.yml
vendored
|
@ -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.
|
||||
|
|
10
.github/workflows/release.yml
vendored
|
@ -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:
|
||||
|
|
40
CHANGELOG.md
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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,7 +31,7 @@ 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 -->
|
||||
|
@ -43,13 +43,13 @@ This change eventually prevents building the app with Flutter v3.0.2.
|
|||
<!-- 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 -->
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,17 +825,21 @@ 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) {
|
||||
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.JPEG -> MultiPage.getMotionPhotoPages(context, uri, mimeType, sizeBytes = sizeBytes)
|
||||
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)
|
||||
} else {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
|||
}
|
||||
}
|
||||
|
||||
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)
|
||||
} 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
|
||||
}
|
||||
|
||||
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()
|
||||
val value = context.queryContentResolverProp(uri, mimeType, prop)
|
||||
result.success(value?.toString())
|
||||
} catch (e: Exception) {
|
||||
result.error("getContentResolverProp-query", "failed to query prop for uri=$uri", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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>()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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 metadata = MetadataExtractorHelper.safeRead(input)
|
||||
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
|
||||
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
|
||||
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)
|
||||
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("${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
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
return offsetFromEnd
|
||||
}
|
||||
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
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> {
|
||||
|
|
|
@ -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)
|
||||
// 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(structNs, structName, fieldNs, fieldName)
|
||||
return getStructField(structFirst.nsUri, structName, field.nsUri, fieldName)
|
||||
} catch (e: XMPException) {
|
||||
Log.w(LOG_TAG, "failed to get XMP struct field for path=$path", e)
|
||||
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()
|
||||
}
|
||||
|
|
|
@ -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) {
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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,
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
@ -18,3 +18,26 @@ 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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<bool name="translucentNavBar">false</bool>
|
||||
</resources>
|
9
android/app/src/main/res/values-night/styles.xml
Normal 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>
|
12
android/app/src/main/res/values-nl/strings.xml
Normal 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">Video’s</string>
|
||||
<string name="analysis_channel_name">Media indexeren</string>
|
||||
<string name="analysis_service_description">Indexeren van afdbeeldingen & video’s</string>
|
||||
<string name="analysis_notification_default_title">Indexeren van media</string>
|
||||
<string name="analysis_notification_action_stop">Stop</string>
|
||||
</resources>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -1,4 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<bool name="translucentNavBar">true</bool>
|
||||
</resources>
|
|
@ -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>
|
||||
|
|
|
@ -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'
|
||||
|
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 253 KiB |
Before Width: | Height: | Size: 494 KiB After Width: | Height: | Size: 499 KiB |
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 210 KiB |
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 64 KiB |
Before Width: | Height: | Size: 338 KiB After Width: | Height: | Size: 350 KiB |
BIN
fastlane/metadata/android/de/images/phoneScreenshots/7.png
Normal file
After Width: | Height: | Size: 346 KiB |
5
fastlane/metadata/android/en-US/changelogs/1078.txt
Normal 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
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 253 KiB |
Before Width: | Height: | Size: 494 KiB After Width: | Height: | Size: 499 KiB |
Before Width: | Height: | Size: 207 KiB After Width: | Height: | Size: 208 KiB |
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 92 KiB |
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 62 KiB |
Before Width: | Height: | Size: 339 KiB After Width: | Height: | Size: 351 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/7.png
Normal file
After Width: | Height: | Size: 345 KiB |
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 255 KiB |
Before Width: | Height: | Size: 494 KiB After Width: | Height: | Size: 499 KiB |
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 214 KiB |
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 94 KiB |
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 63 KiB |
Before Width: | Height: | Size: 338 KiB After Width: | Height: | Size: 350 KiB |
BIN
fastlane/metadata/android/es-MX/images/phoneScreenshots/7.png
Normal file
After Width: | Height: | Size: 345 KiB |
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 253 KiB |
Before Width: | Height: | Size: 494 KiB After Width: | Height: | Size: 499 KiB |
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 211 KiB |
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 94 KiB |
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 64 KiB |
Before Width: | Height: | Size: 338 KiB After Width: | Height: | Size: 349 KiB |
BIN
fastlane/metadata/android/fr/images/phoneScreenshots/7.png
Normal file
After Width: | Height: | Size: 345 KiB |
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 252 KiB |
Before Width: | Height: | Size: 494 KiB After Width: | Height: | Size: 499 KiB |
Before Width: | Height: | Size: 207 KiB After Width: | Height: | Size: 208 KiB |
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 63 KiB |
Before Width: | Height: | Size: 338 KiB After Width: | Height: | Size: 350 KiB |
BIN
fastlane/metadata/android/id/images/phoneScreenshots/7.png
Normal file
After Width: | Height: | Size: 345 KiB |
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 254 KiB |
Before Width: | Height: | Size: 494 KiB After Width: | Height: | Size: 499 KiB |
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 211 KiB |
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 93 KiB |
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 64 KiB |
Before Width: | Height: | Size: 337 KiB After Width: | Height: | Size: 349 KiB |
BIN
fastlane/metadata/android/it/images/phoneScreenshots/7.png
Normal file
After Width: | Height: | Size: 345 KiB |
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 252 KiB |
Before Width: | Height: | Size: 494 KiB After Width: | Height: | Size: 498 KiB |
Before Width: | Height: | Size: 208 KiB After Width: | Height: | Size: 209 KiB |
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 89 KiB |
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 66 KiB |
Before Width: | Height: | Size: 336 KiB After Width: | Height: | Size: 347 KiB |
BIN
fastlane/metadata/android/ja/images/phoneScreenshots/7.png
Normal file
After Width: | Height: | Size: 346 KiB |
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 252 KiB |
Before Width: | Height: | Size: 496 KiB After Width: | Height: | Size: 500 KiB |
Before Width: | Height: | Size: 206 KiB After Width: | Height: | Size: 207 KiB |
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 63 KiB |
Before Width: | Height: | Size: 337 KiB After Width: | Height: | Size: 349 KiB |
BIN
fastlane/metadata/android/ko/images/phoneScreenshots/7.png
Normal file
After Width: | Height: | Size: 346 KiB |
BIN
fastlane/metadata/android/nl/images/featureGraphic.png
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
fastlane/metadata/android/nl/images/phoneScreenshots/1.png
Normal file
After Width: | Height: | Size: 252 KiB |