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
|
# Available versions may lag behind https://github.com/flutter/flutter.git
|
||||||
- uses: subosito/flutter-action@v2
|
- uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
flutter-version: '3.3.0-0.0.pre'
|
flutter-version: '3.3.0-0.5.pre'
|
||||||
channel: 'beta'
|
channel: 'beta'
|
||||||
|
|
||||||
- name: Clone the repository.
|
- 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
|
# Available versions may lag behind https://github.com/flutter/flutter.git
|
||||||
- uses: subosito/flutter-action@v2
|
- uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
flutter-version: '3.3.0-0.0.pre'
|
flutter-version: '3.3.0-0.5.pre'
|
||||||
channel: 'beta'
|
channel: 'beta'
|
||||||
|
|
||||||
# Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1):
|
# Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1):
|
||||||
|
@ -56,15 +56,15 @@ jobs:
|
||||||
rm release.keystore.asc
|
rm release.keystore.asc
|
||||||
mkdir outputs
|
mkdir outputs
|
||||||
(cd scripts/; ./apply_flavor_play.sh)
|
(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
|
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
|
cp build/app/outputs/apk/play/release/*.apk outputs
|
||||||
(cd scripts/; ./apply_flavor_huawei.sh)
|
(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
|
cp build/app/outputs/apk/huawei/release/*.apk outputs
|
||||||
(cd scripts/; ./apply_flavor_izzy.sh)
|
(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
|
cp build/app/outputs/apk/izzy/release/*.apk outputs
|
||||||
rm $AVES_STORE_FILE
|
rm $AVES_STORE_FILE
|
||||||
env:
|
env:
|
||||||
|
|
42
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="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
|
## <a id="v1.6.11"></a>[v1.6.11] - 2022-07-26
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
@ -31,7 +57,7 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
- slideshow
|
- slideshow
|
||||||
- set wallpaper from any media
|
- 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)
|
- Search: date/dimension/size field equality (undocumented)
|
||||||
- support Android 13 (API 33)
|
- support Android 13 (API 33)
|
||||||
- Turkish translation (thanks metezd)
|
- Turkish translation (thanks metezd)
|
||||||
|
@ -126,7 +152,7 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
### Fixed
|
### 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
|
## <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
|
- Info: improved display for PNG text metadata, XMP and others
|
||||||
- Export: output format selection
|
- Export: output format selection
|
||||||
- Search: added raw filter
|
- 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
|
### Changed
|
||||||
|
|
||||||
|
@ -321,7 +347,7 @@ All notable changes to this project will be documented in this file.
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- hide root album of hidden path
|
- 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
|
- renaming was leaving behind obsolete items in some cases
|
||||||
- speeding up videos on Xiaomi devices
|
- speeding up videos on Xiaomi devices
|
||||||
|
|
||||||
|
@ -384,7 +410,7 @@ All notable changes to this project will be documented in this file.
|
||||||
- Map & Stats from selection
|
- Map & Stats from selection
|
||||||
- Map: item browsing, rotation control
|
- Map: item browsing, rotation control
|
||||||
- Navigation menu customization
|
- Navigation menu customization
|
||||||
- shortcut support on older devices (API < 26)
|
- shortcut support on older devices (API <26)
|
||||||
- support Android 12/S (API 31)
|
- support Android 12/S (API 31)
|
||||||
|
|
||||||
## [v1.4.8] - 2021-08-08
|
## [v1.4.8] - 2021-08-08
|
||||||
|
@ -398,7 +424,7 @@ All notable changes to this project will be documented in this file.
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- auto album identification and naming
|
- 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]
|
## [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: support for multi-track HEIF
|
||||||
- Viewer: export image (including multipage TIFF/HEIF and images embedded in XMP)
|
- 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
|
- listen to Media Store changes
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
@ -629,7 +655,7 @@ upgraded libtiff to 4.2.0 for TIFF decoding
|
||||||
|
|
||||||
### Fixed
|
### 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
|
## [v1.3.1] - 2021-01-04
|
||||||
|
|
||||||
|
|
|
@ -93,7 +93,7 @@ At this stage this project does *not* accept PRs, except for translations.
|
||||||
|
|
||||||
### 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
|
### Donations
|
||||||
|
|
||||||
|
|
|
@ -12,8 +12,8 @@ This change eventually prevents building the app with Flutter v3.0.2.
|
||||||
android:installLocation="auto">
|
android:installLocation="auto">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Scoped storage on Android Q is inconvenient because users need to confirm edition on each individual file.
|
Scoped storage on Android 10 is inconvenient because users need to confirm edition on each individual file.
|
||||||
So we request `WRITE_EXTERNAL_STORAGE` until Q (29), and enable `requestLegacyExternalStorage`
|
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_IMAGES" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||||
|
@ -31,25 +31,25 @@ This change eventually prevents building the app with Flutter v3.0.2.
|
||||||
<!-- to show foreground service progress via notification -->
|
<!-- to show foreground service progress via notification -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<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" />
|
<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 -->
|
<!-- TODO TLAD remove this permission when this is fixed: https://github.com/flutter/flutter/issues/42451 -->
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
<!-- for API < 26 -->
|
<!-- for API <26 -->
|
||||||
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
|
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
|
||||||
|
|
||||||
<!-- allow install on API 19, but Google Maps is from API 20 -->
|
<!-- allow install on API 19, but Google Maps is from API 20 -->
|
||||||
<uses-sdk tools:overrideLibrary="io.flutter.plugins.googlemaps" />
|
<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>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
</intent>
|
</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=
|
if appropriate intents are not declared, cf https://pub.dev/packages/url_launcher#configuration=
|
||||||
-->
|
-->
|
||||||
<!-- to open https URLs -->
|
<!-- to open https URLs -->
|
||||||
|
|
|
@ -285,7 +285,7 @@ open class MainActivity : FlutterActivity() {
|
||||||
val filters = intent.getStringArrayExtra(EXTRA_KEY_FILTERS_ARRAY)?.toList()
|
val filters = intent.getStringArrayExtra(EXTRA_KEY_FILTERS_ARRAY)?.toList()
|
||||||
if (filters != null) return filters
|
if (filters != null) return filters
|
||||||
|
|
||||||
// fallback for shortcuts created on API < 26
|
// fallback for shortcuts created on API <26
|
||||||
val filterString = intent.getStringExtra(EXTRA_KEY_FILTERS_STRING)
|
val filterString = intent.getStringExtra(EXTRA_KEY_FILTERS_STRING)
|
||||||
if (filterString != null) {
|
if (filterString != null) {
|
||||||
return filterString.split(EXTRA_STRING_ARRAY_SEPARATOR)
|
return filterString.split(EXTRA_STRING_ARRAY_SEPARATOR)
|
||||||
|
|
|
@ -102,6 +102,7 @@ class ScreenSaverService : DreamService() {
|
||||||
MethodChannel(messenger, MediaFetchHandler.CHANNEL).setMethodCallHandler(MediaFetchHandler(this))
|
MethodChannel(messenger, MediaFetchHandler.CHANNEL).setMethodCallHandler(MediaFetchHandler(this))
|
||||||
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
|
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
|
||||||
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
|
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
|
||||||
|
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
|
||||||
// - need ContextWrapper
|
// - need ContextWrapper
|
||||||
MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this))
|
MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this))
|
||||||
// - need Service
|
// - need Service
|
||||||
|
|
|
@ -36,6 +36,7 @@ class WallpaperActivity : FlutterActivity() {
|
||||||
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
|
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
|
||||||
MethodChannel(messenger, MediaFetchHandler.CHANNEL).setMethodCallHandler(MediaFetchHandler(this))
|
MethodChannel(messenger, MediaFetchHandler.CHANNEL).setMethodCallHandler(MediaFetchHandler(this))
|
||||||
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
|
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
|
||||||
|
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
|
||||||
// - need ContextWrapper
|
// - need ContextWrapper
|
||||||
MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this))
|
MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this))
|
||||||
MethodChannel(messenger, WallpaperHandler.CHANNEL).setMethodCallHandler(WallpaperHandler(this))
|
MethodChannel(messenger, WallpaperHandler.CHANNEL).setMethodCallHandler(WallpaperHandler(this))
|
||||||
|
|
|
@ -17,7 +17,7 @@ class Coresult internal constructor(private val call: MethodCall, private val me
|
||||||
try {
|
try {
|
||||||
methodResult.success(result)
|
methodResult.success(result)
|
||||||
} catch (e: Exception) {
|
} 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 {
|
try {
|
||||||
methodResult.error(errorCode, errorMessage, errorDetails)
|
methodResult.error(errorCode, errorMessage, errorDetails)
|
||||||
} catch (e: Exception) {
|
} 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 {
|
try {
|
||||||
methodResult.notImplemented()
|
methodResult.notImplemented()
|
||||||
} catch (e: Exception) {
|
} 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 androidx.exifinterface.media.ExifInterface
|
||||||
import com.drew.metadata.file.FileTypeDirectory
|
import com.drew.metadata.file.FileTypeDirectory
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
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.model.FieldMap
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface
|
import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface
|
||||||
|
@ -284,7 +288,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
if (canReadWithMetadataExtractor(mimeType)) {
|
if (canReadWithMetadataExtractor(mimeType)) {
|
||||||
try {
|
try {
|
||||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
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 ->
|
metadataMap["mimeType"] = metadata.getDirectoriesOfType(FileTypeDirectory::class.java).joinToString { dir ->
|
||||||
if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) {
|
if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) {
|
||||||
dir.getString(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.safe
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
||||||
import deckers.thibault.aves.metadata.Metadata
|
import deckers.thibault.aves.metadata.Metadata
|
||||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper
|
|
||||||
import deckers.thibault.aves.metadata.MultiPage
|
import deckers.thibault.aves.metadata.MultiPage
|
||||||
import deckers.thibault.aves.metadata.XMP
|
|
||||||
import deckers.thibault.aves.metadata.XMP.getSafeStructField
|
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.FieldMap
|
||||||
import deckers.thibault.aves.model.provider.ContentImageProvider
|
import deckers.thibault.aves.model.provider.ContentImageProvider
|
||||||
import deckers.thibault.aves.model.provider.ImageProvider
|
import deckers.thibault.aves.model.provider.ImageProvider
|
||||||
|
@ -118,7 +118,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
retriever.embeddedPicture?.let { bytes ->
|
retriever.embeddedPicture?.let { bytes ->
|
||||||
var embedMimeType: String? = null
|
var embedMimeType: String? = null
|
||||||
bytes.inputStream().use { input ->
|
bytes.inputStream().use { input ->
|
||||||
MetadataExtractorHelper.readMimeType(input)?.let { embedMimeType = it }
|
Helper.readMimeType(input)?.let { embedMimeType = it }
|
||||||
}
|
}
|
||||||
embedMimeType?.let { mime ->
|
embedMimeType?.let { mime ->
|
||||||
copyEmbeddedBytes(result, mime, displayName, bytes.inputStream())
|
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 uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
val displayName = call.argument<String>("displayName")
|
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")
|
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)
|
result.error("extractXmpDataProp-args", "missing arguments", null)
|
||||||
return
|
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)) {
|
if (canReadWithMetadataExtractor(mimeType)) {
|
||||||
try {
|
try {
|
||||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
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",
|
// data can be large and stored in "Extended XMP",
|
||||||
// which is returned as a second XMP directory
|
// which is returned as a second XMP directory
|
||||||
val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java)
|
val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java)
|
||||||
try {
|
try {
|
||||||
val embedBytes: ByteArray = if (!dataPropPath.contains('/')) {
|
val embedBytes: ByteArray = if (props.size == 1) {
|
||||||
val propNs = XMP.namespaceForPropPath(dataPropPath)
|
val prop = props.first() as XMPPropName
|
||||||
xmpDirs.mapNotNull { it.xmpMeta.getPropertyBase64(propNs, dataPropPath) }.first()
|
xmpDirs.mapNotNull { it.xmpMeta.getPropertyBase64(prop.nsUri, prop.toString()) }.first()
|
||||||
} else {
|
} else {
|
||||||
xmpDirs.mapNotNull { it.xmpMeta.getSafeStructField(dataPropPath) }.first().let {
|
xmpDirs.mapNotNull { it.xmpMeta.getSafeStructField(props) }.first().let {
|
||||||
XMPUtils.decodeBase64(it.value)
|
XMPUtils.decodeBase64(it.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -167,7 +175,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
copyEmbeddedBytes(result, embedMimeType, displayName, embedBytes.inputStream())
|
copyEmbeddedBytes(result, embedMimeType, displayName, embedBytes.inputStream())
|
||||||
return
|
return
|
||||||
} catch (e: XMPException) {
|
} 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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -179,7 +187,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
Log.w(LOG_TAG, "failed to extract file from XMP", e)
|
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) {
|
private fun copyEmbeddedBytes(result: MethodChannel.Result, mimeType: String, displayName: String?, embeddedByteStream: InputStream) {
|
||||||
|
|
|
@ -1,16 +1,14 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.ContentUris
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.Cursor
|
|
||||||
import android.media.MediaMetadataRetriever
|
import android.media.MediaMetadataRetriever
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.MediaStore
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
import com.adobe.internal.xmp.XMPException
|
import com.adobe.internal.xmp.XMPException
|
||||||
|
import com.adobe.internal.xmp.XMPMeta
|
||||||
import com.adobe.internal.xmp.XMPMetaFactory
|
import com.adobe.internal.xmp.XMPMetaFactory
|
||||||
import com.adobe.internal.xmp.options.SerializeOptions
|
import com.adobe.internal.xmp.options.SerializeOptions
|
||||||
import com.adobe.internal.xmp.properties.XMPPropertyInfo
|
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.DIR_PNG_TEXTUAL_DATA
|
||||||
import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
|
import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
|
||||||
import deckers.thibault.aves.metadata.Metadata.isFlippedForExifCode
|
import deckers.thibault.aves.metadata.Metadata.isFlippedForExifCode
|
||||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_ITXT_DIR_NAME
|
import deckers.thibault.aves.metadata.XMP.doesPropExist
|
||||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_LAST_MODIFICATION_TIME_FORMAT
|
import deckers.thibault.aves.metadata.XMP.getPropArrayItemValues
|
||||||
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.getSafeDateMillis
|
import deckers.thibault.aves.metadata.XMP.getSafeDateMillis
|
||||||
import deckers.thibault.aves.metadata.XMP.getSafeInt
|
import deckers.thibault.aves.metadata.XMP.getSafeInt
|
||||||
import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText
|
import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText
|
||||||
import deckers.thibault.aves.metadata.XMP.getSafeString
|
import deckers.thibault.aves.metadata.XMP.getSafeString
|
||||||
import deckers.thibault.aves.metadata.XMP.isMotionPhoto
|
import deckers.thibault.aves.metadata.XMP.isMotionPhoto
|
||||||
import deckers.thibault.aves.metadata.XMP.isPanorama
|
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.model.FieldMap
|
||||||
|
import deckers.thibault.aves.utils.ContextUtils.queryContentResolverProp
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
import deckers.thibault.aves.utils.MimeTypes.TIFF_EXTENSION_PATTERN
|
import deckers.thibault.aves.utils.MimeTypes.TIFF_EXTENSION_PATTERN
|
||||||
import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface
|
import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface
|
||||||
import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor
|
import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isHeic
|
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.MimeTypes.isVideo
|
||||||
import deckers.thibault.aves.utils.StorageUtils
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||||
|
@ -83,6 +83,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.json.JSONObject
|
||||||
import java.nio.charset.Charset
|
import java.nio.charset.Charset
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import java.text.DecimalFormat
|
import java.text.DecimalFormat
|
||||||
|
@ -106,6 +107,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
"hasContentResolverProp" -> ioScope.launch { safe(call, result, ::hasContentResolverProp) }
|
"hasContentResolverProp" -> ioScope.launch { safe(call, result, ::hasContentResolverProp) }
|
||||||
"getContentResolverProp" -> ioScope.launch { safe(call, result, ::getContentResolverProp) }
|
"getContentResolverProp" -> ioScope.launch { safe(call, result, ::getContentResolverProp) }
|
||||||
"getDate" -> ioScope.launch { safe(call, result, ::getDate) }
|
"getDate" -> ioScope.launch { safe(call, result, ::getDate) }
|
||||||
|
"getDescription" -> ioScope.launch { safe(call, result, ::getDescription) }
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -123,18 +125,43 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
var foundExif = false
|
var foundExif = false
|
||||||
var foundXmp = 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)) {
|
if (canReadWithMetadataExtractor(mimeType)) {
|
||||||
try {
|
try {
|
||||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
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 }
|
foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 }
|
||||||
foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
|
foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
|
||||||
|
|
||||||
val uuidDirCount = HashMap<String, Int>()
|
val uuidDirCount = HashMap<String, Int>()
|
||||||
val dirByName = metadata.directories.filter {
|
val dirByName = metadata.directories.filter {
|
||||||
(it.tagCount > 0 || it.errorCount > 0)
|
(it.tagCount > 0 || it.errorCount > 0)
|
||||||
&& it !is FileTypeDirectory
|
&& it !is FileTypeDirectory
|
||||||
&& it !is AviDirectory
|
&& it !is AviDirectory
|
||||||
}.groupBy { dir -> dir.name }
|
}.groupBy { dir -> dir.name }
|
||||||
|
|
||||||
for (dirEntry in dirByName) {
|
for (dirEntry in dirByName) {
|
||||||
val baseDirName = dirEntry.key
|
val baseDirName = dirEntry.key
|
||||||
|
|
||||||
|
@ -262,23 +289,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dir is XmpDirectory) {
|
if (dir is XmpDirectory) {
|
||||||
try {
|
processXmp(dir.xmpMeta, dirMap)
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dir is Mp4UuidBoxDirectory) {
|
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)) {
|
if (isVideo(mimeType)) {
|
||||||
// this is used as fallback when the video metadata cannot be found on the Dart side
|
// 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
|
// and to identify whether there is an accessible cover image
|
||||||
|
@ -409,9 +427,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
// - XMP / photoshop:DateCreated
|
// - XMP / photoshop:DateCreated
|
||||||
// - PNG / TIME / LAST_MODIFICATION_TIME
|
// - PNG / TIME / LAST_MODIFICATION_TIME
|
||||||
// - Video / METADATA_KEY_DATE
|
// - 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:title
|
||||||
// - XMP / dc:description
|
|
||||||
// set `KEY_XMP_SUBJECTS` from these fields (by precedence):
|
// set `KEY_XMP_SUBJECTS` from these fields (by precedence):
|
||||||
// - XMP / dc:subject
|
// - XMP / dc:subject
|
||||||
// - IPTC / keywords
|
// - IPTC / keywords
|
||||||
|
@ -447,12 +464,51 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
) {
|
) {
|
||||||
var flags = (metadataMap[KEY_FLAGS] ?: 0) as Int
|
var flags = (metadataMap[KEY_FLAGS] ?: 0) as Int
|
||||||
var foundExif = false
|
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)) {
|
if (canReadWithMetadataExtractor(mimeType)) {
|
||||||
try {
|
try {
|
||||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
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 }
|
foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 }
|
||||||
|
foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
|
||||||
|
|
||||||
// File type
|
// File type
|
||||||
for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) {
|
for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) {
|
||||||
|
@ -491,7 +547,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
dir.getSafeInt(ExifDirectoryBase.TAG_ORIENTATION) {
|
dir.getSafeInt(ExifDirectoryBase.TAG_ORIENTATION) {
|
||||||
val orientation = it
|
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)
|
metadataMap[KEY_ROTATION_DEGREES] = getRotationDegreesForExifCode(orientation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -506,47 +564,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
// XMP
|
// XMP
|
||||||
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
|
metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// XMP fallback to IPTC
|
// XMP fallback to IPTC
|
||||||
if (!metadataMap.containsKey(KEY_XMP_SUBJECTS)) {
|
if (!metadataMap.containsKey(KEY_XMP_SUBJECTS)) {
|
||||||
|
@ -574,7 +592,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
MimeTypes.GIF -> {
|
MimeTypes.GIF -> {
|
||||||
// identification of animated 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 -> {
|
MimeTypes.WEBP -> {
|
||||||
// identification of animated WEBP
|
// identification of animated WEBP
|
||||||
|
@ -587,7 +607,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
MimeTypes.TIFF -> {
|
MimeTypes.TIFF -> {
|
||||||
// identification of GeoTIFF
|
// identification of GeoTIFF
|
||||||
for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
|
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
|
if (mimeType == MimeTypes.TIFF && MultiPage.isMultiPageTiff(context, uri)) flags = flags or MASK_IS_MULTIPAGE
|
||||||
|
|
||||||
metadataMap[KEY_FLAGS] = flags
|
metadataMap[KEY_FLAGS] = flags
|
||||||
|
@ -718,7 +742,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
if (canReadWithMetadataExtractor(mimeType)) {
|
if (canReadWithMetadataExtractor(mimeType)) {
|
||||||
try {
|
try {
|
||||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
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)) {
|
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
|
||||||
foundExif = true
|
foundExif = true
|
||||||
dir.getSafeRational(ExifDirectoryBase.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it.numerator.toDouble() / it.denominator }
|
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)) {
|
if (canReadWithMetadataExtractor(mimeType)) {
|
||||||
try {
|
try {
|
||||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||||
val metadata = MetadataExtractorHelper.safeRead(input)
|
val metadata = Helper.safeRead(input)
|
||||||
val fields = HashMap<Int, Any?>()
|
val fields = HashMap<Int, Any?>()
|
||||||
for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
|
for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
|
||||||
if (dir.containsGeoTiffTags()) {
|
if (dir.containsGeoTiffTags()) {
|
||||||
|
@ -801,16 +825,20 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
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)
|
result.error("getMultiPageInfo-args", "missing arguments", null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val pages: ArrayList<FieldMap>? = when (mimeType) {
|
val pages: ArrayList<FieldMap>? = if (isMotionPhoto) {
|
||||||
MimeTypes.HEIC, MimeTypes.HEIF -> MultiPage.getHeicTracks(context, uri)
|
MultiPage.getMotionPhotoPages(context, uri, mimeType, sizeBytes = sizeBytes)
|
||||||
MimeTypes.JPEG -> MultiPage.getMotionPhotoPages(context, uri, mimeType, sizeBytes = sizeBytes)
|
} else {
|
||||||
MimeTypes.TIFF -> MultiPage.getTiffPages(context, uri)
|
when (mimeType) {
|
||||||
else -> null
|
MimeTypes.HEIC, MimeTypes.HEIF -> MultiPage.getHeicTracks(context, uri)
|
||||||
|
MimeTypes.TIFF -> MultiPage.getTiffPages(context, uri)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (pages?.isEmpty() == true) {
|
if (pages?.isEmpty() == true) {
|
||||||
result.error("getMultiPageInfo-empty", "failed to get pages for mimeType=$mimeType uri=$uri", null)
|
result.error("getMultiPageInfo-empty", "failed to get pages for mimeType=$mimeType uri=$uri", null)
|
||||||
|
@ -828,25 +856,29 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
return
|
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)) {
|
if (canReadWithMetadataExtractor(mimeType)) {
|
||||||
try {
|
try {
|
||||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||||
val metadata = MetadataExtractorHelper.safeRead(input)
|
val metadata = Helper.safeRead(input)
|
||||||
val fields: FieldMap = hashMapOf(
|
foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
|
||||||
"projectionType" to XMP.GPANO_PROJECTION_TYPE_DEFAULT,
|
metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp)
|
||||||
)
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
|
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)
|
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.error("getPanoramaInfo-empty", "failed to get info for mimeType=$mimeType uri=$uri", null)
|
|
||||||
|
XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp)
|
||||||
|
|
||||||
|
if (fields.isEmpty()) {
|
||||||
|
result.error("getPanoramaInfo-empty", "failed to get info for mimeType=$mimeType uri=$uri", null)
|
||||||
|
} else {
|
||||||
|
fields["projectionType"] = fields["projectionType"] ?: XMP.GPANO_PROJECTION_TYPE_DEFAULT
|
||||||
|
result.success(fields)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getIptc(call: MethodCall, result: MethodChannel.Result) {
|
private fun getIptc(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
@ -892,13 +932,23 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
return
|
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)) {
|
if (canReadWithMetadataExtractor(mimeType)) {
|
||||||
try {
|
try {
|
||||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||||
val metadata = MetadataExtractorHelper.safeRead(input)
|
val metadata = Helper.safeRead(input)
|
||||||
val xmpStrings = metadata.getDirectoriesOfType(XmpDirectory::class.java).mapNotNull { XMPMetaFactory.serializeToString(it.xmpMeta, xmpSerializeOptions) }
|
foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
|
||||||
result.success(xmpStrings.toMutableList())
|
metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
result.error("getXmp-exception", "failed to read XMP for mimeType=$mimeType uri=$uri", e.message)
|
result.error("getXmp-exception", "failed to read XMP for mimeType=$mimeType uri=$uri", e.message)
|
||||||
|
@ -912,7 +962,13 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result.success(null)
|
XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp)
|
||||||
|
|
||||||
|
if (xmpStrings.isEmpty()) {
|
||||||
|
result.success(null)
|
||||||
|
} else {
|
||||||
|
result.success(xmpStrings)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hasContentResolverProp(call: MethodCall, result: MethodChannel.Result) {
|
private fun hasContentResolverProp(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
@ -942,48 +998,12 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
return
|
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 {
|
try {
|
||||||
cursor = context.contentResolver.query(contentUri, projection, null, null, null)
|
val value = context.queryContentResolverProp(uri, mimeType, prop)
|
||||||
|
result.success(value?.toString())
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// throws SQLiteException when the requested prop is not a known column
|
result.error("getContentResolverProp-query", "failed to query prop for uri=$uri", e.message)
|
||||||
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()
|
|
||||||
result.success(value?.toString())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDate(call: MethodCall, result: MethodChannel.Result) {
|
private fun getDate(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
@ -1000,7 +1020,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
if (canReadWithMetadataExtractor(mimeType)) {
|
if (canReadWithMetadataExtractor(mimeType)) {
|
||||||
try {
|
try {
|
||||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||||
val metadata = MetadataExtractorHelper.safeRead(input)
|
val metadata = Helper.safeRead(input)
|
||||||
val tag = when (field) {
|
val tag = when (field) {
|
||||||
ExifInterface.TAG_DATETIME -> ExifIFD0Directory.TAG_DATETIME
|
ExifInterface.TAG_DATETIME -> ExifIFD0Directory.TAG_DATETIME
|
||||||
ExifInterface.TAG_DATETIME_DIGITIZED -> ExifSubIFDDirectory.TAG_DATETIME_DIGITIZED
|
ExifInterface.TAG_DATETIME_DIGITIZED -> ExifSubIFDDirectory.TAG_DATETIME_DIGITIZED
|
||||||
|
@ -1052,6 +1072,58 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
result.success(dateMillis)
|
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 {
|
companion object {
|
||||||
private val LOG_TAG = LogUtils.createTag<MetadataFetchHandler>()
|
private val LOG_TAG = LogUtils.createTag<MetadataFetchHandler>()
|
||||||
const val CHANNEL = "deckers.thibault/aves/metadata_fetch"
|
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_LATITUDE = "latitude"
|
||||||
private const val KEY_LONGITUDE = "longitude"
|
private const val KEY_LONGITUDE = "longitude"
|
||||||
private const val KEY_XMP_SUBJECTS = "xmpSubjects"
|
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 KEY_RATING = "rating"
|
||||||
|
|
||||||
private const val MASK_IS_ANIMATED = 1 shl 0
|
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_GEOTIFF = 1 shl 2
|
||||||
private const val MASK_IS_360 = 1 shl 3
|
private const val MASK_IS_360 = 1 shl 3
|
||||||
private const val MASK_IS_MULTIPAGE = 1 shl 4
|
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 = ";"
|
private const val XMP_SUBJECTS_SEPARATOR = ";"
|
||||||
|
|
||||||
// overlay metadata
|
// overlay metadata
|
||||||
|
|
|
@ -56,7 +56,7 @@ class ThumbnailFetcher internal constructor(
|
||||||
try {
|
try {
|
||||||
if (!customFetch && (width == defaultSize || height == defaultSize) && !isFlipped) {
|
if (!customFetch && (width == defaultSize || height == defaultSize) && !isFlipped) {
|
||||||
// Fetch low quality thumbnails when size is not specified.
|
// 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,
|
// that is automatically rotated according to EXIF orientation, but not flipped,
|
||||||
// so we skip this step for flipped entries.
|
// so we skip this step for flipped entries.
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
@ -108,7 +108,7 @@ class ThumbnailFetcher internal constructor(
|
||||||
} else {
|
} else {
|
||||||
@Suppress("deprecation")
|
@Suppress("deprecation")
|
||||||
var bitmap = MediaStore.Images.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Images.Thumbnails.MINI_KIND, null)
|
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) {
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && bitmap != null) {
|
||||||
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
|
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,6 @@ package deckers.thibault.aves.channel.calls.window
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
|
@ -60,8 +59,4 @@ class ActivityWindowHandler(private val activity: Activity) : WindowHandler(acti
|
||||||
}
|
}
|
||||||
result.success(true)
|
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) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,17 +8,25 @@ import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.adobe.internal.xmp.XMPMeta
|
||||||
import com.drew.metadata.xmp.XmpDirectory
|
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.getSafeLong
|
||||||
import deckers.thibault.aves.metadata.XMP.getSafeStructField
|
import deckers.thibault.aves.metadata.XMP.getSafeStructField
|
||||||
|
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
|
import deckers.thibault.aves.utils.indexOfBytes
|
||||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||||
|
import java.io.DataInputStream
|
||||||
|
|
||||||
object MultiPage {
|
object MultiPage {
|
||||||
private val LOG_TAG = LogUtils.createTag<MultiPage>()
|
private val LOG_TAG = LogUtils.createTag<MultiPage>()
|
||||||
|
|
||||||
|
private val heicMotionPhotoVideoStartIndicator = byteArrayOf(0x00, 0x00, 0x00, 0x18) + "ftypmp42".toByteArray()
|
||||||
|
|
||||||
// page info
|
// page info
|
||||||
private const val KEY_MIME_TYPE = "mimeType"
|
private const val KEY_MIME_TYPE = "mimeType"
|
||||||
private const val KEY_HEIGHT = "height"
|
private const val KEY_HEIGHT = "height"
|
||||||
|
@ -103,9 +111,11 @@ object MultiPage {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
// add video tracks from the appended video
|
// 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 {
|
try {
|
||||||
val format = extractor.getTrackFormat(i)
|
val format = extractor.getTrackFormat(trackIndex)
|
||||||
format.getString(MediaFormat.KEY_MIME)?.let { mime ->
|
format.getString(MediaFormat.KEY_MIME)?.let { mime ->
|
||||||
if (MimeTypes.isVideo(mime)) {
|
if (MimeTypes.isVideo(mime)) {
|
||||||
val track: FieldMap = hashMapOf(
|
val track: FieldMap = hashMapOf(
|
||||||
|
@ -123,7 +133,7 @@ object MultiPage {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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? {
|
fun getMotionPhotoOffset(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Long? {
|
||||||
|
if (MimeTypes.isHeic(mimeType)) {
|
||||||
|
// XMP in HEIC motion photos (as taken with a Samsung Camera v12.0.01.50) indicates an `Item:Length` of 68 bytes for the video.
|
||||||
|
// This item does not contain the video itself, but only some kind of metadata (no doc, no spec),
|
||||||
|
// so we ignore the `Item:Length` and look instead for the MP4 marker bytes indicating the start of the video.
|
||||||
|
try {
|
||||||
|
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||||
|
val bytes = ByteArray(sizeBytes.toInt())
|
||||||
|
DataInputStream(input).use {
|
||||||
|
it.readFully(bytes)
|
||||||
|
}
|
||||||
|
val index = bytes.indexOfBytes(heicMotionPhotoVideoStartIndicator)
|
||||||
|
if (index != -1) {
|
||||||
|
return sizeBytes - index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(LOG_TAG, "failed to get motion photo offset from uri=$uri", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var offsetFromEnd: Long? = null
|
||||||
|
var foundXmp = false
|
||||||
|
|
||||||
|
fun processXmp(xmpMeta: XMPMeta) {
|
||||||
|
if (xmpMeta.doesPropExist(XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME)) {
|
||||||
|
// `GCamera` motion photo
|
||||||
|
xmpMeta.getSafeLong(XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME) { offsetFromEnd = it }
|
||||||
|
} else if (xmpMeta.doesPropExist(XMP.CONTAINER_DIRECTORY_PROP_NAME)) {
|
||||||
|
// `Container` motion photo
|
||||||
|
val count = xmpMeta.countPropArrayItems(XMP.CONTAINER_DIRECTORY_PROP_NAME)
|
||||||
|
if (count == 2) {
|
||||||
|
// expect the video to be the second item
|
||||||
|
val i = 2
|
||||||
|
val mime = xmpMeta.getSafeStructField(listOf(XMP.CONTAINER_DIRECTORY_PROP_NAME, i, XMP.CONTAINER_ITEM_PROP_NAME, XMP.CONTAINER_ITEM_MIME_PROP_NAME))?.value
|
||||||
|
val length = xmpMeta.getSafeStructField(listOf(XMP.CONTAINER_DIRECTORY_PROP_NAME, i, XMP.CONTAINER_ITEM_PROP_NAME, XMP.CONTAINER_ITEM_LENGTH_PROP_NAME))?.value
|
||||||
|
if (MimeTypes.isVideo(mime) && length != null) {
|
||||||
|
offsetFromEnd = length.toLong()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||||
val metadata = MetadataExtractorHelper.safeRead(input)
|
val metadata = Helper.safeRead(input)
|
||||||
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
|
foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
|
||||||
var offsetFromEnd: Long? = null
|
metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp)
|
||||||
val xmpMeta = dir.xmpMeta
|
|
||||||
if (xmpMeta.doesPropertyExist(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME)) {
|
|
||||||
// GCamera motion photo
|
|
||||||
xmpMeta.getSafeLong(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME) { offsetFromEnd = it }
|
|
||||||
} else if (xmpMeta.doesPropertyExist(XMP.CONTAINER_SCHEMA_NS, XMP.CONTAINER_DIRECTORY_PROP_NAME)) {
|
|
||||||
// Container motion photo
|
|
||||||
val count = xmpMeta.countArrayItems(XMP.CONTAINER_SCHEMA_NS, XMP.CONTAINER_DIRECTORY_PROP_NAME)
|
|
||||||
if (count == 2) {
|
|
||||||
// expect the video to be the second item
|
|
||||||
val i = 2
|
|
||||||
val mime = xmpMeta.getSafeStructField("${XMP.CONTAINER_DIRECTORY_PROP_NAME}[$i]/${XMP.CONTAINER_ITEM_PROP_NAME}/${XMP.CONTAINER_ITEM_MIME_PROP_NAME}")?.value
|
|
||||||
val length = xmpMeta.getSafeStructField("${XMP.CONTAINER_DIRECTORY_PROP_NAME}[$i]/${XMP.CONTAINER_ITEM_PROP_NAME}/${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}")?.value
|
|
||||||
if (MimeTypes.isVideo(mime) && length != null) {
|
|
||||||
offsetFromEnd = length.toLong()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return offsetFromEnd
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(LOG_TAG, "failed to get motion photo offset from uri=$uri", e)
|
Log.w(LOG_TAG, "failed to get motion photo offset from uri=$uri", e)
|
||||||
|
@ -170,7 +203,10 @@ object MultiPage {
|
||||||
} catch (e: AssertionError) {
|
} catch (e: AssertionError) {
|
||||||
Log.w(LOG_TAG, "failed to get motion photo offset from uri=$uri", e)
|
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> {
|
fun getTiffPages(context: Context, uri: Uri): ArrayList<FieldMap> {
|
||||||
|
@ -214,4 +250,4 @@ object MultiPage {
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,20 @@
|
||||||
package deckers.thibault.aves.metadata
|
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 android.util.Log
|
||||||
import com.adobe.internal.xmp.XMPError
|
import com.adobe.internal.xmp.XMPError
|
||||||
import com.adobe.internal.xmp.XMPException
|
import com.adobe.internal.xmp.XMPException
|
||||||
import com.adobe.internal.xmp.XMPMeta
|
import com.adobe.internal.xmp.XMPMeta
|
||||||
|
import com.adobe.internal.xmp.XMPMetaFactory
|
||||||
import com.adobe.internal.xmp.properties.XMPProperty
|
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.LogUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
object XMP {
|
object XMP {
|
||||||
|
@ -14,74 +22,65 @@ object XMP {
|
||||||
|
|
||||||
// standard namespaces
|
// standard namespaces
|
||||||
// cf com.adobe.internal.xmp.XMPConst
|
// cf com.adobe.internal.xmp.XMPConst
|
||||||
const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/"
|
private const val DC_NS_URI = "http://purl.org/dc/elements/1.1/"
|
||||||
const val MICROSOFTPHOTO_SCHEMA_NS = "http://ns.microsoft.com/photo/1.0/"
|
private const val MICROSOFTPHOTO_NS_URI = "http://ns.microsoft.com/photo/1.0/"
|
||||||
const val PHOTOSHOP_SCHEMA_NS = "http://ns.adobe.com/photoshop/1.0/"
|
private const val PHOTOSHOP_NS_URI = "http://ns.adobe.com/photoshop/1.0/"
|
||||||
const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/"
|
private const val XMP_NS_URI = "http://ns.adobe.com/xap/1.0/"
|
||||||
private const val XMP_GIMG_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/g/img/"
|
|
||||||
|
|
||||||
// other namespaces
|
// other namespaces
|
||||||
private const val GAUDIO_SCHEMA_NS = "http://ns.google.com/photos/1.0/audio/"
|
private const val CONTAINER_NS_URI = "http://ns.google.com/photos/1.0/container/"
|
||||||
const val GCAMERA_SCHEMA_NS = "http://ns.google.com/photos/1.0/camera/"
|
private const val CONTAINER_ITEM_NS_URI = "http://ns.google.com/photos/1.0/container/item/"
|
||||||
private const val GDEPTH_SCHEMA_NS = "http://ns.google.com/photos/1.0/depthmap/"
|
private const val GAUDIO_NS_URI = "http://ns.google.com/photos/1.0/audio/"
|
||||||
private const val GIMAGE_SCHEMA_NS = "http://ns.google.com/photos/1.0/image/"
|
private const val GCAMERA_NS_URI = "http://ns.google.com/photos/1.0/camera/"
|
||||||
const val CONTAINER_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/"
|
private const val GDEPTH_NS_URI = "http://ns.google.com/photos/1.0/depthmap/"
|
||||||
private const val CONTAINER_ITEM_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/item/"
|
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"
|
val DC_SUBJECT_PROP_NAME = XMPPropName(DC_NS_URI, "subject")
|
||||||
const val DC_SUBJECT_PROP_NAME = "dc:subject"
|
val DC_DESCRIPTION_PROP_NAME = XMPPropName(DC_NS_URI, "description")
|
||||||
const val DC_TITLE_PROP_NAME = "dc:title"
|
val DC_TITLE_PROP_NAME = XMPPropName(DC_NS_URI, "title")
|
||||||
const val MS_RATING_PROP_NAME = "MicrosoftPhoto:Rating"
|
val MS_RATING_PROP_NAME = XMPPropName(MICROSOFTPHOTO_NS_URI, "Rating")
|
||||||
const val PS_DATE_CREATED_PROP_NAME = "photoshop:DateCreated"
|
val PS_DATE_CREATED_PROP_NAME = XMPPropName(PHOTOSHOP_NS_URI, "DateCreated")
|
||||||
const val XMP_CREATE_DATE_PROP_NAME = "xmp:CreateDate"
|
val XMP_CREATE_DATE_PROP_NAME = XMPPropName(XMP_NS_URI, "CreateDate")
|
||||||
const val XMP_RATING_PROP_NAME = "xmp:Rating"
|
val XMP_RATING_PROP_NAME = XMPPropName(XMP_NS_URI, "Rating")
|
||||||
|
|
||||||
private const val GENERIC_LANG = ""
|
private const val GENERIC_LANG = ""
|
||||||
private const val SPECIFIC_LANG = "en-US"
|
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
|
// embedded media data properties
|
||||||
// cf https://developers.google.com/depthmap-metadata
|
// cf https://developers.google.com/depthmap-metadata
|
||||||
// cf https://developers.google.com/vr/reference/cardboard-camera-vr-photo-format
|
// 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
|
// motion photo
|
||||||
|
|
||||||
const val GCAMERA_VIDEO_OFFSET_PROP_NAME = "GCamera:MicroVideoOffset"
|
val GCAMERA_VIDEO_OFFSET_PROP_NAME = XMPPropName(GCAMERA_NS_URI, "MicroVideoOffset")
|
||||||
const val CONTAINER_DIRECTORY_PROP_NAME = "Container:Directory"
|
val CONTAINER_DIRECTORY_PROP_NAME = XMPPropName(CONTAINER_NS_URI, "Directory")
|
||||||
const val CONTAINER_ITEM_PROP_NAME = "Container:Item"
|
val CONTAINER_ITEM_PROP_NAME = XMPPropName(CONTAINER_NS_URI, "Item")
|
||||||
const val CONTAINER_ITEM_LENGTH_PROP_NAME = "Item:Length"
|
val CONTAINER_ITEM_LENGTH_PROP_NAME = XMPPropName(CONTAINER_ITEM_NS_URI, "Length")
|
||||||
const val CONTAINER_ITEM_MIME_PROP_NAME = "Item:Mime"
|
val CONTAINER_ITEM_MIME_PROP_NAME = XMPPropName(CONTAINER_ITEM_NS_URI, "Mime")
|
||||||
|
|
||||||
// panorama
|
// panorama
|
||||||
// cf https://developers.google.com/streetview/spherical-metadata
|
// cf https://developers.google.com/streetview/spherical-metadata
|
||||||
|
|
||||||
const val GPANO_SCHEMA_NS = "http://ns.google.com/photos/1.0/panorama/"
|
val GPANO_CROPPED_AREA_HEIGHT_PROP_NAME = XMPPropName(GPANO_NS_URI, "CroppedAreaImageHeightPixels")
|
||||||
private const val PMTM_SCHEMA_NS = "http://www.hdrsoft.com/photomatix_settings01"
|
val GPANO_CROPPED_AREA_WIDTH_PROP_NAME = XMPPropName(GPANO_NS_URI, "CroppedAreaImageWidthPixels")
|
||||||
|
val GPANO_CROPPED_AREA_LEFT_PROP_NAME = XMPPropName(GPANO_NS_URI, "CroppedAreaLeftPixels")
|
||||||
const val GPANO_CROPPED_AREA_HEIGHT_PROP_NAME = "GPano:CroppedAreaImageHeightPixels"
|
val GPANO_CROPPED_AREA_TOP_PROP_NAME = XMPPropName(GPANO_NS_URI, "CroppedAreaTopPixels")
|
||||||
const val GPANO_CROPPED_AREA_WIDTH_PROP_NAME = "GPano:CroppedAreaImageWidthPixels"
|
val GPANO_FULL_PANO_HEIGHT_PROP_NAME = XMPPropName(GPANO_NS_URI, "FullPanoHeightPixels")
|
||||||
const val GPANO_CROPPED_AREA_LEFT_PROP_NAME = "GPano:CroppedAreaLeftPixels"
|
val GPANO_FULL_PANO_WIDTH_PROP_NAME = XMPPropName(GPANO_NS_URI, "FullPanoWidthPixels")
|
||||||
const val GPANO_CROPPED_AREA_TOP_PROP_NAME = "GPano:CroppedAreaTopPixels"
|
val GPANO_PROJECTION_TYPE_PROP_NAME = XMPPropName(GPANO_NS_URI, "ProjectionType")
|
||||||
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"
|
|
||||||
const val GPANO_PROJECTION_TYPE_DEFAULT = "equirectangular"
|
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: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)
|
// `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,
|
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
|
// extensions
|
||||||
|
|
||||||
fun XMPMeta.isMotionPhoto(): Boolean {
|
fun XMPMeta.isMotionPhoto(): Boolean {
|
||||||
try {
|
try {
|
||||||
// GCamera motion photo
|
// 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
|
// Container motion photo
|
||||||
if (doesPropertyExist(CONTAINER_SCHEMA_NS, CONTAINER_DIRECTORY_PROP_NAME)) {
|
if (doesPropExist(CONTAINER_DIRECTORY_PROP_NAME)) {
|
||||||
val count = countArrayItems(CONTAINER_SCHEMA_NS, CONTAINER_DIRECTORY_PROP_NAME)
|
val count = countPropArrayItems(CONTAINER_DIRECTORY_PROP_NAME)
|
||||||
if (count == 2) {
|
if (count == 2) {
|
||||||
var hasImage = false
|
var hasImage = false
|
||||||
var hasVideo = false
|
var hasVideo = false
|
||||||
for (i in 1 until count + 1) {
|
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 mime = getSafeStructField(listOf(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 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
|
hasImage = hasImage || MimeTypes.isImage(mime) && length != null
|
||||||
hasVideo = hasVideo || MimeTypes.isVideo(mime) && length != null
|
hasVideo = hasVideo || MimeTypes.isVideo(mime) && length != null
|
||||||
}
|
}
|
||||||
|
@ -130,7 +145,7 @@ object XMP {
|
||||||
fun XMPMeta.isPanorama(): Boolean {
|
fun XMPMeta.isPanorama(): Boolean {
|
||||||
// Google
|
// Google
|
||||||
try {
|
try {
|
||||||
if (gpanoRequiredProps.all { doesPropertyExist(GPANO_SCHEMA_NS, it) }) return true
|
if (gpanoRequiredProps.all { doesPropExist(it) }) return true
|
||||||
} catch (e: XMPException) {
|
} catch (e: XMPException) {
|
||||||
if (e.errorCode != XMPError.BADSCHEMA) {
|
if (e.errorCode != XMPError.BADSCHEMA) {
|
||||||
// `BADSCHEMA` code is reported when we check a property
|
// `BADSCHEMA` code is reported when we check a property
|
||||||
|
@ -141,7 +156,7 @@ object XMP {
|
||||||
|
|
||||||
// Photomatix
|
// Photomatix
|
||||||
try {
|
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) {
|
} catch (e: XMPException) {
|
||||||
if (e.errorCode != XMPError.BADSCHEMA) {
|
if (e.errorCode != XMPError.BADSCHEMA) {
|
||||||
// `BADSCHEMA` code is reported when we check a property
|
// `BADSCHEMA` code is reported when we check a property
|
||||||
|
@ -153,7 +168,24 @@ object XMP {
|
||||||
return false
|
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 {
|
try {
|
||||||
if (doesPropertyExist(schema, propName)) {
|
if (doesPropertyExist(schema, propName)) {
|
||||||
val item = getPropertyInteger(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 {
|
try {
|
||||||
if (doesPropertyExist(schema, propName)) {
|
if (doesPropertyExist(schema, propName)) {
|
||||||
val item = getPropertyLong(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 {
|
try {
|
||||||
if (doesPropertyExist(schema, propName)) {
|
if (doesPropertyExist(schema, propName)) {
|
||||||
val item = getPropertyString(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 {
|
try {
|
||||||
if (doesPropertyExist(schema, propName)) {
|
if (doesPropertyExist(schema, propName)) {
|
||||||
val item = getLocalizedText(schema, propName, GENERIC_LANG, SPECIFIC_LANG)
|
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 {
|
try {
|
||||||
if (doesPropertyExist(schema, propName)) {
|
if (doesPropertyExist(schema, propName)) {
|
||||||
val item = getPropertyDate(schema, propName)
|
val item = getPropertyDate(schema, propName)
|
||||||
|
@ -226,20 +266,38 @@ object XMP {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// e.g. 'Container:Directory[42]/Container:Item/Item:Mime'
|
// e.g. path 'Container:Directory[42]/Container:Item/Item:Mime' matches:
|
||||||
fun XMPMeta.getSafeStructField(path: String): XMPProperty? {
|
// - structNs: "http://ns.google.com/photos/1.0/container/"
|
||||||
val separator = path.lastIndexOf("/")
|
// - structName: "Container:Directory[42]/Container:Item"
|
||||||
if (separator != -1) {
|
// - fieldNs: "http://ns.google.com/photos/1.0/container/item/"
|
||||||
val structName = path.substring(0, separator)
|
// - fieldName: "Item:Mime"
|
||||||
val structNs = namespaceForPropPath(structName)
|
fun XMPMeta.getSafeStructField(props: List<Any>): XMPProperty? {
|
||||||
val fieldName = path.substring(separator + 1)
|
if (props.size >= 2) {
|
||||||
val fieldNs = namespaceForPropPath(fieldName)
|
val structFirst = props.first()
|
||||||
try {
|
val field = props.last()
|
||||||
return getStructField(structNs, structName, fieldNs, fieldName)
|
if (structFirst is XMPPropName && field is XMPPropName) {
|
||||||
} catch (e: XMPException) {
|
val structName = props.take(props.size - 1).mapIndexed { index, prop ->
|
||||||
Log.w(LOG_TAG, "failed to get XMP struct field for path=$path", e)
|
when (prop) {
|
||||||
|
is XMPPropName -> "${if (index == 0) "" else "/"}$prop"
|
||||||
|
is Int -> "[$prop]"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}.filterNotNull().joinToString("")
|
||||||
|
val fieldName = field.toString()
|
||||||
|
|
||||||
|
try {
|
||||||
|
return getStructField(structFirst.nsUri, structName, field.nsUri, fieldName)
|
||||||
|
} catch (e: XMPException) {
|
||||||
|
Log.w(LOG_TAG, "failed to get XMP struct field for props=$props", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
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 android.util.Log
|
||||||
import com.drew.imaging.FileType
|
import com.drew.imaging.FileType
|
||||||
import com.drew.imaging.FileTypeDetector
|
import com.drew.imaging.FileTypeDetector
|
||||||
import com.drew.imaging.ImageMetadataReader
|
import com.drew.imaging.ImageMetadataReader
|
||||||
|
import com.drew.imaging.ImageProcessingException
|
||||||
import com.drew.imaging.jpeg.JpegMetadataReader
|
import com.drew.imaging.jpeg.JpegMetadataReader
|
||||||
import com.drew.imaging.jpeg.JpegSegmentMetadataReader
|
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.ByteArrayReader
|
||||||
|
import com.drew.lang.RandomAccessStreamReader
|
||||||
import com.drew.lang.Rational
|
import com.drew.lang.Rational
|
||||||
import com.drew.lang.SequentialByteArrayReader
|
import com.drew.lang.SequentialByteArrayReader
|
||||||
import com.drew.metadata.Directory
|
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.iptc.IptcReader
|
||||||
import com.drew.metadata.png.PngDirectory
|
import com.drew.metadata.png.PngDirectory
|
||||||
import com.drew.metadata.xmp.XmpReader
|
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 deckers.thibault.aves.utils.LogUtils
|
||||||
import java.io.BufferedInputStream
|
import java.io.BufferedInputStream
|
||||||
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
object MetadataExtractorHelper {
|
object Helper {
|
||||||
private val LOG_TAG = LogUtils.createTag<MetadataExtractorHelper>()
|
private val LOG_TAG = LogUtils.createTag<Helper>()
|
||||||
|
|
||||||
const val PNG_ITXT_DIR_NAME = "PNG-iTXt"
|
const val PNG_ITXT_DIR_NAME = "PNG-iTXt"
|
||||||
private const val PNG_TEXT_DIR_NAME = "PNG-tEXt"
|
private const val PNG_TEXT_DIR_NAME = "PNG-tEXt"
|
||||||
|
@ -43,33 +52,42 @@ object MetadataExtractorHelper {
|
||||||
// e.g. "exif [...] 134 [...] 4578696600004949[...]"
|
// e.g. "exif [...] 134 [...] 4578696600004949[...]"
|
||||||
private val PNG_RAW_PROFILE_PATTERN = Regex("^\\n(.*?)\\n\\s*(\\d+)\\n(.*)", RegexOption.DOT_MATCHES_ALL)
|
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? {
|
fun readMimeType(input: InputStream): String? {
|
||||||
val bufferedInputStream = if (input is BufferedInputStream) input else BufferedInputStream(input)
|
val bufferedInputStream = if (input is BufferedInputStream) input else BufferedInputStream(input)
|
||||||
return FileTypeDetector.detectFileType(bufferedInputStream).mimeType
|
return FileTypeDetector.detectFileType(bufferedInputStream).mimeType
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class, ImageProcessingException::class)
|
||||||
fun safeRead(input: InputStream): com.drew.metadata.Metadata {
|
fun safeRead(input: InputStream): com.drew.metadata.Metadata {
|
||||||
val bufferedInputStream = if (input is BufferedInputStream) input else BufferedInputStream(input)
|
val inputStream = if (input is BufferedInputStream) input else BufferedInputStream(input)
|
||||||
val fileType = FileTypeDetector.detectFileType(bufferedInputStream)
|
val fileType = FileTypeDetector.detectFileType(inputStream)
|
||||||
|
|
||||||
val metadata = if (fileType == FileType.Jpeg) {
|
val metadata = when (fileType) {
|
||||||
safeReadJpeg(bufferedInputStream)
|
FileType.Jpeg -> safeReadJpeg(inputStream)
|
||||||
} else {
|
FileType.Tiff,
|
||||||
// providing the stream length is risky, as it may crash if it is incorrect
|
FileType.Arw,
|
||||||
ImageMetadataReader.readMetadata(bufferedInputStream, -1L, fileType)
|
FileType.Cr2,
|
||||||
|
FileType.Nef,
|
||||||
|
FileType.Orf,
|
||||||
|
FileType.Rw2 -> safeReadTiff(inputStream)
|
||||||
|
FileType.Mp4 -> safeReadMp4(inputStream)
|
||||||
|
else -> ImageMetadataReader.readMetadata(inputStream, safeReadStreamLength, fileType)
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata.addDirectory(FileTypeDirectory(fileType))
|
metadata.addDirectory(FileTypeDirectory(fileType))
|
||||||
return metadata
|
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`
|
// 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.
|
// 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 {
|
private fun safeReadJpeg(input: InputStream): com.drew.metadata.Metadata {
|
||||||
val readers = ArrayList<JpegSegmentMetadataReader>().apply {
|
val readers = ArrayList<JpegSegmentMetadataReader>().apply {
|
||||||
addAll(JpegMetadataReader.ALL_READERS.filter { it !is XmpReader })
|
addAll(JpegMetadataReader.ALL_READERS.filter { it !is XmpReader })
|
||||||
add(MetadataExtractorSafeXmpReader())
|
add(SafeXmpReader())
|
||||||
}
|
}
|
||||||
|
|
||||||
val metadata = com.drew.metadata.Metadata()
|
val metadata = com.drew.metadata.Metadata()
|
||||||
|
@ -77,6 +95,21 @@ object MetadataExtractorHelper {
|
||||||
return metadata
|
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
|
// extensions
|
||||||
|
|
||||||
fun Directory.getSafeString(tag: Int, save: (value: String) -> Unit) {
|
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 android.util.Log
|
||||||
import com.adobe.internal.xmp.XMPException
|
import com.adobe.internal.xmp.XMPException
|
||||||
|
@ -19,7 +19,7 @@ import com.drew.metadata.xmp.XmpReader
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
class MetadataExtractorSafeXmpReader : XmpReader() {
|
class SafeXmpReader : XmpReader() {
|
||||||
// adapted from `XmpReader` to detect and skip large extended XMP
|
// adapted from `XmpReader` to detect and skip large extended XMP
|
||||||
override fun readJpegSegments(segments: Iterable<ByteArray>, metadata: Metadata, segmentType: JpegSegmentType) {
|
override fun readJpegSegments(segments: Iterable<ByteArray>, metadata: Metadata, segmentType: JpegSegmentType) {
|
||||||
val preambleLength = XMP_JPEG_PREAMBLE.length
|
val preambleLength = XMP_JPEG_PREAMBLE.length
|
||||||
|
@ -132,13 +132,13 @@ class MetadataExtractorSafeXmpReader : XmpReader() {
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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
|
// arbitrary size to detect extended XMP that may yield an OOM
|
||||||
private const val segmentTypeSizeDangerThreshold = 3 * (1 shl 20) // MB
|
private const val segmentTypeSizeDangerThreshold = 3 * (1 shl 20) // MB
|
||||||
|
|
||||||
// tighter node limits for faster loading
|
// tighter node limits for faster loading
|
||||||
private val PARSE_OPTIONS = ParseOptions().setXMPNodesToLimit(
|
val PARSE_OPTIONS: ParseOptions = ParseOptions().setXMPNodesToLimit(
|
||||||
mapOf(
|
mapOf(
|
||||||
"photoshop:DocumentAncestors" to 200,
|
"photoshop:DocumentAncestors" to 200,
|
||||||
"xmpMM:History" 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.MediaMetadataRetrieverHelper.getSafeString
|
||||||
import deckers.thibault.aves.metadata.Metadata
|
import deckers.thibault.aves.metadata.Metadata
|
||||||
import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
|
import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
|
||||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper
|
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
||||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
|
import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeDateMillis
|
||||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
|
import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeInt
|
||||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeLong
|
import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeLong
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
import deckers.thibault.aves.utils.StorageUtils
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
||||||
|
@ -161,7 +161,7 @@ class SourceEntry {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Metadata.openSafeInputStream(context, uri, sourceMimeType, sizeBytes)?.use { input ->
|
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
|
// do not switch on specific MIME types, as the reported MIME type could be wrong
|
||||||
// (e.g. PNG registered as JPG)
|
// (e.g. PNG registered as JPG)
|
||||||
|
|
|
@ -6,7 +6,7 @@ import android.provider.MediaStore
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import deckers.thibault.aves.metadata.Metadata
|
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.FieldMap
|
||||||
import deckers.thibault.aves.model.SourceEntry
|
import deckers.thibault.aves.model.SourceEntry
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
|
@ -22,7 +22,7 @@ internal class ContentImageProvider : ImageProvider() {
|
||||||
StorageUtils.openInputStream(context, safeUri)?.use { input ->
|
StorageUtils.openInputStream(context, safeUri)?.use { input ->
|
||||||
// `metadata-extractor` is the most reliable, except for `tiff` (false positives, false negatives)
|
// `metadata-extractor` is the most reliable, except for `tiff` (false positives, false negatives)
|
||||||
// cf https://github.com/drewnoakes/metadata-extractor/issues/296
|
// 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
|
extractorMimeType = it
|
||||||
if (extractorMimeType != sourceMimeType) {
|
if (extractorMimeType != sourceMimeType) {
|
||||||
Log.d(LOG_TAG, "source MIME type is $sourceMimeType but extracted MIME type is $extractorMimeType for uri=$uri")
|
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"))
|
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")
|
throw UnsupportedOperationException("`scanPostMetadataEdit` is not supported by this image provider")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -684,7 +684,7 @@ abstract class ImageProvider {
|
||||||
op: ExifOrientationOp,
|
op: ExifOrientationOp,
|
||||||
callback: ImageOpCallback,
|
callback: ImageOpCallback,
|
||||||
) {
|
) {
|
||||||
val newFields = HashMap<String, Any?>()
|
val newFields: FieldMap = hashMapOf()
|
||||||
|
|
||||||
val success = editExif(context, path, uri, mimeType, callback) { exif ->
|
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)`
|
// 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)
|
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -961,7 +961,7 @@ abstract class ImageProvider {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val newFields = HashMap<String, Any?>()
|
val newFields: FieldMap = hashMapOf()
|
||||||
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
|
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1008,7 +1008,7 @@ abstract class ImageProvider {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val newFields = HashMap<String, Any?>()
|
val newFields: FieldMap = hashMapOf()
|
||||||
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
|
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -193,7 +193,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
val dateModifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
|
val dateModifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
|
||||||
val dateTakenColumn = cursor.getColumnIndex(MediaColumns.DATE_TAKEN)
|
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)
|
val orientationColumn = cursor.getColumnIndex(MediaColumns.ORIENTATION)
|
||||||
|
|
||||||
// video only
|
// video only
|
||||||
|
@ -347,7 +347,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
}
|
}
|
||||||
} catch (securityException: SecurityException) {
|
} catch (securityException: SecurityException) {
|
||||||
// even if the app has access permission granted on the containing directory,
|
// 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
|
// 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) {
|
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)
|
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)
|
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)) { _, _ ->
|
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, _ ->
|
||||||
val projection = arrayOf(
|
val projection = arrayOf(
|
||||||
MediaStore.MediaColumns.DATE_MODIFIED,
|
MediaStore.MediaColumns.DATE_MODIFIED,
|
||||||
|
@ -876,7 +876,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
private val VIDEO_PROJECTION = arrayOf(
|
private val VIDEO_PROJECTION = arrayOf(
|
||||||
*BASE_PROJECTION,
|
*BASE_PROJECTION,
|
||||||
MediaColumns.DURATION,
|
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(
|
*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) arrayOf(
|
||||||
MediaStore.MediaColumns.ORIENTATION,
|
MediaStore.MediaColumns.ORIENTATION,
|
||||||
) else emptyArray()
|
) else emptyArray()
|
||||||
|
|
|
@ -2,7 +2,7 @@ package deckers.thibault.aves.utils
|
||||||
|
|
||||||
import android.os.Build
|
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 {
|
fun <E> MutableList<E>.compatRemoveIf(filter: (t: E) -> Boolean): Boolean {
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
this.removeIf(filter)
|
this.removeIf(filter)
|
||||||
|
@ -17,4 +17,27 @@ fun <E> MutableList<E>.compatRemoveIf(filter: (t: E) -> Boolean): Boolean {
|
||||||
}
|
}
|
||||||
return removed
|
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.ActivityManager
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
|
import android.content.ContentUris
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.database.Cursor
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.util.Log
|
||||||
|
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
||||||
|
|
||||||
object ContextUtils {
|
object ContextUtils {
|
||||||
|
private val LOG_TAG = LogUtils.createTag<ContextUtils>()
|
||||||
|
|
||||||
fun Context.resourceUri(resourceId: Int): Uri = with(resources) {
|
fun Context.resourceUri(resourceId: Int): Uri = with(resources) {
|
||||||
Uri.Builder()
|
Uri.Builder()
|
||||||
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
|
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
|
||||||
|
@ -22,4 +29,40 @@ object ContextUtils {
|
||||||
@Suppress("deprecation")
|
@Suppress("deprecation")
|
||||||
return am.getRunningServices(Integer.MAX_VALUE).any { it.service.className == serviceClass.name }
|
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 ->
|
segments.volumePath?.let { volumePath ->
|
||||||
val dirSet = dirsPerVolume[volumePath] ?: HashSet()
|
val dirSet = dirsPerVolume[volumePath] ?: HashSet()
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
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
|
val relativeDir = segments.relativeDir
|
||||||
if (relativeDir != null) {
|
if (relativeDir != null) {
|
||||||
val dirSegments = relativeDir.split(File.separator).takeWhile { it.isNotEmpty() }
|
val dirSegments = relativeDir.split(File.separator).takeWhile { it.isNotEmpty() }
|
||||||
|
@ -111,11 +111,11 @@ object PermissionManager {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// the requested path is the volume root itself
|
// 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("")
|
dirSet.add("")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// request volume root until Android Q
|
// request volume root until Android 10
|
||||||
dirSet.add("")
|
dirSet.add("")
|
||||||
}
|
}
|
||||||
dirsPerVolume[volumePath] = dirSet
|
dirsPerVolume[volumePath] = dirSet
|
||||||
|
@ -236,7 +236,7 @@ object PermissionManager {
|
||||||
return dirs
|
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,
|
// URI permissions we hold points to a folder that no longer exists,
|
||||||
// so we should remove these obsolete URIs before proceeding.
|
// so we should remove these obsolete URIs before proceeding.
|
||||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||||
|
|
|
@ -82,7 +82,7 @@ object StorageUtils {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getVolumePaths(context: Context): Array<String> {
|
fun getVolumePaths(context: Context): Array<String> {
|
||||||
if (mStorageVolumePaths == null) {
|
if (mStorageVolumePaths == null || mStorageVolumePaths!!.isEmpty()) {
|
||||||
mStorageVolumePaths = findVolumePaths(context)
|
mStorageVolumePaths = findVolumePaths(context)
|
||||||
}
|
}
|
||||||
return mStorageVolumePaths!!
|
return mStorageVolumePaths!!
|
||||||
|
@ -162,22 +162,27 @@ object StorageUtils {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
lateinit var files: List<File>
|
lateinit var files: List<File>
|
||||||
var validFiles: Boolean
|
var validFiles: Boolean
|
||||||
|
val retryInterval = 100L
|
||||||
|
val maxDelay = 1000L
|
||||||
|
var totalDelay = 0L
|
||||||
do {
|
do {
|
||||||
// `getExternalFilesDirs` sometimes include `null` when called right after getting read access
|
// `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.
|
// (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)
|
val externalFilesDirs = context.getExternalFilesDirs(null)
|
||||||
validFiles = !externalFilesDirs.contains(null)
|
validFiles = !externalFilesDirs.contains(null)
|
||||||
if (validFiles) {
|
if (validFiles) {
|
||||||
files = externalFilesDirs.filterNotNull()
|
files = externalFilesDirs.filterNotNull()
|
||||||
} else {
|
} else {
|
||||||
|
Log.d(LOG_TAG, "External files dirs contain `null`. Retrying...")
|
||||||
|
totalDelay += retryInterval
|
||||||
try {
|
try {
|
||||||
Thread.sleep(100)
|
Thread.sleep(retryInterval)
|
||||||
} catch (e: InterruptedException) {
|
} catch (e: InterruptedException) {
|
||||||
Log.e(LOG_TAG, "insomnia", e)
|
Log.e(LOG_TAG, "insomnia", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} while (!validFiles)
|
} while (!validFiles && totalDelay < maxDelay)
|
||||||
paths.addAll(files.mapNotNull(::appSpecificVolumePath))
|
paths.addAll(files.mapNotNull(::appSpecificVolumePath))
|
||||||
} else {
|
} else {
|
||||||
// Primary physical SD-CARD (not emulated)
|
// Primary physical SD-CARD (not emulated)
|
||||||
|
@ -468,7 +473,7 @@ object StorageUtils {
|
||||||
fun requireAccessPermission(context: Context, anyPath: String): Boolean {
|
fun requireAccessPermission(context: Context, anyPath: String): Boolean {
|
||||||
if (isAppFile(context, anyPath)) return false
|
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
|
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) return true
|
||||||
|
|
||||||
val onPrimaryVolume = anyPath.startsWith(getPrimaryVolumePath(context))
|
val onPrimaryVolume = anyPath.startsWith(getPrimaryVolumePath(context))
|
||||||
|
@ -487,7 +492,7 @@ object StorageUtils {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) {
|
||||||
val path = uri.path
|
val path = uri.path
|
||||||
path ?: return uri
|
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/")) {
|
if (path.startsWith("/external/images/") || path.startsWith("/external/video/")) {
|
||||||
// "Caller must hold ACCESS_MEDIA_LOCATION permission to access original"
|
// "Caller must hold ACCESS_MEDIA_LOCATION permission to access original"
|
||||||
if (context.checkSelfPermission(Manifest.permission.ACCESS_MEDIA_LOCATION) == PackageManager.PERMISSION_GRANTED) {
|
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
|
// 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`
|
// This loader relies on `MediaStore.setRequireOriginal` but this yields a `SecurityException`
|
||||||
// for some non image/video content URIs (e.g. `downloads`, `file`)
|
// for some non image/video content URIs (e.g. `downloads`, `file`)
|
||||||
fun getGlideSafeUri(context: Context, uri: Uri, mimeType: String): Uri {
|
fun getGlideSafeUri(context: Context, uri: Uri, mimeType: String): Uri {
|
||||||
|
@ -594,7 +599,7 @@ object StorageUtils {
|
||||||
val effectiveUri = getOriginalUri(context, uri)
|
val effectiveUri = getOriginalUri(context, uri)
|
||||||
return try {
|
return try {
|
||||||
MediaMetadataRetriever().apply {
|
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
|
// (`Input file descriptor already original`), whether we provide the original URI or not
|
||||||
setDataSource(context, effectiveUri)
|
setDataSource(context, effectiveUri)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Aves</string>
|
<string name="app_name">Aves</string>
|
||||||
|
<string name="app_widget_label">Marco de foto</string>
|
||||||
<string name="wallpaper">Fondo de pantalla</string>
|
<string name="wallpaper">Fondo de pantalla</string>
|
||||||
<string name="search_shortcut_short_label">Búsqueda</string>
|
<string name="search_shortcut_short_label">Búsqueda</string>
|
||||||
<string name="videos_shortcut_short_label">Videos</string>
|
<string name="videos_shortcut_short_label">Videos</string>
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Aves</string>
|
<string name="app_name">Aves</string>
|
||||||
|
<string name="app_widget_label">Bingkai Foto</string>
|
||||||
<string name="wallpaper">Wallpaper</string>
|
<string name="wallpaper">Wallpaper</string>
|
||||||
<string name="search_shortcut_short_label">Cari</string>
|
<string name="search_shortcut_short_label">Cari</string>
|
||||||
<string name="videos_shortcut_short_label">Video</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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Aves</string>
|
<string name="app_name">Aves</string>
|
||||||
|
<string name="app_widget_label">Фоторамка</string>
|
||||||
<string name="wallpaper">Обои</string>
|
<string name="wallpaper">Обои</string>
|
||||||
<string name="search_shortcut_short_label">Поиск</string>
|
<string name="search_shortcut_short_label">Поиск</string>
|
||||||
<string name="videos_shortcut_short_label">Видео</string>
|
<string name="videos_shortcut_short_label">Видео</string>
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Aves</string>
|
<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="wallpaper">Duvar kağıdı</string>
|
||||||
<string name="search_shortcut_short_label">Arama</string>
|
<string name="search_shortcut_short_label">Arama</string>
|
||||||
<string name="videos_shortcut_short_label">Videolar</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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
<style name="NormalTheme" parent="@android:style/Theme.Light.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:windowBackground">?android:colorBackground</item>
|
||||||
|
|
||||||
|
<!-- API28+, draws next to the notch in fullscreen -->
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode" tools:targetApi="28">shortEdges</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
@ -7,7 +7,7 @@ buildscript {
|
||||||
maven { url 'https://developer.huawei.com/repo/' }
|
maven { url 'https://developer.huawei.com/repo/' }
|
||||||
}
|
}
|
||||||
dependencies {
|
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"
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
// GMS & Firebase Crashlytics (used by some flavors only)
|
// GMS & Firebase Crashlytics (used by some flavors only)
|
||||||
classpath 'com.google.gms:google-services:4.3.13'
|
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 |