diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 000000000..4c554092d
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,3 @@
+github: deckerst
+liberapay: deckerst
+custom: https://www.paypal.me/ThibaultDeckersFr
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index 40d2592d7..7a4728df5 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -17,7 +17,7 @@ jobs:
# Available versions may lag behind https://github.com/flutter/flutter.git
- uses: subosito/flutter-action@v2
with:
- flutter-version: '3.3.0-0.0.pre'
+ flutter-version: '3.3.0-0.5.pre'
channel: 'beta'
- name: Clone the repository.
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b213d6512..23aa3e59e 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -19,7 +19,7 @@ jobs:
# Available versions may lag behind https://github.com/flutter/flutter.git
- uses: subosito/flutter-action@v2
with:
- flutter-version: '3.3.0-0.0.pre'
+ flutter-version: '3.3.0-0.5.pre'
channel: 'beta'
# Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1):
@@ -56,15 +56,15 @@ jobs:
rm release.keystore.asc
mkdir outputs
(cd scripts/; ./apply_flavor_play.sh)
- flutter build appbundle -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_3.3.0-0.0.pre.sksl.json
+ flutter build appbundle -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_3.3.0-0.5.pre.sksl.json
cp build/app/outputs/bundle/playRelease/*.aab outputs
- flutter build apk -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_3.3.0-0.0.pre.sksl.json
+ flutter build apk -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_3.3.0-0.5.pre.sksl.json
cp build/app/outputs/apk/play/release/*.apk outputs
(cd scripts/; ./apply_flavor_huawei.sh)
- flutter build apk -t lib/main_huawei.dart --flavor huawei --bundle-sksl-path shaders_3.3.0-0.0.pre.sksl.json
+ flutter build apk -t lib/main_huawei.dart --flavor huawei --bundle-sksl-path shaders_3.3.0-0.5.pre.sksl.json
cp build/app/outputs/apk/huawei/release/*.apk outputs
(cd scripts/; ./apply_flavor_izzy.sh)
- flutter build apk -t lib/main_izzy.dart --flavor izzy --split-per-abi --bundle-sksl-path shaders_3.3.0-0.0.pre.sksl.json
+ flutter build apk -t lib/main_izzy.dart --flavor izzy --split-per-abi --bundle-sksl-path shaders_3.3.0-0.5.pre.sksl.json
cp build/app/outputs/apk/izzy/release/*.apk outputs
rm $AVES_STORE_FILE
env:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ed35c5db5..64f2a4152 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,32 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
+## [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
+
## [v1.6.11] - 2022-07-26
### Added
@@ -31,7 +57,7 @@ All notable changes to this project will be documented in this file.
- slideshow
- set wallpaper from any media
-- optional dynamic accent color on Android 12+
+- optional dynamic accent color on Android >=12
- Search: date/dimension/size field equality (undocumented)
- support Android 13 (API 33)
- Turkish translation (thanks metezd)
@@ -126,7 +152,7 @@ All notable changes to this project will be documented in this file.
### Fixed
-- app launch despite faulty storage volumes on Android 11+
+- app launch despite faulty storage volumes on Android >=11
## [v1.6.2] - 2022-03-07
@@ -311,7 +337,7 @@ All notable changes to this project will be documented in this file.
- Info: improved display for PNG text metadata, XMP and others
- Export: output format selection
- Search: added raw filter
-- Support modifying files in the Download folder on Android 11+
+- Support modifying files in the Download folder on Android >=11
### Changed
@@ -321,7 +347,7 @@ All notable changes to this project will be documented in this file.
### Fixed
- hide root album of hidden path
-- gesture & spacing handling for Android 10+ navigation gestures
+- gesture & spacing handling for Android >=10 navigation gestures
- renaming was leaving behind obsolete items in some cases
- speeding up videos on Xiaomi devices
@@ -384,7 +410,7 @@ All notable changes to this project will be documented in this file.
- Map & Stats from selection
- Map: item browsing, rotation control
- Navigation menu customization
-- shortcut support on older devices (API < 26)
+- shortcut support on older devices (API <26)
- support Android 12/S (API 31)
## [v1.4.8] - 2021-08-08
@@ -398,7 +424,7 @@ All notable changes to this project will be documented in this file.
### Fixed
- auto album identification and naming
-- opening HEIC images from downloads content URI on Android R+
+- opening HEIC images from downloads content URI on Android >=11
## [v1.4.7] - 2021-08-06 [YANKED]
@@ -602,7 +628,7 @@ All notable changes to this project will be documented in this file.
- Viewer: support for multi-track HEIF
- Viewer: export image (including multipage TIFF/HEIF and images embedded in XMP)
-- Info: show owner app (Android Q and up)
+- Info: show owner app (Android >=10)
- listen to Media Store changes
### Changed
@@ -629,7 +655,7 @@ upgraded libtiff to 4.2.0 for TIFF decoding
### Fixed
-- prevent scrolling when using Android Q style gesture navigation
+- prevent scrolling when using Android 10 style gesture navigation
## [v1.3.1] - 2021-01-04
diff --git a/README.md b/README.md
index 4a6d48055..c07305529 100644
--- a/README.md
+++ b/README.md
@@ -93,7 +93,7 @@ At this stage this project does *not* accept PRs, except for translations.
### Translations
-If you want to translate this app in your language and share the result, [there is a guide](https://github.com/deckerst/aves/wiki/Contributing-to-Translations). English, Korean and French are already handled by me. Russian, German, Spanish, Portuguese, Indonesian, Japanese, Italian, Chinese & Turkish are handled by generous volunteers.
+If you want to translate this app in your language and share the result, [there is a guide](https://github.com/deckerst/aves/wiki/Contributing-to-Translations). English, Korean and French are already handled by me. Russian, German, Spanish, Portuguese, Indonesian, Japanese, Italian, Chinese, Turkish & Dutch are handled by generous volunteers.
### Donations
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 8e1be5b28..c6189d907 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -12,8 +12,8 @@ This change eventually prevents building the app with Flutter v3.0.2.
android:installLocation="auto">
@@ -31,25 +31,25 @@ This change eventually prevents building the app with Flutter v3.0.2.
-
+
-
+
-
+
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt
index 81d468d32..ad2196561 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt
@@ -285,7 +285,7 @@ open class MainActivity : FlutterActivity() {
val filters = intent.getStringArrayExtra(EXTRA_KEY_FILTERS_ARRAY)?.toList()
if (filters != null) return filters
- // fallback for shortcuts created on API < 26
+ // fallback for shortcuts created on API <26
val filterString = intent.getStringExtra(EXTRA_KEY_FILTERS_STRING)
if (filterString != null) {
return filterString.split(EXTRA_STRING_ARRAY_SEPARATOR)
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/ScreenSaverService.kt b/android/app/src/main/kotlin/deckers/thibault/aves/ScreenSaverService.kt
index b6373d3ae..18847b2ea 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/ScreenSaverService.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/ScreenSaverService.kt
@@ -102,6 +102,7 @@ class ScreenSaverService : DreamService() {
MethodChannel(messenger, MediaFetchHandler.CHANNEL).setMethodCallHandler(MediaFetchHandler(this))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
+ MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
// - need ContextWrapper
MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this))
// - need Service
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/WallpaperActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/WallpaperActivity.kt
index 4fe1f9a6a..ccee5b904 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/WallpaperActivity.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/WallpaperActivity.kt
@@ -36,6 +36,7 @@ class WallpaperActivity : FlutterActivity() {
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
MethodChannel(messenger, MediaFetchHandler.CHANNEL).setMethodCallHandler(MediaFetchHandler(this))
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
+ MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
// - need ContextWrapper
MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this))
MethodChannel(messenger, WallpaperHandler.CHANNEL).setMethodCallHandler(WallpaperHandler(this))
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/Coresult.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/Coresult.kt
index 6e2f770c5..7dfb84dc4 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/Coresult.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/Coresult.kt
@@ -17,7 +17,7 @@ class Coresult internal constructor(private val call: MethodCall, private val me
try {
methodResult.success(result)
} catch (e: Exception) {
- MainActivity.notifyError("failed to reply success for method=${call.method}, result=$result, exception=$e")
+ MainActivity.notifyError("failed to reply success for method=${call.method}, result=$result, exception=\n${e.stackTraceToString()}")
}
}
}
@@ -27,7 +27,7 @@ class Coresult internal constructor(private val call: MethodCall, private val me
try {
methodResult.error(errorCode, errorMessage, errorDetails)
} catch (e: Exception) {
- MainActivity.notifyError("failed to reply error for method=${call.method}, errorCode=$errorCode, errorMessage=$errorMessage, errorDetails=$errorDetails, exception=$e")
+ MainActivity.notifyError("failed to reply error for method=${call.method}, errorCode=$errorCode, errorMessage=$errorMessage, errorDetails=$errorDetails, exception=\n${e.stackTraceToString()}")
}
}
}
@@ -37,7 +37,7 @@ class Coresult internal constructor(private val call: MethodCall, private val me
try {
methodResult.notImplemented()
} catch (e: Exception) {
- MainActivity.notifyError("failed to reply notImplemented for method=${call.method}, exception=$e")
+ MainActivity.notifyError("failed to reply notImplemented for method=${call.method}, exception=\n${e.stackTraceToString()}")
}
}
}
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt
index f4019a6b5..e056cb3af 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt
@@ -15,7 +15,11 @@ import android.util.Log
import androidx.exifinterface.media.ExifInterface
import com.drew.metadata.file.FileTypeDirectory
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
-import deckers.thibault.aves.metadata.*
+import deckers.thibault.aves.metadata.ExifInterfaceHelper
+import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
+import deckers.thibault.aves.metadata.Metadata
+import deckers.thibault.aves.metadata.PixyMetaHelper
+import deckers.thibault.aves.metadata.metadataextractor.Helper
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface
@@ -284,7 +288,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
- val metadata = MetadataExtractorHelper.safeRead(input)
+ val metadata = Helper.safeRead(input)
metadataMap["mimeType"] = metadata.getDirectoriesOfType(FileTypeDirectory::class.java).joinToString { dir ->
if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) {
dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt
index 15511c3bf..4ad4d93fc 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt
@@ -12,10 +12,10 @@ import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
import deckers.thibault.aves.metadata.Metadata
-import deckers.thibault.aves.metadata.MetadataExtractorHelper
import deckers.thibault.aves.metadata.MultiPage
-import deckers.thibault.aves.metadata.XMP
import deckers.thibault.aves.metadata.XMP.getSafeStructField
+import deckers.thibault.aves.metadata.XMPPropName
+import deckers.thibault.aves.metadata.metadataextractor.Helper
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.provider.ContentImageProvider
import deckers.thibault.aves.model.provider.ImageProvider
@@ -118,7 +118,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
retriever.embeddedPicture?.let { bytes ->
var embedMimeType: String? = null
bytes.inputStream().use { input ->
- MetadataExtractorHelper.readMimeType(input)?.let { embedMimeType = it }
+ Helper.readMimeType(input)?.let { embedMimeType = it }
}
embedMimeType?.let { mime ->
copyEmbeddedBytes(result, mime, displayName, bytes.inputStream())
@@ -140,26 +140,34 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
val uri = call.argument("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument("sizeBytes")?.toLong()
val displayName = call.argument("displayName")
- val dataPropPath = call.argument("propPath")
+ val dataProp = call.argument>("propPath")
val embedMimeType = call.argument("propMimeType")
- if (mimeType == null || uri == null || dataPropPath == null || embedMimeType == null) {
+ if (mimeType == null || uri == null || dataProp == null || embedMimeType == null) {
result.error("extractXmpDataProp-args", "missing arguments", null)
return
}
+ val props = dataProp.mapNotNull {
+ when (it) {
+ is List<*> -> XMPPropName(it.first() as String, it.last() as String)
+ is Int -> it
+ else -> null
+ }
+ }
+
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
- val metadata = MetadataExtractorHelper.safeRead(input)
+ val metadata = Helper.safeRead(input)
// data can be large and stored in "Extended XMP",
// which is returned as a second XMP directory
val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java)
try {
- val embedBytes: ByteArray = if (!dataPropPath.contains('/')) {
- val propNs = XMP.namespaceForPropPath(dataPropPath)
- xmpDirs.mapNotNull { it.xmpMeta.getPropertyBase64(propNs, dataPropPath) }.first()
+ val embedBytes: ByteArray = if (props.size == 1) {
+ val prop = props.first() as XMPPropName
+ xmpDirs.mapNotNull { it.xmpMeta.getPropertyBase64(prop.nsUri, prop.toString()) }.first()
} else {
- xmpDirs.mapNotNull { it.xmpMeta.getSafeStructField(dataPropPath) }.first().let {
+ xmpDirs.mapNotNull { it.xmpMeta.getSafeStructField(props) }.first().let {
XMPUtils.decodeBase64(it.value)
}
}
@@ -167,7 +175,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
copyEmbeddedBytes(result, embedMimeType, displayName, embedBytes.inputStream())
return
} catch (e: XMPException) {
- result.error("extractXmpDataProp-xmp", "failed to read XMP directory for uri=$uri prop=$dataPropPath", e.message)
+ result.error("extractXmpDataProp-xmp", "failed to read XMP directory for uri=$uri prop=$dataProp", e.message)
return
}
}
@@ -179,7 +187,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
Log.w(LOG_TAG, "failed to extract file from XMP", e)
}
}
- result.error("extractXmpDataProp-empty", "failed to extract file from XMP uri=$uri prop=$dataPropPath", null)
+ result.error("extractXmpDataProp-empty", "failed to extract file from XMP uri=$uri prop=$dataProp", null)
}
private fun copyEmbeddedBytes(result: MethodChannel.Result, mimeType: String, displayName: String?, embeddedByteStream: InputStream) {
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt
index 45234542f..6314e9c2a 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt
@@ -1,16 +1,14 @@
package deckers.thibault.aves.channel.calls
import android.annotation.SuppressLint
-import android.content.ContentUris
import android.content.Context
-import android.database.Cursor
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
-import android.provider.MediaStore
import android.util.Log
import androidx.exifinterface.media.ExifInterface
import com.adobe.internal.xmp.XMPException
+import com.adobe.internal.xmp.XMPMeta
import com.adobe.internal.xmp.XMPMetaFactory
import com.adobe.internal.xmp.options.SerializeOptions
import com.adobe.internal.xmp.properties.XMPPropertyInfo
@@ -44,38 +42,40 @@ import deckers.thibault.aves.metadata.Metadata.DIR_EXIF_GEOTIFF
import deckers.thibault.aves.metadata.Metadata.DIR_PNG_TEXTUAL_DATA
import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
import deckers.thibault.aves.metadata.Metadata.isFlippedForExifCode
-import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_ITXT_DIR_NAME
-import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_LAST_MODIFICATION_TIME_FORMAT
-import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_TIME_DIR_NAME
-import deckers.thibault.aves.metadata.MetadataExtractorHelper.containsGeoTiffTags
-import deckers.thibault.aves.metadata.MetadataExtractorHelper.extractGeoKeys
-import deckers.thibault.aves.metadata.MetadataExtractorHelper.extractPngProfile
-import deckers.thibault.aves.metadata.MetadataExtractorHelper.getDateDigitizedMillis
-import deckers.thibault.aves.metadata.MetadataExtractorHelper.getDateModifiedMillis
-import deckers.thibault.aves.metadata.MetadataExtractorHelper.getDateOriginalMillis
-import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeBoolean
-import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
-import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
-import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeRational
-import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
-import deckers.thibault.aves.metadata.MetadataExtractorHelper.isPngTextDir
+import deckers.thibault.aves.metadata.XMP.doesPropExist
+import deckers.thibault.aves.metadata.XMP.getPropArrayItemValues
import deckers.thibault.aves.metadata.XMP.getSafeDateMillis
import deckers.thibault.aves.metadata.XMP.getSafeInt
import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText
import deckers.thibault.aves.metadata.XMP.getSafeString
import deckers.thibault.aves.metadata.XMP.isMotionPhoto
import deckers.thibault.aves.metadata.XMP.isPanorama
+import deckers.thibault.aves.metadata.metadataextractor.Helper
+import deckers.thibault.aves.metadata.metadataextractor.Helper.PNG_ITXT_DIR_NAME
+import deckers.thibault.aves.metadata.metadataextractor.Helper.PNG_LAST_MODIFICATION_TIME_FORMAT
+import deckers.thibault.aves.metadata.metadataextractor.Helper.PNG_TIME_DIR_NAME
+import deckers.thibault.aves.metadata.metadataextractor.Helper.containsGeoTiffTags
+import deckers.thibault.aves.metadata.metadataextractor.Helper.extractGeoKeys
+import deckers.thibault.aves.metadata.metadataextractor.Helper.extractPngProfile
+import deckers.thibault.aves.metadata.metadataextractor.Helper.getDateDigitizedMillis
+import deckers.thibault.aves.metadata.metadataextractor.Helper.getDateModifiedMillis
+import deckers.thibault.aves.metadata.metadataextractor.Helper.getDateOriginalMillis
+import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeBoolean
+import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeDateMillis
+import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeInt
+import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeRational
+import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeString
+import deckers.thibault.aves.metadata.metadataextractor.Helper.isPngTextDir
import deckers.thibault.aves.model.FieldMap
+import deckers.thibault.aves.utils.ContextUtils.queryContentResolverProp
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.TIFF_EXTENSION_PATTERN
import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface
import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor
import deckers.thibault.aves.utils.MimeTypes.isHeic
-import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.StorageUtils
-import deckers.thibault.aves.utils.UriUtils.tryParseId
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
@@ -83,6 +83,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
+import org.json.JSONObject
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets
import java.text.DecimalFormat
@@ -106,6 +107,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
"hasContentResolverProp" -> ioScope.launch { safe(call, result, ::hasContentResolverProp) }
"getContentResolverProp" -> ioScope.launch { safe(call, result, ::getContentResolverProp) }
"getDate" -> ioScope.launch { safe(call, result, ::getDate) }
+ "getDescription" -> ioScope.launch { safe(call, result, ::getDescription) }
else -> result.notImplemented()
}
}
@@ -123,18 +125,43 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
var foundExif = false
var foundXmp = false
+ fun processXmp(xmpMeta: XMPMeta, dirMap: MutableMap) {
+ try {
+ for (prop in xmpMeta) {
+ if (prop is XMPPropertyInfo) {
+ val path = prop.path
+ if (path?.isNotEmpty() == true) {
+ val value = if (XMP.isDataPath(path)) "[skipped]" else prop.value
+ if (value?.isNotEmpty() == true) {
+ dirMap[path] = value
+ }
+ }
+ }
+ }
+ } catch (e: XMPException) {
+ Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
+ }
+ // remove this stat as it is not actual XMP data
+ dirMap.remove(XmpDirectory().getTagName(XmpDirectory.TAG_XMP_VALUE_COUNT))
+ // add schema prefixes for namespace resolution
+ val prefixes = XMPMetaFactory.getSchemaRegistry().prefixes
+ dirMap["schemaRegistryPrefixes"] = JSONObject(prefixes).toString()
+ }
+
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
- val metadata = MetadataExtractorHelper.safeRead(input)
+ val metadata = Helper.safeRead(input)
foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 }
foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
+
val uuidDirCount = HashMap()
val dirByName = metadata.directories.filter {
(it.tagCount > 0 || it.errorCount > 0)
&& it !is FileTypeDirectory
&& it !is AviDirectory
}.groupBy { dir -> dir.name }
+
for (dirEntry in dirByName) {
val baseDirName = dirEntry.key
@@ -262,23 +289,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
if (dir is XmpDirectory) {
- try {
- for (prop in dir.xmpMeta) {
- if (prop is XMPPropertyInfo) {
- val path = prop.path
- if (path?.isNotEmpty() == true) {
- val value = if (XMP.isDataPath(path)) "[skipped]" else prop.value
- if (value?.isNotEmpty() == true) {
- dirMap[path] = value
- }
- }
- }
- }
- } catch (e: XMPException) {
- Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
- }
- // remove this stat as it is not actual XMP data
- dirMap.remove(dir.getTagName(XmpDirectory.TAG_XMP_VALUE_COUNT))
+ processXmp(dir.xmpMeta, dirMap)
}
if (dir is Mp4UuidBoxDirectory) {
@@ -356,6 +367,13 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
+ XMP.checkHeic(context, uri, mimeType, foundXmp) { xmpMeta ->
+ val thisDirName = XmpDirectory().name
+ val dirMap = metadataMap[thisDirName] ?: HashMap()
+ metadataMap[thisDirName] = dirMap
+ processXmp(xmpMeta, dirMap)
+ }
+
if (isVideo(mimeType)) {
// this is used as fallback when the video metadata cannot be found on the Dart side
// and to identify whether there is an accessible cover image
@@ -409,9 +427,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
// - XMP / photoshop:DateCreated
// - PNG / TIME / LAST_MODIFICATION_TIME
// - Video / METADATA_KEY_DATE
- // set `KEY_XMP_TITLE_DESCRIPTION` from these fields (by precedence):
+ // set `KEY_XMP_TITLE` from this field:
// - XMP / dc:title
- // - XMP / dc:description
// set `KEY_XMP_SUBJECTS` from these fields (by precedence):
// - XMP / dc:subject
// - IPTC / keywords
@@ -447,12 +464,51 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
) {
var flags = (metadataMap[KEY_FLAGS] ?: 0) as Int
var foundExif = false
+ var foundXmp = false
+
+ fun processXmp(xmpMeta: XMPMeta) {
+ try {
+ if (xmpMeta.doesPropExist(XMP.DC_SUBJECT_PROP_NAME)) {
+ val values = xmpMeta.getPropArrayItemValues(XMP.DC_SUBJECT_PROP_NAME)
+ metadataMap[KEY_XMP_SUBJECTS] = values.joinToString(XMP_SUBJECTS_SEPARATOR)
+ }
+ xmpMeta.getSafeLocalizedText(XMP.DC_TITLE_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE] = it }
+ if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
+ xmpMeta.getSafeDateMillis(XMP.XMP_CREATE_DATE_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it }
+ if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
+ xmpMeta.getSafeDateMillis(XMP.PS_DATE_CREATED_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it }
+ }
+ }
+
+ xmpMeta.getSafeInt(XMP.XMP_RATING_PROP_NAME) { metadataMap[KEY_RATING] = it }
+ if (!metadataMap.containsKey(KEY_RATING)) {
+ xmpMeta.getSafeInt(XMP.MS_RATING_PROP_NAME) { percentRating ->
+ // values of 1,25,50,75,99% correspond to 1,2,3,4,5 stars
+ val standardRating = (percentRating / 25f).roundToInt() + 1
+ metadataMap[KEY_RATING] = standardRating
+ }
+ }
+
+ // identification of panorama (aka photo sphere)
+ if (xmpMeta.isPanorama()) {
+ flags = flags or MASK_IS_360
+ }
+
+ // identification of motion photo
+ if (xmpMeta.isMotionPhoto()) {
+ flags = flags or MASK_IS_MULTIPAGE or MASK_IS_MOTION_PHOTO
+ }
+ } catch (e: XMPException) {
+ Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
+ }
+ }
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
- val metadata = MetadataExtractorHelper.safeRead(input)
+ val metadata = Helper.safeRead(input)
foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 }
+ foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
// File type
for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) {
@@ -491,7 +547,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
dir.getSafeInt(ExifDirectoryBase.TAG_ORIENTATION) {
val orientation = it
- if (isFlippedForExifCode(orientation)) flags = flags or MASK_IS_FLIPPED
+ if (isFlippedForExifCode(orientation)) {
+ flags = flags or MASK_IS_FLIPPED
+ }
metadataMap[KEY_ROTATION_DEGREES] = getRotationDegreesForExifCode(orientation)
}
}
@@ -506,47 +564,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
// XMP
- for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
- val xmpMeta = dir.xmpMeta
- try {
- if (xmpMeta.doesPropertyExist(XMP.DC_SCHEMA_NS, XMP.DC_SUBJECT_PROP_NAME)) {
- val count = xmpMeta.countArrayItems(XMP.DC_SCHEMA_NS, XMP.DC_SUBJECT_PROP_NAME)
- val values = (1 until count + 1).map { xmpMeta.getArrayItem(XMP.DC_SCHEMA_NS, XMP.DC_SUBJECT_PROP_NAME, it).value }
- metadataMap[KEY_XMP_SUBJECTS] = values.joinToString(XMP_SUBJECTS_SEPARATOR)
- }
- xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.DC_TITLE_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it }
- if (!metadataMap.containsKey(KEY_XMP_TITLE_DESCRIPTION)) {
- xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.DC_DESCRIPTION_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it }
- }
- if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
- xmpMeta.getSafeDateMillis(XMP.XMP_SCHEMA_NS, XMP.XMP_CREATE_DATE_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it }
- if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
- xmpMeta.getSafeDateMillis(XMP.PHOTOSHOP_SCHEMA_NS, XMP.PS_DATE_CREATED_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it }
- }
- }
-
- xmpMeta.getSafeInt(XMP.XMP_SCHEMA_NS, XMP.XMP_RATING_PROP_NAME) { metadataMap[KEY_RATING] = it }
- if (!metadataMap.containsKey(KEY_RATING)) {
- xmpMeta.getSafeInt(XMP.MICROSOFTPHOTO_SCHEMA_NS, XMP.MS_RATING_PROP_NAME) { percentRating ->
- // values of 1,25,50,75,99% correspond to 1,2,3,4,5 stars
- val standardRating = (percentRating / 25f).roundToInt() + 1
- metadataMap[KEY_RATING] = standardRating
- }
- }
-
- // identification of panorama (aka photo sphere)
- if (xmpMeta.isPanorama()) {
- flags = flags or MASK_IS_360
- }
-
- // identification of motion photo
- if (xmpMeta.isMotionPhoto()) {
- flags = flags or MASK_IS_MULTIPAGE
- }
- } catch (e: XMPException) {
- Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
- }
- }
+ metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp)
// XMP fallback to IPTC
if (!metadataMap.containsKey(KEY_XMP_SUBJECTS)) {
@@ -574,7 +592,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
MimeTypes.GIF -> {
// identification of animated GIF
- if (metadata.containsDirectoryOfType(GifAnimationDirectory::class.java)) flags = flags or MASK_IS_ANIMATED
+ if (metadata.containsDirectoryOfType(GifAnimationDirectory::class.java)) {
+ flags = flags or MASK_IS_ANIMATED
+ }
}
MimeTypes.WEBP -> {
// identification of animated WEBP
@@ -587,7 +607,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
MimeTypes.TIFF -> {
// identification of GeoTIFF
for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
- if (dir.containsGeoTiffTags()) flags = flags or MASK_IS_GEOTIFF
+ if (dir.containsGeoTiffTags()) {
+ flags = flags or MASK_IS_GEOTIFF
+ }
}
}
}
@@ -634,6 +656,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
+ XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp)
+
if (mimeType == MimeTypes.TIFF && MultiPage.isMultiPageTiff(context, uri)) flags = flags or MASK_IS_MULTIPAGE
metadataMap[KEY_FLAGS] = flags
@@ -718,7 +742,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
- val metadata = MetadataExtractorHelper.safeRead(input)
+ val metadata = Helper.safeRead(input)
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
foundExif = true
dir.getSafeRational(ExifDirectoryBase.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it.numerator.toDouble() / it.denominator }
@@ -768,7 +792,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
- val metadata = MetadataExtractorHelper.safeRead(input)
+ val metadata = Helper.safeRead(input)
val fields = HashMap()
for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
if (dir.containsGeoTiffTags()) {
@@ -801,16 +825,20 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
val mimeType = call.argument("mimeType")
val uri = call.argument("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument("sizeBytes")?.toLong()
- if (mimeType == null || uri == null || sizeBytes == null) {
+ val isMotionPhoto = call.argument("isMotionPhoto")
+ if (mimeType == null || uri == null || sizeBytes == null || isMotionPhoto == null) {
result.error("getMultiPageInfo-args", "missing arguments", null)
return
}
- val pages: ArrayList? = when (mimeType) {
- MimeTypes.HEIC, MimeTypes.HEIF -> MultiPage.getHeicTracks(context, uri)
- MimeTypes.JPEG -> MultiPage.getMotionPhotoPages(context, uri, mimeType, sizeBytes = sizeBytes)
- MimeTypes.TIFF -> MultiPage.getTiffPages(context, uri)
- else -> null
+ val pages: ArrayList? = if (isMotionPhoto) {
+ MultiPage.getMotionPhotoPages(context, uri, mimeType, sizeBytes = sizeBytes)
+ } else {
+ when (mimeType) {
+ MimeTypes.HEIC, MimeTypes.HEIF -> MultiPage.getHeicTracks(context, uri)
+ MimeTypes.TIFF -> MultiPage.getTiffPages(context, uri)
+ else -> null
+ }
}
if (pages?.isEmpty() == true) {
result.error("getMultiPageInfo-empty", "failed to get pages for mimeType=$mimeType uri=$uri", null)
@@ -828,25 +856,29 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
return
}
+ var foundXmp = false
+ val fields: FieldMap = hashMapOf()
+
+ fun processXmp(xmpMeta: XMPMeta) {
+ try {
+ xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_LEFT_PROP_NAME) { fields["croppedAreaLeft"] = it }
+ xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_TOP_PROP_NAME) { fields["croppedAreaTop"] = it }
+ xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_WIDTH_PROP_NAME) { fields["croppedAreaWidth"] = it }
+ xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_HEIGHT_PROP_NAME) { fields["croppedAreaHeight"] = it }
+ xmpMeta.getSafeInt(XMP.GPANO_FULL_PANO_WIDTH_PROP_NAME) { fields["fullPanoWidth"] = it }
+ xmpMeta.getSafeInt(XMP.GPANO_FULL_PANO_HEIGHT_PROP_NAME) { fields["fullPanoHeight"] = it }
+ xmpMeta.getSafeString(XMP.GPANO_PROJECTION_TYPE_PROP_NAME) { fields["projectionType"] = it }
+ } catch (e: XMPException) {
+ Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
+ }
+ }
+
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
- val metadata = MetadataExtractorHelper.safeRead(input)
- val fields: FieldMap = hashMapOf(
- "projectionType" to XMP.GPANO_PROJECTION_TYPE_DEFAULT,
- )
- for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
- val xmpMeta = dir.xmpMeta
- xmpMeta.getSafeInt(XMP.GPANO_SCHEMA_NS, XMP.GPANO_CROPPED_AREA_LEFT_PROP_NAME) { fields["croppedAreaLeft"] = it }
- xmpMeta.getSafeInt(XMP.GPANO_SCHEMA_NS, XMP.GPANO_CROPPED_AREA_TOP_PROP_NAME) { fields["croppedAreaTop"] = it }
- xmpMeta.getSafeInt(XMP.GPANO_SCHEMA_NS, XMP.GPANO_CROPPED_AREA_WIDTH_PROP_NAME) { fields["croppedAreaWidth"] = it }
- xmpMeta.getSafeInt(XMP.GPANO_SCHEMA_NS, XMP.GPANO_CROPPED_AREA_HEIGHT_PROP_NAME) { fields["croppedAreaHeight"] = it }
- xmpMeta.getSafeInt(XMP.GPANO_SCHEMA_NS, XMP.GPANO_FULL_PANO_WIDTH_PROP_NAME) { fields["fullPanoWidth"] = it }
- xmpMeta.getSafeInt(XMP.GPANO_SCHEMA_NS, XMP.GPANO_FULL_PANO_HEIGHT_PROP_NAME) { fields["fullPanoHeight"] = it }
- xmpMeta.getSafeString(XMP.GPANO_SCHEMA_NS, XMP.GPANO_PROJECTION_TYPE_PROP_NAME) { fields["projectionType"] = it }
- }
- result.success(fields)
- return
+ val metadata = Helper.safeRead(input)
+ foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
+ metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp)
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
@@ -856,7 +888,15 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
}
}
- result.error("getPanoramaInfo-empty", "failed to get info for mimeType=$mimeType uri=$uri", null)
+
+ XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp)
+
+ if (fields.isEmpty()) {
+ result.error("getPanoramaInfo-empty", "failed to get info for mimeType=$mimeType uri=$uri", null)
+ } else {
+ fields["projectionType"] = fields["projectionType"] ?: XMP.GPANO_PROJECTION_TYPE_DEFAULT
+ result.success(fields)
+ }
}
private fun getIptc(call: MethodCall, result: MethodChannel.Result) {
@@ -892,13 +932,23 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
return
}
+ var foundXmp = false
+ val xmpStrings = mutableListOf()
+
+ fun processXmp(xmpMeta: XMPMeta) {
+ try {
+ xmpStrings.add(XMPMetaFactory.serializeToString(xmpMeta, xmpSerializeOptions))
+ } catch (e: XMPException) {
+ Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
+ }
+ }
+
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
- val metadata = MetadataExtractorHelper.safeRead(input)
- val xmpStrings = metadata.getDirectoriesOfType(XmpDirectory::class.java).mapNotNull { XMPMetaFactory.serializeToString(it.xmpMeta, xmpSerializeOptions) }
- result.success(xmpStrings.toMutableList())
- return
+ val metadata = Helper.safeRead(input)
+ foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
+ metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp)
}
} catch (e: Exception) {
result.error("getXmp-exception", "failed to read XMP for mimeType=$mimeType uri=$uri", e.message)
@@ -912,7 +962,13 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
- result.success(null)
+ XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp)
+
+ if (xmpStrings.isEmpty()) {
+ result.success(null)
+ } else {
+ result.success(xmpStrings)
+ }
}
private fun hasContentResolverProp(call: MethodCall, result: MethodChannel.Result) {
@@ -942,48 +998,12 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
return
}
- var contentUri: Uri = uri
- if (StorageUtils.isMediaStoreContentUri(uri)) {
- uri.tryParseId()?.let { id ->
- contentUri = when {
- isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
- isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
- else -> uri
- }
- contentUri = StorageUtils.getOriginalUri(context, contentUri)
- }
- }
-
- val projection = arrayOf(prop)
- val cursor: Cursor?
try {
- cursor = context.contentResolver.query(contentUri, projection, null, null, null)
+ val value = context.queryContentResolverProp(uri, mimeType, prop)
+ result.success(value?.toString())
} catch (e: Exception) {
- // throws SQLiteException when the requested prop is not a known column
- result.error("getContentResolverProp-query", "failed to query for contentUri=$contentUri", e.message)
- return
+ result.error("getContentResolverProp-query", "failed to query prop for uri=$uri", e.message)
}
-
- if (cursor == null || !cursor.moveToFirst()) {
- result.error("getContentResolverProp-cursor", "failed to get cursor for contentUri=$contentUri", null)
- return
- }
-
- var value: Any? = null
- try {
- value = when (cursor.getType(0)) {
- Cursor.FIELD_TYPE_NULL -> null
- Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(0)
- Cursor.FIELD_TYPE_FLOAT -> cursor.getFloat(0)
- Cursor.FIELD_TYPE_STRING -> cursor.getString(0)
- Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(0)
- else -> null
- }
- } catch (e: Exception) {
- Log.w(LOG_TAG, "failed to get value for key=$prop", e)
- }
- cursor.close()
- result.success(value?.toString())
}
private fun getDate(call: MethodCall, result: MethodChannel.Result) {
@@ -1000,7 +1020,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
- val metadata = MetadataExtractorHelper.safeRead(input)
+ val metadata = Helper.safeRead(input)
val tag = when (field) {
ExifInterface.TAG_DATETIME -> ExifIFD0Directory.TAG_DATETIME
ExifInterface.TAG_DATETIME_DIGITIZED -> ExifSubIFDDirectory.TAG_DATETIME_DIGITIZED
@@ -1052,6 +1072,58 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
result.success(dateMillis)
}
+ // return description from these fields (by precedence):
+ // - XMP / dc:description
+ // - IPTC / caption-abstract
+ // - Exif / ImageDescription
+ private fun getDescription(call: MethodCall, result: MethodChannel.Result) {
+ val mimeType = call.argument("mimeType")
+ val uri = call.argument("uri")?.let { Uri.parse(it) }
+ val sizeBytes = call.argument("sizeBytes")?.toLong()
+ if (mimeType == null || uri == null) {
+ result.error("getDescription-args", "missing arguments", null)
+ return
+ }
+
+ var description: String? = null
+ if (canReadWithMetadataExtractor(mimeType)) {
+ try {
+ Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
+ val metadata = Helper.safeRead(input)
+
+ for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
+ val xmpMeta = dir.xmpMeta
+ try {
+ if (xmpMeta.doesPropExist(XMP.DC_DESCRIPTION_PROP_NAME)) {
+ xmpMeta.getSafeLocalizedText(XMP.DC_DESCRIPTION_PROP_NAME) { description = it }
+ }
+ } catch (e: XMPException) {
+ Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
+ }
+ }
+ if (description == null) {
+ for (dir in metadata.getDirectoriesOfType(IptcDirectory::class.java)) {
+ dir.getSafeString(IptcDirectory.TAG_CAPTION) { description = it }
+ }
+ }
+ if (description == null) {
+ for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
+ dir.getSafeString(ExifIFD0Directory.TAG_IMAGE_DESCRIPTION) { description = it }
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
+ } catch (e: NoClassDefFoundError) {
+ Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
+ } catch (e: AssertionError) {
+ Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
+ }
+ }
+
+ result.success(description)
+ }
+
companion object {
private val LOG_TAG = LogUtils.createTag()
const val CHANNEL = "deckers.thibault/aves/metadata_fetch"
@@ -1100,7 +1172,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
private const val KEY_LATITUDE = "latitude"
private const val KEY_LONGITUDE = "longitude"
private const val KEY_XMP_SUBJECTS = "xmpSubjects"
- private const val KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription"
+ private const val KEY_XMP_TITLE = "xmpTitle"
private const val KEY_RATING = "rating"
private const val MASK_IS_ANIMATED = 1 shl 0
@@ -1108,6 +1180,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
private const val MASK_IS_GEOTIFF = 1 shl 2
private const val MASK_IS_360 = 1 shl 3
private const val MASK_IS_MULTIPAGE = 1 shl 4
+ private const val MASK_IS_MOTION_PHOTO = 1 shl 5
private const val XMP_SUBJECTS_SEPARATOR = ";"
// overlay metadata
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt
index a6b54573d..b518cd15a 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt
@@ -56,7 +56,7 @@ class ThumbnailFetcher internal constructor(
try {
if (!customFetch && (width == defaultSize || height == defaultSize) && !isFlipped) {
// Fetch low quality thumbnails when size is not specified.
- // As of Android R, the Media Store content resolver may return a thumbnail
+ // As of Android 11, the Media Store content resolver may return a thumbnail
// that is automatically rotated according to EXIF orientation, but not flipped,
// so we skip this step for flipped entries.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@@ -108,7 +108,7 @@ class ThumbnailFetcher internal constructor(
} else {
@Suppress("deprecation")
var bitmap = MediaStore.Images.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Images.Thumbnails.MINI_KIND, null)
- // from Android Q, returned thumbnail is already rotated according to EXIF orientation
+ // from Android 10, returned thumbnail is already rotated according to EXIF orientation
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && bitmap != null) {
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
}
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/ActivityWindowHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/ActivityWindowHandler.kt
index 88bda61d4..0b01c4578 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/ActivityWindowHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/ActivityWindowHandler.kt
@@ -3,7 +3,6 @@ package deckers.thibault.aves.channel.calls.window
import android.app.Activity
import android.os.Build
import android.view.WindowManager
-import deckers.thibault.aves.utils.LogUtils
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
@@ -60,8 +59,4 @@ class ActivityWindowHandler(private val activity: Activity) : WindowHandler(acti
}
result.success(true)
}
-
- companion object {
- private val LOG_TAG = LogUtils.createTag()
- }
}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ActivityResultStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ActivityResultStreamHandler.kt
index e01adca3a..8d7c272cf 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ActivityResultStreamHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ActivityResultStreamHandler.kt
@@ -88,7 +88,7 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
- error("requestMediaFileAccess-unsupported", "media file bulk access is not allowed before Android R", null)
+ error("requestMediaFileAccess-unsupported", "media file bulk access is not allowed before Android 11", null)
return
}
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt
index 6d17f94e2..8152ba065 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt
@@ -8,17 +8,25 @@ import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
import android.util.Log
+import com.adobe.internal.xmp.XMPMeta
import com.drew.metadata.xmp.XmpDirectory
+import deckers.thibault.aves.metadata.XMP.countPropArrayItems
+import deckers.thibault.aves.metadata.XMP.doesPropExist
import deckers.thibault.aves.metadata.XMP.getSafeLong
import deckers.thibault.aves.metadata.XMP.getSafeStructField
+import deckers.thibault.aves.metadata.metadataextractor.Helper
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
+import deckers.thibault.aves.utils.indexOfBytes
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
+import java.io.DataInputStream
object MultiPage {
private val LOG_TAG = LogUtils.createTag()
+ private val heicMotionPhotoVideoStartIndicator = byteArrayOf(0x00, 0x00, 0x00, 0x18) + "ftypmp42".toByteArray()
+
// page info
private const val KEY_MIME_TYPE = "mimeType"
private const val KEY_HEIGHT = "height"
@@ -103,9 +111,11 @@ object MultiPage {
)
)
// add video tracks from the appended video
- for (i in 0 until extractor.trackCount) {
+ if (extractor.trackCount > 0) {
+ // only consider the first track to represent the appended video
+ val trackIndex = 0
try {
- val format = extractor.getTrackFormat(i)
+ val format = extractor.getTrackFormat(trackIndex)
format.getString(MediaFormat.KEY_MIME)?.let { mime ->
if (MimeTypes.isVideo(mime)) {
val track: FieldMap = hashMapOf(
@@ -123,7 +133,7 @@ object MultiPage {
}
}
} catch (e: Exception) {
- Log.w(LOG_TAG, "failed to get motion photo track information for uri=$uri, track num=$i", e)
+ Log.w(LOG_TAG, "failed to get motion photo track information for uri=$uri, track num=$trackIndex", e)
}
}
}
@@ -138,30 +148,53 @@ object MultiPage {
}
fun getMotionPhotoOffset(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Long? {
+ if (MimeTypes.isHeic(mimeType)) {
+ // XMP in HEIC motion photos (as taken with a Samsung Camera v12.0.01.50) indicates an `Item:Length` of 68 bytes for the video.
+ // This item does not contain the video itself, but only some kind of metadata (no doc, no spec),
+ // so we ignore the `Item:Length` and look instead for the MP4 marker bytes indicating the start of the video.
+ try {
+ Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
+ val bytes = ByteArray(sizeBytes.toInt())
+ DataInputStream(input).use {
+ it.readFully(bytes)
+ }
+ val index = bytes.indexOfBytes(heicMotionPhotoVideoStartIndicator)
+ if (index != -1) {
+ return sizeBytes - index
+ }
+ }
+ } catch (e: Exception) {
+ Log.w(LOG_TAG, "failed to get motion photo offset from uri=$uri", e)
+ }
+ }
+
+ var offsetFromEnd: Long? = null
+ var foundXmp = false
+
+ fun processXmp(xmpMeta: XMPMeta) {
+ if (xmpMeta.doesPropExist(XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME)) {
+ // `GCamera` motion photo
+ xmpMeta.getSafeLong(XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME) { offsetFromEnd = it }
+ } else if (xmpMeta.doesPropExist(XMP.CONTAINER_DIRECTORY_PROP_NAME)) {
+ // `Container` motion photo
+ val count = xmpMeta.countPropArrayItems(XMP.CONTAINER_DIRECTORY_PROP_NAME)
+ if (count == 2) {
+ // expect the video to be the second item
+ val i = 2
+ val mime = xmpMeta.getSafeStructField(listOf(XMP.CONTAINER_DIRECTORY_PROP_NAME, i, XMP.CONTAINER_ITEM_PROP_NAME, XMP.CONTAINER_ITEM_MIME_PROP_NAME))?.value
+ val length = xmpMeta.getSafeStructField(listOf(XMP.CONTAINER_DIRECTORY_PROP_NAME, i, XMP.CONTAINER_ITEM_PROP_NAME, XMP.CONTAINER_ITEM_LENGTH_PROP_NAME))?.value
+ if (MimeTypes.isVideo(mime) && length != null) {
+ offsetFromEnd = length.toLong()
+ }
+ }
+ }
+ }
+
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
- val metadata = MetadataExtractorHelper.safeRead(input)
- for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
- var offsetFromEnd: Long? = null
- val xmpMeta = dir.xmpMeta
- if (xmpMeta.doesPropertyExist(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME)) {
- // GCamera motion photo
- xmpMeta.getSafeLong(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME) { offsetFromEnd = it }
- } else if (xmpMeta.doesPropertyExist(XMP.CONTAINER_SCHEMA_NS, XMP.CONTAINER_DIRECTORY_PROP_NAME)) {
- // Container motion photo
- val count = xmpMeta.countArrayItems(XMP.CONTAINER_SCHEMA_NS, XMP.CONTAINER_DIRECTORY_PROP_NAME)
- if (count == 2) {
- // expect the video to be the second item
- val i = 2
- val mime = xmpMeta.getSafeStructField("${XMP.CONTAINER_DIRECTORY_PROP_NAME}[$i]/${XMP.CONTAINER_ITEM_PROP_NAME}/${XMP.CONTAINER_ITEM_MIME_PROP_NAME}")?.value
- val length = xmpMeta.getSafeStructField("${XMP.CONTAINER_DIRECTORY_PROP_NAME}[$i]/${XMP.CONTAINER_ITEM_PROP_NAME}/${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}")?.value
- if (MimeTypes.isVideo(mime) && length != null) {
- offsetFromEnd = length.toLong()
- }
- }
- }
- return offsetFromEnd
- }
+ val metadata = Helper.safeRead(input)
+ foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
+ metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp)
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get motion photo offset from uri=$uri", e)
@@ -170,7 +203,10 @@ object MultiPage {
} catch (e: AssertionError) {
Log.w(LOG_TAG, "failed to get motion photo offset from uri=$uri", e)
}
- return null
+
+ XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp)
+
+ return offsetFromEnd
}
fun getTiffPages(context: Context, uri: Uri): ArrayList {
@@ -214,4 +250,4 @@ object MultiPage {
}
return null
}
-}
\ No newline at end of file
+}
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt
index 2819915e8..540f37fa7 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt
@@ -1,12 +1,20 @@
package deckers.thibault.aves.metadata
+import android.content.Context
+import android.net.Uri
+import android.os.Build
+import android.provider.MediaStore
import android.util.Log
import com.adobe.internal.xmp.XMPError
import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.XMPMeta
+import com.adobe.internal.xmp.XMPMetaFactory
import com.adobe.internal.xmp.properties.XMPProperty
+import deckers.thibault.aves.metadata.metadataextractor.SafeXmpReader
+import deckers.thibault.aves.utils.ContextUtils.queryContentResolverProp
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
+import deckers.thibault.aves.utils.StorageUtils
import java.util.*
object XMP {
@@ -14,74 +22,65 @@ object XMP {
// standard namespaces
// cf com.adobe.internal.xmp.XMPConst
- const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/"
- const val MICROSOFTPHOTO_SCHEMA_NS = "http://ns.microsoft.com/photo/1.0/"
- const val PHOTOSHOP_SCHEMA_NS = "http://ns.adobe.com/photoshop/1.0/"
- const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/"
- private const val XMP_GIMG_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/g/img/"
+ private const val DC_NS_URI = "http://purl.org/dc/elements/1.1/"
+ private const val MICROSOFTPHOTO_NS_URI = "http://ns.microsoft.com/photo/1.0/"
+ private const val PHOTOSHOP_NS_URI = "http://ns.adobe.com/photoshop/1.0/"
+ private const val XMP_NS_URI = "http://ns.adobe.com/xap/1.0/"
// other namespaces
- private const val GAUDIO_SCHEMA_NS = "http://ns.google.com/photos/1.0/audio/"
- const val GCAMERA_SCHEMA_NS = "http://ns.google.com/photos/1.0/camera/"
- private const val GDEPTH_SCHEMA_NS = "http://ns.google.com/photos/1.0/depthmap/"
- private const val GIMAGE_SCHEMA_NS = "http://ns.google.com/photos/1.0/image/"
- const val CONTAINER_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/"
- private const val CONTAINER_ITEM_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/item/"
+ private const val CONTAINER_NS_URI = "http://ns.google.com/photos/1.0/container/"
+ private const val CONTAINER_ITEM_NS_URI = "http://ns.google.com/photos/1.0/container/item/"
+ private const val GAUDIO_NS_URI = "http://ns.google.com/photos/1.0/audio/"
+ private const val GCAMERA_NS_URI = "http://ns.google.com/photos/1.0/camera/"
+ private const val GDEPTH_NS_URI = "http://ns.google.com/photos/1.0/depthmap/"
+ private const val GIMAGE_NS_URI = "http://ns.google.com/photos/1.0/image/"
+ private const val GPANO_NS_URI = "http://ns.google.com/photos/1.0/panorama/"
+ private const val PMTM_NS_URI = "http://www.hdrsoft.com/photomatix_settings01"
- const val DC_DESCRIPTION_PROP_NAME = "dc:description"
- const val DC_SUBJECT_PROP_NAME = "dc:subject"
- const val DC_TITLE_PROP_NAME = "dc:title"
- const val MS_RATING_PROP_NAME = "MicrosoftPhoto:Rating"
- const val PS_DATE_CREATED_PROP_NAME = "photoshop:DateCreated"
- const val XMP_CREATE_DATE_PROP_NAME = "xmp:CreateDate"
- const val XMP_RATING_PROP_NAME = "xmp:Rating"
+ val DC_SUBJECT_PROP_NAME = XMPPropName(DC_NS_URI, "subject")
+ val DC_DESCRIPTION_PROP_NAME = XMPPropName(DC_NS_URI, "description")
+ val DC_TITLE_PROP_NAME = XMPPropName(DC_NS_URI, "title")
+ val MS_RATING_PROP_NAME = XMPPropName(MICROSOFTPHOTO_NS_URI, "Rating")
+ val PS_DATE_CREATED_PROP_NAME = XMPPropName(PHOTOSHOP_NS_URI, "DateCreated")
+ val XMP_CREATE_DATE_PROP_NAME = XMPPropName(XMP_NS_URI, "CreateDate")
+ val XMP_RATING_PROP_NAME = XMPPropName(XMP_NS_URI, "Rating")
private const val GENERIC_LANG = ""
private const val SPECIFIC_LANG = "en-US"
- private val schemas = hashMapOf(
- "Container" to CONTAINER_SCHEMA_NS,
- "GAudio" to GAUDIO_SCHEMA_NS,
- "GDepth" to GDEPTH_SCHEMA_NS,
- "GImage" to GIMAGE_SCHEMA_NS,
- "Item" to CONTAINER_ITEM_SCHEMA_NS,
- "xmp" to XMP_SCHEMA_NS,
- "xmpGImg" to XMP_GIMG_SCHEMA_NS,
- )
-
- fun namespaceForPropPath(propPath: String) = schemas[propPath.split(":")[0]]
-
// embedded media data properties
// cf https://developers.google.com/depthmap-metadata
// cf https://developers.google.com/vr/reference/cardboard-camera-vr-photo-format
- private val knownDataPaths = listOf("GAudio:Data", "GImage:Data", "GDepth:Data", "GDepth:Confidence")
+ private val knownDataProps = listOf(
+ XMPPropName(GAUDIO_NS_URI, "Data"),
+ XMPPropName(GIMAGE_NS_URI, "Data"),
+ XMPPropName(GDEPTH_NS_URI, "Data"),
+ XMPPropName(GDEPTH_NS_URI, "Confidence"),
+ )
- fun isDataPath(path: String) = knownDataPaths.contains(path)
+ fun isDataPath(path: String) = knownDataProps.map { it.toString() }.any { path == it }
// motion photo
- const val GCAMERA_VIDEO_OFFSET_PROP_NAME = "GCamera:MicroVideoOffset"
- const val CONTAINER_DIRECTORY_PROP_NAME = "Container:Directory"
- const val CONTAINER_ITEM_PROP_NAME = "Container:Item"
- const val CONTAINER_ITEM_LENGTH_PROP_NAME = "Item:Length"
- const val CONTAINER_ITEM_MIME_PROP_NAME = "Item:Mime"
+ val GCAMERA_VIDEO_OFFSET_PROP_NAME = XMPPropName(GCAMERA_NS_URI, "MicroVideoOffset")
+ val CONTAINER_DIRECTORY_PROP_NAME = XMPPropName(CONTAINER_NS_URI, "Directory")
+ val CONTAINER_ITEM_PROP_NAME = XMPPropName(CONTAINER_NS_URI, "Item")
+ val CONTAINER_ITEM_LENGTH_PROP_NAME = XMPPropName(CONTAINER_ITEM_NS_URI, "Length")
+ val CONTAINER_ITEM_MIME_PROP_NAME = XMPPropName(CONTAINER_ITEM_NS_URI, "Mime")
// panorama
// cf https://developers.google.com/streetview/spherical-metadata
- const val GPANO_SCHEMA_NS = "http://ns.google.com/photos/1.0/panorama/"
- private const val PMTM_SCHEMA_NS = "http://www.hdrsoft.com/photomatix_settings01"
-
- const val GPANO_CROPPED_AREA_HEIGHT_PROP_NAME = "GPano:CroppedAreaImageHeightPixels"
- const val GPANO_CROPPED_AREA_WIDTH_PROP_NAME = "GPano:CroppedAreaImageWidthPixels"
- const val GPANO_CROPPED_AREA_LEFT_PROP_NAME = "GPano:CroppedAreaLeftPixels"
- const val GPANO_CROPPED_AREA_TOP_PROP_NAME = "GPano:CroppedAreaTopPixels"
- const val GPANO_FULL_PANO_HEIGHT_PROP_NAME = "GPano:FullPanoHeightPixels"
- const val GPANO_FULL_PANO_WIDTH_PROP_NAME = "GPano:FullPanoWidthPixels"
- const val GPANO_PROJECTION_TYPE_PROP_NAME = "GPano:ProjectionType"
+ val GPANO_CROPPED_AREA_HEIGHT_PROP_NAME = XMPPropName(GPANO_NS_URI, "CroppedAreaImageHeightPixels")
+ val GPANO_CROPPED_AREA_WIDTH_PROP_NAME = XMPPropName(GPANO_NS_URI, "CroppedAreaImageWidthPixels")
+ val GPANO_CROPPED_AREA_LEFT_PROP_NAME = XMPPropName(GPANO_NS_URI, "CroppedAreaLeftPixels")
+ val GPANO_CROPPED_AREA_TOP_PROP_NAME = XMPPropName(GPANO_NS_URI, "CroppedAreaTopPixels")
+ val GPANO_FULL_PANO_HEIGHT_PROP_NAME = XMPPropName(GPANO_NS_URI, "FullPanoHeightPixels")
+ val GPANO_FULL_PANO_WIDTH_PROP_NAME = XMPPropName(GPANO_NS_URI, "FullPanoWidthPixels")
+ val GPANO_PROJECTION_TYPE_PROP_NAME = XMPPropName(GPANO_NS_URI, "ProjectionType")
const val GPANO_PROJECTION_TYPE_DEFAULT = "equirectangular"
- private const val PMTM_IS_PANO360 = "pmtm:IsPano360"
+ private val PMTM_IS_PANO360_PROP_NAME = XMPPropName(PMTM_NS_URI, "IsPano360")
// `GPano:ProjectionType` is required by spec but it is sometimes missing, assuming default
// `GPano:FullPanoHeightPixels` is required by spec but it is sometimes missing (e.g. Samsung Camera app panorama mode)
@@ -93,22 +92,38 @@ object XMP {
GPANO_FULL_PANO_WIDTH_PROP_NAME,
)
+ // as of `metadata-extractor` v2.18.0, XMP is not discovered in HEIC images,
+ // so we fall back to the native content resolver, if possible
+ fun checkHeic(context: Context, uri: Uri, mimeType: String, foundXmp: Boolean, processXmp: (xmpMeta: XMPMeta) -> Unit) {
+ if (MimeTypes.isHeic(mimeType) && !foundXmp && StorageUtils.isMediaStoreContentUri(uri) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ try {
+ val xmpBytes = context.queryContentResolverProp(uri, mimeType, MediaStore.MediaColumns.XMP)
+ if (xmpBytes is ByteArray) {
+ val xmpMeta = XMPMetaFactory.parseFromBuffer(xmpBytes, SafeXmpReader.PARSE_OPTIONS)
+ processXmp(xmpMeta)
+ }
+ } catch (e: Exception) {
+ Log.w(LOG_TAG, "failed to get XMP by content resolver for mimeType=$mimeType uri=$uri", e)
+ }
+ }
+ }
+
// extensions
fun XMPMeta.isMotionPhoto(): Boolean {
try {
// GCamera motion photo
- if (doesPropertyExist(GCAMERA_SCHEMA_NS, GCAMERA_VIDEO_OFFSET_PROP_NAME)) return true
+ if (doesPropExist(GCAMERA_VIDEO_OFFSET_PROP_NAME)) return true
// Container motion photo
- if (doesPropertyExist(CONTAINER_SCHEMA_NS, CONTAINER_DIRECTORY_PROP_NAME)) {
- val count = countArrayItems(CONTAINER_SCHEMA_NS, CONTAINER_DIRECTORY_PROP_NAME)
+ if (doesPropExist(CONTAINER_DIRECTORY_PROP_NAME)) {
+ val count = countPropArrayItems(CONTAINER_DIRECTORY_PROP_NAME)
if (count == 2) {
var hasImage = false
var hasVideo = false
for (i in 1 until count + 1) {
- val mime = getSafeStructField("$CONTAINER_DIRECTORY_PROP_NAME[$i]/$CONTAINER_ITEM_PROP_NAME/$CONTAINER_ITEM_MIME_PROP_NAME")?.value
- val length = getSafeStructField("$CONTAINER_DIRECTORY_PROP_NAME[$i]/$CONTAINER_ITEM_PROP_NAME/$CONTAINER_ITEM_LENGTH_PROP_NAME")?.value
+ val mime = getSafeStructField(listOf(CONTAINER_DIRECTORY_PROP_NAME, i, CONTAINER_ITEM_PROP_NAME, CONTAINER_ITEM_MIME_PROP_NAME))?.value
+ val length = getSafeStructField(listOf(CONTAINER_DIRECTORY_PROP_NAME, i, CONTAINER_ITEM_PROP_NAME, CONTAINER_ITEM_LENGTH_PROP_NAME))?.value
hasImage = hasImage || MimeTypes.isImage(mime) && length != null
hasVideo = hasVideo || MimeTypes.isVideo(mime) && length != null
}
@@ -130,7 +145,7 @@ object XMP {
fun XMPMeta.isPanorama(): Boolean {
// Google
try {
- if (gpanoRequiredProps.all { doesPropertyExist(GPANO_SCHEMA_NS, it) }) return true
+ if (gpanoRequiredProps.all { doesPropExist(it) }) return true
} catch (e: XMPException) {
if (e.errorCode != XMPError.BADSCHEMA) {
// `BADSCHEMA` code is reported when we check a property
@@ -141,7 +156,7 @@ object XMP {
// Photomatix
try {
- if (getPropertyString(PMTM_SCHEMA_NS, PMTM_IS_PANO360) == "Yes") return true
+ if (getPropertyString(PMTM_IS_PANO360_PROP_NAME.nsUri, PMTM_IS_PANO360_PROP_NAME.toString()) == "Yes") return true
} catch (e: XMPException) {
if (e.errorCode != XMPError.BADSCHEMA) {
// `BADSCHEMA` code is reported when we check a property
@@ -153,7 +168,24 @@ object XMP {
return false
}
- fun XMPMeta.getSafeInt(schema: String, propName: String, save: (value: Int) -> Unit) {
+ fun XMPMeta.doesPropExist(prop: XMPPropName): Boolean {
+ return doesPropertyExist(prop.nsUri, prop.toString())
+ }
+
+ fun XMPMeta.countPropArrayItems(prop: XMPPropName): Int {
+ return countArrayItems(prop.nsUri, prop.toString())
+ }
+
+ fun XMPMeta.getPropArrayItemValues(prop: XMPPropName): List {
+ val schema = prop.nsUri
+ val propName = prop.toString()
+ val count = countArrayItems(schema, propName)
+ return (1 until count + 1).map { getArrayItem(schema, propName, it).value }
+ }
+
+ fun XMPMeta.getSafeInt(prop: XMPPropName, save: (value: Int) -> Unit) {
+ val schema = prop.nsUri
+ val propName = prop.toString()
try {
if (doesPropertyExist(schema, propName)) {
val item = getPropertyInteger(schema, propName)
@@ -167,7 +199,9 @@ object XMP {
}
}
- fun XMPMeta.getSafeLong(schema: String, propName: String, save: (value: Long) -> Unit) {
+ fun XMPMeta.getSafeLong(prop: XMPPropName, save: (value: Long) -> Unit) {
+ val schema = prop.nsUri
+ val propName = prop.toString()
try {
if (doesPropertyExist(schema, propName)) {
val item = getPropertyLong(schema, propName)
@@ -181,7 +215,9 @@ object XMP {
}
}
- fun XMPMeta.getSafeString(schema: String, propName: String, save: (value: String) -> Unit) {
+ fun XMPMeta.getSafeString(prop: XMPPropName, save: (value: String) -> Unit) {
+ val schema = prop.nsUri
+ val propName = prop.toString()
try {
if (doesPropertyExist(schema, propName)) {
val item = getPropertyString(schema, propName)
@@ -195,7 +231,9 @@ object XMP {
}
}
- fun XMPMeta.getSafeLocalizedText(schema: String, propName: String, acceptBlank: Boolean = true, save: (value: String) -> Unit) {
+ fun XMPMeta.getSafeLocalizedText(prop: XMPPropName, acceptBlank: Boolean = true, save: (value: String) -> Unit) {
+ val schema = prop.nsUri
+ val propName = prop.toString()
try {
if (doesPropertyExist(schema, propName)) {
val item = getLocalizedText(schema, propName, GENERIC_LANG, SPECIFIC_LANG)
@@ -209,7 +247,9 @@ object XMP {
}
}
- fun XMPMeta.getSafeDateMillis(schema: String, propName: String, save: (value: Long) -> Unit) {
+ fun XMPMeta.getSafeDateMillis(prop: XMPPropName, save: (value: Long) -> Unit) {
+ val schema = prop.nsUri
+ val propName = prop.toString()
try {
if (doesPropertyExist(schema, propName)) {
val item = getPropertyDate(schema, propName)
@@ -226,20 +266,38 @@ object XMP {
}
}
- // e.g. 'Container:Directory[42]/Container:Item/Item:Mime'
- fun XMPMeta.getSafeStructField(path: String): XMPProperty? {
- val separator = path.lastIndexOf("/")
- if (separator != -1) {
- val structName = path.substring(0, separator)
- val structNs = namespaceForPropPath(structName)
- val fieldName = path.substring(separator + 1)
- val fieldNs = namespaceForPropPath(fieldName)
- try {
- return getStructField(structNs, structName, fieldNs, fieldName)
- } catch (e: XMPException) {
- Log.w(LOG_TAG, "failed to get XMP struct field for path=$path", e)
+ // e.g. path 'Container:Directory[42]/Container:Item/Item:Mime' matches:
+ // - structNs: "http://ns.google.com/photos/1.0/container/"
+ // - structName: "Container:Directory[42]/Container:Item"
+ // - fieldNs: "http://ns.google.com/photos/1.0/container/item/"
+ // - fieldName: "Item:Mime"
+ fun XMPMeta.getSafeStructField(props: List): XMPProperty? {
+ if (props.size >= 2) {
+ val structFirst = props.first()
+ val field = props.last()
+ if (structFirst is XMPPropName && field is XMPPropName) {
+ val structName = props.take(props.size - 1).mapIndexed { index, prop ->
+ when (prop) {
+ is XMPPropName -> "${if (index == 0) "" else "/"}$prop"
+ is Int -> "[$prop]"
+ else -> null
+ }
+ }.filterNotNull().joinToString("")
+ val fieldName = field.toString()
+
+ try {
+ return getStructField(structFirst.nsUri, structName, field.nsUri, fieldName)
+ } catch (e: XMPException) {
+ Log.w(LOG_TAG, "failed to get XMP struct field for props=$props", e)
+ }
}
}
return null
}
-}
\ No newline at end of file
+}
+
+class XMPPropName(val nsUri: String, private val prop: String) {
+ private fun resolve(): String = "${XMPMetaFactory.getSchemaRegistry().getNamespacePrefix(nsUri)}$prop"
+
+ override fun toString(): String = resolve()
+}
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/Helper.kt
similarity index 83%
rename from android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt
rename to android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/Helper.kt
index ef5639d22..06add40ae 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/Helper.kt
@@ -1,12 +1,17 @@
-package deckers.thibault.aves.metadata
+package deckers.thibault.aves.metadata.metadataextractor
import android.util.Log
import com.drew.imaging.FileType
import com.drew.imaging.FileTypeDetector
import com.drew.imaging.ImageMetadataReader
+import com.drew.imaging.ImageProcessingException
import com.drew.imaging.jpeg.JpegMetadataReader
import com.drew.imaging.jpeg.JpegSegmentMetadataReader
+import com.drew.imaging.mp4.Mp4Reader
+import com.drew.imaging.tiff.TiffProcessingException
+import com.drew.imaging.tiff.TiffReader
import com.drew.lang.ByteArrayReader
+import com.drew.lang.RandomAccessStreamReader
import com.drew.lang.Rational
import com.drew.lang.SequentialByteArrayReader
import com.drew.metadata.Directory
@@ -19,14 +24,18 @@ import com.drew.metadata.file.FileTypeDirectory
import com.drew.metadata.iptc.IptcReader
import com.drew.metadata.png.PngDirectory
import com.drew.metadata.xmp.XmpReader
+import deckers.thibault.aves.metadata.ExifGeoTiffTags
+import deckers.thibault.aves.metadata.GeoTiffKeys
+import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.utils.LogUtils
import java.io.BufferedInputStream
+import java.io.IOException
import java.io.InputStream
import java.text.SimpleDateFormat
import java.util.*
-object MetadataExtractorHelper {
- private val LOG_TAG = LogUtils.createTag()
+object Helper {
+ private val LOG_TAG = LogUtils.createTag()
const val PNG_ITXT_DIR_NAME = "PNG-iTXt"
private const val PNG_TEXT_DIR_NAME = "PNG-tEXt"
@@ -43,33 +52,42 @@ object MetadataExtractorHelper {
// e.g. "exif [...] 134 [...] 4578696600004949[...]"
private val PNG_RAW_PROFILE_PATTERN = Regex("^\\n(.*?)\\n\\s*(\\d+)\\n(.*)", RegexOption.DOT_MATCHES_ALL)
+ // providing the stream length is risky, as it may crash if it is incorrect
+ private const val safeReadStreamLength = -1L
+
fun readMimeType(input: InputStream): String? {
val bufferedInputStream = if (input is BufferedInputStream) input else BufferedInputStream(input)
return FileTypeDetector.detectFileType(bufferedInputStream).mimeType
}
+ @Throws(IOException::class, ImageProcessingException::class)
fun safeRead(input: InputStream): com.drew.metadata.Metadata {
- val bufferedInputStream = if (input is BufferedInputStream) input else BufferedInputStream(input)
- val fileType = FileTypeDetector.detectFileType(bufferedInputStream)
+ val inputStream = if (input is BufferedInputStream) input else BufferedInputStream(input)
+ val fileType = FileTypeDetector.detectFileType(inputStream)
- val metadata = if (fileType == FileType.Jpeg) {
- safeReadJpeg(bufferedInputStream)
- } else {
- // providing the stream length is risky, as it may crash if it is incorrect
- ImageMetadataReader.readMetadata(bufferedInputStream, -1L, fileType)
+ val metadata = when (fileType) {
+ FileType.Jpeg -> safeReadJpeg(inputStream)
+ FileType.Tiff,
+ FileType.Arw,
+ FileType.Cr2,
+ FileType.Nef,
+ FileType.Orf,
+ FileType.Rw2 -> safeReadTiff(inputStream)
+ FileType.Mp4 -> safeReadMp4(inputStream)
+ else -> ImageMetadataReader.readMetadata(inputStream, safeReadStreamLength, fileType)
}
metadata.addDirectory(FileTypeDirectory(fileType))
return metadata
}
- // Some JPEG (and other types?) contain XMP with a preposterous number of `DocumentAncestors`.
+ // Some JPEG, TIFF, MP4 (and other types?) contain XMP with a preposterous number of `DocumentAncestors`.
// This bloated XMP is unsafely loaded in memory by Adobe's `XMPMetaParser.parseInputSource`
// which easily yields OOM on Android, so we try to detect and strip extended XMP with a modified XMP reader.
private fun safeReadJpeg(input: InputStream): com.drew.metadata.Metadata {
val readers = ArrayList().apply {
addAll(JpegMetadataReader.ALL_READERS.filter { it !is XmpReader })
- add(MetadataExtractorSafeXmpReader())
+ add(SafeXmpReader())
}
val metadata = com.drew.metadata.Metadata()
@@ -77,6 +95,21 @@ object MetadataExtractorHelper {
return metadata
}
+ @Throws(IOException::class, TiffProcessingException::class)
+ fun safeReadTiff(input: InputStream): com.drew.metadata.Metadata {
+ val reader = RandomAccessStreamReader(input, RandomAccessStreamReader.DEFAULT_CHUNK_LENGTH, safeReadStreamLength)
+ val metadata = com.drew.metadata.Metadata()
+ val handler = SafeExifTiffHandler(metadata, null)
+ TiffReader().processTiff(reader, handler, 0)
+ return metadata
+ }
+
+ private fun safeReadMp4(input: InputStream): com.drew.metadata.Metadata {
+ val metadata = com.drew.metadata.Metadata()
+ Mp4Reader.extract(input, SafeMp4BoxHandler(metadata))
+ return metadata
+ }
+
// extensions
fun Directory.getSafeString(tag: Int, save: (value: String) -> Unit) {
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafeExifTiffHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafeExifTiffHandler.kt
new file mode 100644
index 000000000..afad1de4c
--- /dev/null
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafeExifTiffHandler.kt
@@ -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?,
+ 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)
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafeMp4BoxHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafeMp4BoxHandler.kt
new file mode 100644
index 000000000..2e6bebb3b
--- /dev/null
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafeMp4BoxHandler.kt
@@ -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)
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafeMp4UuidBoxHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafeMp4UuidBoxHandler.kt
new file mode 100644
index 000000000..35f3e0914
--- /dev/null
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafeMp4UuidBoxHandler.kt
@@ -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())
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorSafeXmpReader.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafeXmpReader.kt
similarity index 96%
rename from android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorSafeXmpReader.kt
rename to android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafeXmpReader.kt
index 0edd03c85..c70c09ae2 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorSafeXmpReader.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafeXmpReader.kt
@@ -1,4 +1,4 @@
-package deckers.thibault.aves.metadata
+package deckers.thibault.aves.metadata.metadataextractor
import android.util.Log
import com.adobe.internal.xmp.XMPException
@@ -19,7 +19,7 @@ import com.drew.metadata.xmp.XmpReader
import deckers.thibault.aves.utils.LogUtils
import java.io.IOException
-class MetadataExtractorSafeXmpReader : XmpReader() {
+class SafeXmpReader : XmpReader() {
// adapted from `XmpReader` to detect and skip large extended XMP
override fun readJpegSegments(segments: Iterable, metadata: Metadata, segmentType: JpegSegmentType) {
val preambleLength = XMP_JPEG_PREAMBLE.length
@@ -132,13 +132,13 @@ class MetadataExtractorSafeXmpReader : XmpReader() {
}
companion object {
- private val LOG_TAG = LogUtils.createTag()
+ private val LOG_TAG = LogUtils.createTag()
// arbitrary size to detect extended XMP that may yield an OOM
private const val segmentTypeSizeDangerThreshold = 3 * (1 shl 20) // MB
// tighter node limits for faster loading
- private val PARSE_OPTIONS = ParseOptions().setXMPNodesToLimit(
+ val PARSE_OPTIONS: ParseOptions = ParseOptions().setXMPNodesToLimit(
mapOf(
"photoshop:DocumentAncestors" to 200,
"xmpMM:History" to 200,
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt
index 347835fef..42855da21 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt
@@ -22,10 +22,10 @@ import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeLong
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeString
import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
-import deckers.thibault.aves.metadata.MetadataExtractorHelper
-import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
-import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
-import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeLong
+import deckers.thibault.aves.metadata.metadataextractor.Helper
+import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeDateMillis
+import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeInt
+import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeLong
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.UriUtils.tryParseId
@@ -161,7 +161,7 @@ class SourceEntry {
try {
Metadata.openSafeInputStream(context, uri, sourceMimeType, sizeBytes)?.use { input ->
- val metadata = MetadataExtractorHelper.safeRead(input)
+ val metadata = Helper.safeRead(input)
// do not switch on specific MIME types, as the reported MIME type could be wrong
// (e.g. PNG registered as JPG)
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt
index 1781bba01..6d5bb2315 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt
@@ -6,7 +6,7 @@ import android.provider.MediaStore
import android.provider.OpenableColumns
import android.util.Log
import deckers.thibault.aves.metadata.Metadata
-import deckers.thibault.aves.metadata.MetadataExtractorHelper
+import deckers.thibault.aves.metadata.metadataextractor.Helper
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.SourceEntry
import deckers.thibault.aves.utils.LogUtils
@@ -22,7 +22,7 @@ internal class ContentImageProvider : ImageProvider() {
StorageUtils.openInputStream(context, safeUri)?.use { input ->
// `metadata-extractor` is the most reliable, except for `tiff` (false positives, false negatives)
// cf https://github.com/drewnoakes/metadata-extractor/issues/296
- MetadataExtractorHelper.readMimeType(input)?.takeIf { it != MimeTypes.TIFF }?.let {
+ Helper.readMimeType(input)?.takeIf { it != MimeTypes.TIFF }?.let {
extractorMimeType = it
if (extractorMimeType != sourceMimeType) {
Log.d(LOG_TAG, "source MIME type is $sourceMimeType but extracted MIME type is $extractorMimeType for uri=$uri")
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt
index 679e96b9a..a4dc00da7 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt
@@ -70,7 +70,7 @@ abstract class ImageProvider {
callback.onFailure(UnsupportedOperationException("`renameMultiple` is not supported by this image provider"))
}
- open fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap, callback: ImageOpCallback) {
+ open fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: FieldMap, callback: ImageOpCallback) {
throw UnsupportedOperationException("`scanPostMetadataEdit` is not supported by this image provider")
}
@@ -684,7 +684,7 @@ abstract class ImageProvider {
op: ExifOrientationOp,
callback: ImageOpCallback,
) {
- val newFields = HashMap()
+ val newFields: FieldMap = hashMapOf()
val success = editExif(context, path, uri, mimeType, callback) { exif ->
// when the orientation is not defined, it returns `undefined (0)` instead of the orientation default value `normal (1)`
@@ -909,7 +909,7 @@ abstract class ImageProvider {
}
}
- val newFields = HashMap()
+ val newFields: FieldMap = hashMapOf()
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
}
@@ -961,7 +961,7 @@ abstract class ImageProvider {
return
}
- val newFields = HashMap()
+ val newFields: FieldMap = hashMapOf()
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
}
@@ -1008,7 +1008,7 @@ abstract class ImageProvider {
return
}
- val newFields = HashMap()
+ val newFields: FieldMap = hashMapOf()
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
}
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt
index e3e9ff736..b171334fd 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt
@@ -193,7 +193,7 @@ class MediaStoreImageProvider : ImageProvider() {
val dateModifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
val dateTakenColumn = cursor.getColumnIndex(MediaColumns.DATE_TAKEN)
- // image & video for API >= Q, only for images for API < Q
+ // image & video for API >=29, only for images for API <29
val orientationColumn = cursor.getColumnIndex(MediaColumns.ORIENTATION)
// video only
@@ -347,7 +347,7 @@ class MediaStoreImageProvider : ImageProvider() {
}
} catch (securityException: SecurityException) {
// even if the app has access permission granted on the containing directory,
- // the delete request may yield a `RecoverableSecurityException` on Android 10+
+ // the delete request may yield a `RecoverableSecurityException` on Android >=10
// when the underlying file no longer exists and this is an orphaned entry in the Media Store
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && contextWrapper is Activity) {
Log.w(LOG_TAG, "caught a security exception when attempting to delete uri=$uri", securityException)
@@ -726,7 +726,7 @@ class MediaStoreImageProvider : ImageProvider() {
return scanNewPath(activity, newFile.path, mimeType)
}
- override fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap, callback: ImageOpCallback) {
+ override fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: FieldMap, callback: ImageOpCallback) {
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, _ ->
val projection = arrayOf(
MediaStore.MediaColumns.DATE_MODIFIED,
@@ -876,7 +876,7 @@ class MediaStoreImageProvider : ImageProvider() {
private val VIDEO_PROJECTION = arrayOf(
*BASE_PROJECTION,
MediaColumns.DURATION,
- // `ORIENTATION` was only available for images before Android Q
+ // `ORIENTATION` was only available for images before Android 10
*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) arrayOf(
MediaStore.MediaColumns.ORIENTATION,
) else emptyArray()
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/CollectionUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/CollectionUtils.kt
index 56c78df1f..9fc81e5c7 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/CollectionUtils.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/CollectionUtils.kt
@@ -2,7 +2,7 @@ package deckers.thibault.aves.utils
import android.os.Build
-// compatibility extension for `removeIf` for API < N
+// compatibility extension for `removeIf` for API <24
fun MutableList.compatRemoveIf(filter: (t: E) -> Boolean): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
this.removeIf(filter)
@@ -17,4 +17,27 @@ fun MutableList.compatRemoveIf(filter: (t: E) -> Boolean): Boolean {
}
return removed
}
-}
\ No newline at end of file
+}
+
+// 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
+}
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/ContextUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/ContextUtils.kt
index 7b4d57941..0dd235137 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/ContextUtils.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/ContextUtils.kt
@@ -3,10 +3,17 @@ package deckers.thibault.aves.utils
import android.app.ActivityManager
import android.app.Service
import android.content.ContentResolver
+import android.content.ContentUris
import android.content.Context
+import android.database.Cursor
import android.net.Uri
+import android.provider.MediaStore
+import android.util.Log
+import deckers.thibault.aves.utils.UriUtils.tryParseId
object ContextUtils {
+ private val LOG_TAG = LogUtils.createTag()
+
fun Context.resourceUri(resourceId: Int): Uri = with(resources) {
Uri.Builder()
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
@@ -22,4 +29,40 @@ object ContextUtils {
@Suppress("deprecation")
return am.getRunningServices(Integer.MAX_VALUE).any { it.service.className == serviceClass.name }
}
+
+ fun Context.queryContentResolverProp(uri: Uri, mimeType: String, prop: String): Any? {
+ var contentUri: Uri = uri
+ if (StorageUtils.isMediaStoreContentUri(uri)) {
+ uri.tryParseId()?.let { id ->
+ contentUri = when {
+ MimeTypes.isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
+ MimeTypes.isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
+ else -> uri
+ }
+ contentUri = StorageUtils.getOriginalUri(this, contentUri)
+ }
+ }
+
+ // throws SQLiteException when the requested prop is not a known column
+ val cursor = contentResolver.query(contentUri, arrayOf(prop), null, null, null)
+ if (cursor == null || !cursor.moveToFirst()) {
+ throw Exception("failed to get cursor for contentUri=$contentUri")
+ }
+
+ var value: Any? = null
+ try {
+ value = when (cursor.getType(0)) {
+ Cursor.FIELD_TYPE_NULL -> null
+ Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(0)
+ Cursor.FIELD_TYPE_FLOAT -> cursor.getFloat(0)
+ Cursor.FIELD_TYPE_STRING -> cursor.getString(0)
+ Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(0)
+ else -> null
+ }
+ } catch (e: Exception) {
+ Log.w(LOG_TAG, "failed to get value for contentUri=$contentUri key=$prop", e)
+ }
+ cursor.close()
+ return value
+ }
}
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt
index add315a11..4491eec38 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt
@@ -98,7 +98,7 @@ object PermissionManager {
segments.volumePath?.let { volumePath ->
val dirSet = dirsPerVolume[volumePath] ?: HashSet()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- // request primary directory on volume from Android R
+ // request primary directory on volume from Android 11
val relativeDir = segments.relativeDir
if (relativeDir != null) {
val dirSegments = relativeDir.split(File.separator).takeWhile { it.isNotEmpty() }
@@ -111,11 +111,11 @@ object PermissionManager {
}
} else {
// the requested path is the volume root itself
- // which cannot be granted, due to Android R restrictions
+ // which cannot be granted, due to Android 11 restrictions
dirSet.add("")
}
} else {
- // request volume root until Android Q
+ // request volume root until Android 10
dirSet.add("")
}
dirsPerVolume[volumePath] = dirSet
@@ -236,7 +236,7 @@ object PermissionManager {
return dirs
}
- // As of Android R, `MediaStore.getDocumentUri` fails if any of the persisted
+ // As of Android 11, `MediaStore.getDocumentUri` fails if any of the persisted
// URI permissions we hold points to a folder that no longer exists,
// so we should remove these obsolete URIs before proceeding.
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt
index ba7d1537e..33db8d6f6 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt
@@ -82,7 +82,7 @@ object StorageUtils {
}
fun getVolumePaths(context: Context): Array {
- if (mStorageVolumePaths == null) {
+ if (mStorageVolumePaths == null || mStorageVolumePaths!!.isEmpty()) {
mStorageVolumePaths = findVolumePaths(context)
}
return mStorageVolumePaths!!
@@ -162,22 +162,27 @@ object StorageUtils {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
lateinit var files: List
var validFiles: Boolean
+ val retryInterval = 100L
+ val maxDelay = 1000L
+ var totalDelay = 0L
do {
// `getExternalFilesDirs` sometimes include `null` when called right after getting read access
// (e.g. on API 30 emulator) so we retry until the file system is ready.
- // TODO TLAD It can also include `null` when there is a faulty SD card.
+ // It can also include `null` when there is a faulty SD card.
val externalFilesDirs = context.getExternalFilesDirs(null)
validFiles = !externalFilesDirs.contains(null)
if (validFiles) {
files = externalFilesDirs.filterNotNull()
} else {
+ Log.d(LOG_TAG, "External files dirs contain `null`. Retrying...")
+ totalDelay += retryInterval
try {
- Thread.sleep(100)
+ Thread.sleep(retryInterval)
} catch (e: InterruptedException) {
Log.e(LOG_TAG, "insomnia", e)
}
}
- } while (!validFiles)
+ } while (!validFiles && totalDelay < maxDelay)
paths.addAll(files.mapNotNull(::appSpecificVolumePath))
} else {
// Primary physical SD-CARD (not emulated)
@@ -468,7 +473,7 @@ object StorageUtils {
fun requireAccessPermission(context: Context, anyPath: String): Boolean {
if (isAppFile(context, anyPath)) return false
- // on Android R, we should always require access permission, even on primary volume
+ // on Android 11, we should always require access permission, even on primary volume
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) return true
val onPrimaryVolume = anyPath.startsWith(getPrimaryVolumePath(context))
@@ -487,7 +492,7 @@ object StorageUtils {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) {
val path = uri.path
path ?: return uri
- // from Android R, accessing the original URI for a `file` or `downloads` media content yields a `SecurityException`
+ // from Android 11, accessing the original URI for a `file` or `downloads` media content yields a `SecurityException`
if (path.startsWith("/external/images/") || path.startsWith("/external/video/")) {
// "Caller must hold ACCESS_MEDIA_LOCATION permission to access original"
if (context.checkSelfPermission(Manifest.permission.ACCESS_MEDIA_LOCATION) == PackageManager.PERMISSION_GRANTED) {
@@ -499,7 +504,7 @@ object StorageUtils {
}
// As of Glide v4.12.0, a special loader `QMediaStoreUriLoader` is automatically used
- // to work around a bug from Android Q where metadata redaction corrupts HEIC images.
+ // to work around a bug from Android 10 where metadata redaction corrupts HEIC images.
// This loader relies on `MediaStore.setRequireOriginal` but this yields a `SecurityException`
// for some non image/video content URIs (e.g. `downloads`, `file`)
fun getGlideSafeUri(context: Context, uri: Uri, mimeType: String): Uri {
@@ -594,7 +599,7 @@ object StorageUtils {
val effectiveUri = getOriginalUri(context, uri)
return try {
MediaMetadataRetriever().apply {
- // on Android S preview, setting the data source works but yields an internal IOException
+ // on Android 12 preview, setting the data source works but yields an internal IOException
// (`Input file descriptor already original`), whether we provide the original URI or not
setDataSource(context, effectiveUri)
}
diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml
index d501be2f1..9d4e328f0 100644
--- a/android/app/src/main/res/values-es/strings.xml
+++ b/android/app/src/main/res/values-es/strings.xml
@@ -1,6 +1,7 @@
Aves
+ Marco de foto
Fondo de pantalla
Búsqueda
Videos
diff --git a/android/app/src/main/res/values-id/strings.xml b/android/app/src/main/res/values-id/strings.xml
index dee1fe89f..4a074015a 100644
--- a/android/app/src/main/res/values-id/strings.xml
+++ b/android/app/src/main/res/values-id/strings.xml
@@ -1,6 +1,7 @@
Aves
+ Bingkai Foto
Wallpaper
Cari
Video
diff --git a/android/app/src/main/res/values-land/flags.xml b/android/app/src/main/res/values-land/flags.xml
deleted file mode 100644
index 637d33153..000000000
--- a/android/app/src/main/res/values-land/flags.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
- false
-
\ No newline at end of file
diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 000000000..25046f02e
--- /dev/null
+++ b/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,9 @@
+
+
+
+
diff --git a/android/app/src/main/res/values-nl/strings.xml b/android/app/src/main/res/values-nl/strings.xml
new file mode 100644
index 000000000..b8015ce4f
--- /dev/null
+++ b/android/app/src/main/res/values-nl/strings.xml
@@ -0,0 +1,12 @@
+
+
+ Aves
+ Foto Lijstje
+ Achtergrond
+ Zoeken
+ Video’s
+ Media indexeren
+ Indexeren van afdbeeldingen & video’s
+ Indexeren van media
+ Stop
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml
index bb46342b8..8b5d7fdcf 100644
--- a/android/app/src/main/res/values-ru/strings.xml
+++ b/android/app/src/main/res/values-ru/strings.xml
@@ -1,6 +1,7 @@
Aves
+ Фоторамка
Обои
Поиск
Видео
diff --git a/android/app/src/main/res/values-tr/strings.xml b/android/app/src/main/res/values-tr/strings.xml
index d5f43548a..a358643e8 100644
--- a/android/app/src/main/res/values-tr/strings.xml
+++ b/android/app/src/main/res/values-tr/strings.xml
@@ -1,6 +1,7 @@
Aves
+ Fotoğraf Çerçevesi
Duvar kağıdı
Arama
Videolar
diff --git a/android/app/src/main/res/values-v28/styles.xml b/android/app/src/main/res/values-v28/styles.xml
deleted file mode 100644
index ecbc11c23..000000000
--- a/android/app/src/main/res/values-v28/styles.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
diff --git a/android/app/src/main/res/values/flags.xml b/android/app/src/main/res/values/flags.xml
deleted file mode 100644
index a02d00023..000000000
--- a/android/app/src/main/res/values/flags.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
- true
-
\ No newline at end of file
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
index f5ce40ae0..0f75fc293 100644
--- a/android/app/src/main/res/values/styles.xml
+++ b/android/app/src/main/res/values/styles.xml
@@ -1,6 +1,9 @@
-
-
diff --git a/android/build.gradle b/android/build.gradle
index e8e5eb4cc..56449bb12 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -7,7 +7,7 @@ buildscript {
maven { url 'https://developer.huawei.com/repo/' }
}
dependencies {
- classpath 'com.android.tools.build:gradle:7.2.1'
+ classpath 'com.android.tools.build:gradle:7.2.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// GMS & Firebase Crashlytics (used by some flavors only)
classpath 'com.google.gms:google-services:4.3.13'
diff --git a/fastlane/metadata/android/de/images/phoneScreenshots/1.png b/fastlane/metadata/android/de/images/phoneScreenshots/1.png
index a4feab64f..ad9cb124a 100644
Binary files a/fastlane/metadata/android/de/images/phoneScreenshots/1.png and b/fastlane/metadata/android/de/images/phoneScreenshots/1.png differ
diff --git a/fastlane/metadata/android/de/images/phoneScreenshots/2.png b/fastlane/metadata/android/de/images/phoneScreenshots/2.png
index bb7b53e99..f43179c6e 100644
Binary files a/fastlane/metadata/android/de/images/phoneScreenshots/2.png and b/fastlane/metadata/android/de/images/phoneScreenshots/2.png differ
diff --git a/fastlane/metadata/android/de/images/phoneScreenshots/3.png b/fastlane/metadata/android/de/images/phoneScreenshots/3.png
index eb5b0abf2..c190bb5bd 100644
Binary files a/fastlane/metadata/android/de/images/phoneScreenshots/3.png and b/fastlane/metadata/android/de/images/phoneScreenshots/3.png differ
diff --git a/fastlane/metadata/android/de/images/phoneScreenshots/4.png b/fastlane/metadata/android/de/images/phoneScreenshots/4.png
index 7566991ad..66707d1c0 100644
Binary files a/fastlane/metadata/android/de/images/phoneScreenshots/4.png and b/fastlane/metadata/android/de/images/phoneScreenshots/4.png differ
diff --git a/fastlane/metadata/android/de/images/phoneScreenshots/5.png b/fastlane/metadata/android/de/images/phoneScreenshots/5.png
index 0a6a56bb6..988a95b07 100644
Binary files a/fastlane/metadata/android/de/images/phoneScreenshots/5.png and b/fastlane/metadata/android/de/images/phoneScreenshots/5.png differ
diff --git a/fastlane/metadata/android/de/images/phoneScreenshots/6.png b/fastlane/metadata/android/de/images/phoneScreenshots/6.png
index 8d7fec3d3..9de79e28b 100644
Binary files a/fastlane/metadata/android/de/images/phoneScreenshots/6.png and b/fastlane/metadata/android/de/images/phoneScreenshots/6.png differ
diff --git a/fastlane/metadata/android/de/images/phoneScreenshots/7.png b/fastlane/metadata/android/de/images/phoneScreenshots/7.png
new file mode 100644
index 000000000..6d0ff3f96
Binary files /dev/null and b/fastlane/metadata/android/de/images/phoneScreenshots/7.png differ
diff --git a/fastlane/metadata/android/en-US/changelogs/1078.txt b/fastlane/metadata/android/en-US/changelogs/1078.txt
new file mode 100644
index 000000000..756c775df
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/1078.txt
@@ -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
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png
index 986afd0af..eac80ee54 100644
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png
index 699c677f4..3fdfd7a50 100644
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png
index 5893043b9..5c427c13c 100644
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png
index 727166076..001741e57 100644
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png
index f5a0b693b..1d4f88433 100644
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png
index dc41af368..1ab68a950 100644
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png
new file mode 100644
index 000000000..fd97f6418
Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png differ
diff --git a/fastlane/metadata/android/es-MX/images/phoneScreenshots/1.png b/fastlane/metadata/android/es-MX/images/phoneScreenshots/1.png
index 691254b62..2615a94a2 100644
Binary files a/fastlane/metadata/android/es-MX/images/phoneScreenshots/1.png and b/fastlane/metadata/android/es-MX/images/phoneScreenshots/1.png differ
diff --git a/fastlane/metadata/android/es-MX/images/phoneScreenshots/2.png b/fastlane/metadata/android/es-MX/images/phoneScreenshots/2.png
index 619af80e6..e46458e7f 100644
Binary files a/fastlane/metadata/android/es-MX/images/phoneScreenshots/2.png and b/fastlane/metadata/android/es-MX/images/phoneScreenshots/2.png differ
diff --git a/fastlane/metadata/android/es-MX/images/phoneScreenshots/3.png b/fastlane/metadata/android/es-MX/images/phoneScreenshots/3.png
index a28e9bf7e..7235df192 100644
Binary files a/fastlane/metadata/android/es-MX/images/phoneScreenshots/3.png and b/fastlane/metadata/android/es-MX/images/phoneScreenshots/3.png differ
diff --git a/fastlane/metadata/android/es-MX/images/phoneScreenshots/4.png b/fastlane/metadata/android/es-MX/images/phoneScreenshots/4.png
index bdabfd3f4..0c8910757 100644
Binary files a/fastlane/metadata/android/es-MX/images/phoneScreenshots/4.png and b/fastlane/metadata/android/es-MX/images/phoneScreenshots/4.png differ
diff --git a/fastlane/metadata/android/es-MX/images/phoneScreenshots/5.png b/fastlane/metadata/android/es-MX/images/phoneScreenshots/5.png
index 303fe809e..98b0f8691 100644
Binary files a/fastlane/metadata/android/es-MX/images/phoneScreenshots/5.png and b/fastlane/metadata/android/es-MX/images/phoneScreenshots/5.png differ
diff --git a/fastlane/metadata/android/es-MX/images/phoneScreenshots/6.png b/fastlane/metadata/android/es-MX/images/phoneScreenshots/6.png
index 7cbfb60ce..b50b970eb 100644
Binary files a/fastlane/metadata/android/es-MX/images/phoneScreenshots/6.png and b/fastlane/metadata/android/es-MX/images/phoneScreenshots/6.png differ
diff --git a/fastlane/metadata/android/es-MX/images/phoneScreenshots/7.png b/fastlane/metadata/android/es-MX/images/phoneScreenshots/7.png
new file mode 100644
index 000000000..59dff9ed5
Binary files /dev/null and b/fastlane/metadata/android/es-MX/images/phoneScreenshots/7.png differ
diff --git a/fastlane/metadata/android/fr/images/phoneScreenshots/1.png b/fastlane/metadata/android/fr/images/phoneScreenshots/1.png
index 0fd4909e9..985209ece 100644
Binary files a/fastlane/metadata/android/fr/images/phoneScreenshots/1.png and b/fastlane/metadata/android/fr/images/phoneScreenshots/1.png differ
diff --git a/fastlane/metadata/android/fr/images/phoneScreenshots/2.png b/fastlane/metadata/android/fr/images/phoneScreenshots/2.png
index 05cd6c007..111066ff1 100644
Binary files a/fastlane/metadata/android/fr/images/phoneScreenshots/2.png and b/fastlane/metadata/android/fr/images/phoneScreenshots/2.png differ
diff --git a/fastlane/metadata/android/fr/images/phoneScreenshots/3.png b/fastlane/metadata/android/fr/images/phoneScreenshots/3.png
index 7a42d6be8..2a3e59ee1 100644
Binary files a/fastlane/metadata/android/fr/images/phoneScreenshots/3.png and b/fastlane/metadata/android/fr/images/phoneScreenshots/3.png differ
diff --git a/fastlane/metadata/android/fr/images/phoneScreenshots/4.png b/fastlane/metadata/android/fr/images/phoneScreenshots/4.png
index 4d2f5116d..b15b67da9 100644
Binary files a/fastlane/metadata/android/fr/images/phoneScreenshots/4.png and b/fastlane/metadata/android/fr/images/phoneScreenshots/4.png differ
diff --git a/fastlane/metadata/android/fr/images/phoneScreenshots/5.png b/fastlane/metadata/android/fr/images/phoneScreenshots/5.png
index 48dfa2352..d43e85964 100644
Binary files a/fastlane/metadata/android/fr/images/phoneScreenshots/5.png and b/fastlane/metadata/android/fr/images/phoneScreenshots/5.png differ
diff --git a/fastlane/metadata/android/fr/images/phoneScreenshots/6.png b/fastlane/metadata/android/fr/images/phoneScreenshots/6.png
index 7597244d7..7c792b21b 100644
Binary files a/fastlane/metadata/android/fr/images/phoneScreenshots/6.png and b/fastlane/metadata/android/fr/images/phoneScreenshots/6.png differ
diff --git a/fastlane/metadata/android/fr/images/phoneScreenshots/7.png b/fastlane/metadata/android/fr/images/phoneScreenshots/7.png
new file mode 100644
index 000000000..5c1e6cd36
Binary files /dev/null and b/fastlane/metadata/android/fr/images/phoneScreenshots/7.png differ
diff --git a/fastlane/metadata/android/id/images/phoneScreenshots/1.png b/fastlane/metadata/android/id/images/phoneScreenshots/1.png
index 2a05491b8..7b5bb8c8d 100644
Binary files a/fastlane/metadata/android/id/images/phoneScreenshots/1.png and b/fastlane/metadata/android/id/images/phoneScreenshots/1.png differ
diff --git a/fastlane/metadata/android/id/images/phoneScreenshots/2.png b/fastlane/metadata/android/id/images/phoneScreenshots/2.png
index 386bba1a8..276edee53 100644
Binary files a/fastlane/metadata/android/id/images/phoneScreenshots/2.png and b/fastlane/metadata/android/id/images/phoneScreenshots/2.png differ
diff --git a/fastlane/metadata/android/id/images/phoneScreenshots/3.png b/fastlane/metadata/android/id/images/phoneScreenshots/3.png
index 8ef649c71..0e6eb3db7 100644
Binary files a/fastlane/metadata/android/id/images/phoneScreenshots/3.png and b/fastlane/metadata/android/id/images/phoneScreenshots/3.png differ
diff --git a/fastlane/metadata/android/id/images/phoneScreenshots/4.png b/fastlane/metadata/android/id/images/phoneScreenshots/4.png
index f7e145bb2..b19652df5 100644
Binary files a/fastlane/metadata/android/id/images/phoneScreenshots/4.png and b/fastlane/metadata/android/id/images/phoneScreenshots/4.png differ
diff --git a/fastlane/metadata/android/id/images/phoneScreenshots/5.png b/fastlane/metadata/android/id/images/phoneScreenshots/5.png
index a3ae51c2c..e65c058db 100644
Binary files a/fastlane/metadata/android/id/images/phoneScreenshots/5.png and b/fastlane/metadata/android/id/images/phoneScreenshots/5.png differ
diff --git a/fastlane/metadata/android/id/images/phoneScreenshots/6.png b/fastlane/metadata/android/id/images/phoneScreenshots/6.png
index a973e9bcf..f1f00ca5e 100644
Binary files a/fastlane/metadata/android/id/images/phoneScreenshots/6.png and b/fastlane/metadata/android/id/images/phoneScreenshots/6.png differ
diff --git a/fastlane/metadata/android/id/images/phoneScreenshots/7.png b/fastlane/metadata/android/id/images/phoneScreenshots/7.png
new file mode 100644
index 000000000..e3af538e6
Binary files /dev/null and b/fastlane/metadata/android/id/images/phoneScreenshots/7.png differ
diff --git a/fastlane/metadata/android/it/images/phoneScreenshots/1.png b/fastlane/metadata/android/it/images/phoneScreenshots/1.png
index 72c0e69d3..9489221ca 100644
Binary files a/fastlane/metadata/android/it/images/phoneScreenshots/1.png and b/fastlane/metadata/android/it/images/phoneScreenshots/1.png differ
diff --git a/fastlane/metadata/android/it/images/phoneScreenshots/2.png b/fastlane/metadata/android/it/images/phoneScreenshots/2.png
index 9f291bbca..6ba6e70dc 100644
Binary files a/fastlane/metadata/android/it/images/phoneScreenshots/2.png and b/fastlane/metadata/android/it/images/phoneScreenshots/2.png differ
diff --git a/fastlane/metadata/android/it/images/phoneScreenshots/3.png b/fastlane/metadata/android/it/images/phoneScreenshots/3.png
index fb089dd6c..5ce25cd4d 100644
Binary files a/fastlane/metadata/android/it/images/phoneScreenshots/3.png and b/fastlane/metadata/android/it/images/phoneScreenshots/3.png differ
diff --git a/fastlane/metadata/android/it/images/phoneScreenshots/4.png b/fastlane/metadata/android/it/images/phoneScreenshots/4.png
index e49333640..b0a32aa7c 100644
Binary files a/fastlane/metadata/android/it/images/phoneScreenshots/4.png and b/fastlane/metadata/android/it/images/phoneScreenshots/4.png differ
diff --git a/fastlane/metadata/android/it/images/phoneScreenshots/5.png b/fastlane/metadata/android/it/images/phoneScreenshots/5.png
index e8d6e3691..af8bc2c4d 100644
Binary files a/fastlane/metadata/android/it/images/phoneScreenshots/5.png and b/fastlane/metadata/android/it/images/phoneScreenshots/5.png differ
diff --git a/fastlane/metadata/android/it/images/phoneScreenshots/6.png b/fastlane/metadata/android/it/images/phoneScreenshots/6.png
index 0780544c6..854b14068 100644
Binary files a/fastlane/metadata/android/it/images/phoneScreenshots/6.png and b/fastlane/metadata/android/it/images/phoneScreenshots/6.png differ
diff --git a/fastlane/metadata/android/it/images/phoneScreenshots/7.png b/fastlane/metadata/android/it/images/phoneScreenshots/7.png
new file mode 100644
index 000000000..fe36b22d3
Binary files /dev/null and b/fastlane/metadata/android/it/images/phoneScreenshots/7.png differ
diff --git a/fastlane/metadata/android/ja/images/phoneScreenshots/1.png b/fastlane/metadata/android/ja/images/phoneScreenshots/1.png
index 727e65483..7113ce3c1 100644
Binary files a/fastlane/metadata/android/ja/images/phoneScreenshots/1.png and b/fastlane/metadata/android/ja/images/phoneScreenshots/1.png differ
diff --git a/fastlane/metadata/android/ja/images/phoneScreenshots/2.png b/fastlane/metadata/android/ja/images/phoneScreenshots/2.png
index 225213380..80352a8e4 100644
Binary files a/fastlane/metadata/android/ja/images/phoneScreenshots/2.png and b/fastlane/metadata/android/ja/images/phoneScreenshots/2.png differ
diff --git a/fastlane/metadata/android/ja/images/phoneScreenshots/3.png b/fastlane/metadata/android/ja/images/phoneScreenshots/3.png
index 4b85e913a..1220cee5a 100644
Binary files a/fastlane/metadata/android/ja/images/phoneScreenshots/3.png and b/fastlane/metadata/android/ja/images/phoneScreenshots/3.png differ
diff --git a/fastlane/metadata/android/ja/images/phoneScreenshots/4.png b/fastlane/metadata/android/ja/images/phoneScreenshots/4.png
index b16fcd1f3..39ab1da91 100644
Binary files a/fastlane/metadata/android/ja/images/phoneScreenshots/4.png and b/fastlane/metadata/android/ja/images/phoneScreenshots/4.png differ
diff --git a/fastlane/metadata/android/ja/images/phoneScreenshots/5.png b/fastlane/metadata/android/ja/images/phoneScreenshots/5.png
index b57fa9ea2..a893c9401 100644
Binary files a/fastlane/metadata/android/ja/images/phoneScreenshots/5.png and b/fastlane/metadata/android/ja/images/phoneScreenshots/5.png differ
diff --git a/fastlane/metadata/android/ja/images/phoneScreenshots/6.png b/fastlane/metadata/android/ja/images/phoneScreenshots/6.png
index 682f00a67..94a630301 100644
Binary files a/fastlane/metadata/android/ja/images/phoneScreenshots/6.png and b/fastlane/metadata/android/ja/images/phoneScreenshots/6.png differ
diff --git a/fastlane/metadata/android/ja/images/phoneScreenshots/7.png b/fastlane/metadata/android/ja/images/phoneScreenshots/7.png
new file mode 100644
index 000000000..565e6ebef
Binary files /dev/null and b/fastlane/metadata/android/ja/images/phoneScreenshots/7.png differ
diff --git a/fastlane/metadata/android/ko/images/phoneScreenshots/1.png b/fastlane/metadata/android/ko/images/phoneScreenshots/1.png
index 941382121..af02b0701 100644
Binary files a/fastlane/metadata/android/ko/images/phoneScreenshots/1.png and b/fastlane/metadata/android/ko/images/phoneScreenshots/1.png differ
diff --git a/fastlane/metadata/android/ko/images/phoneScreenshots/2.png b/fastlane/metadata/android/ko/images/phoneScreenshots/2.png
index 3f1647e3f..a90b98b25 100644
Binary files a/fastlane/metadata/android/ko/images/phoneScreenshots/2.png and b/fastlane/metadata/android/ko/images/phoneScreenshots/2.png differ
diff --git a/fastlane/metadata/android/ko/images/phoneScreenshots/3.png b/fastlane/metadata/android/ko/images/phoneScreenshots/3.png
index 62b7c19be..1f32a2593 100644
Binary files a/fastlane/metadata/android/ko/images/phoneScreenshots/3.png and b/fastlane/metadata/android/ko/images/phoneScreenshots/3.png differ
diff --git a/fastlane/metadata/android/ko/images/phoneScreenshots/4.png b/fastlane/metadata/android/ko/images/phoneScreenshots/4.png
index c80b35c0f..f8a757e1c 100644
Binary files a/fastlane/metadata/android/ko/images/phoneScreenshots/4.png and b/fastlane/metadata/android/ko/images/phoneScreenshots/4.png differ
diff --git a/fastlane/metadata/android/ko/images/phoneScreenshots/5.png b/fastlane/metadata/android/ko/images/phoneScreenshots/5.png
index f61817ea3..e8ab9c453 100644
Binary files a/fastlane/metadata/android/ko/images/phoneScreenshots/5.png and b/fastlane/metadata/android/ko/images/phoneScreenshots/5.png differ
diff --git a/fastlane/metadata/android/ko/images/phoneScreenshots/6.png b/fastlane/metadata/android/ko/images/phoneScreenshots/6.png
index e179b2c05..206370d9b 100644
Binary files a/fastlane/metadata/android/ko/images/phoneScreenshots/6.png and b/fastlane/metadata/android/ko/images/phoneScreenshots/6.png differ
diff --git a/fastlane/metadata/android/ko/images/phoneScreenshots/7.png b/fastlane/metadata/android/ko/images/phoneScreenshots/7.png
new file mode 100644
index 000000000..514fa72c2
Binary files /dev/null and b/fastlane/metadata/android/ko/images/phoneScreenshots/7.png differ
diff --git a/fastlane/metadata/android/nl/images/featureGraphic.png b/fastlane/metadata/android/nl/images/featureGraphic.png
new file mode 100644
index 000000000..ec6a7efdc
Binary files /dev/null and b/fastlane/metadata/android/nl/images/featureGraphic.png differ
diff --git a/fastlane/metadata/android/nl/images/phoneScreenshots/1.png b/fastlane/metadata/android/nl/images/phoneScreenshots/1.png
new file mode 100644
index 000000000..d10712612
Binary files /dev/null and b/fastlane/metadata/android/nl/images/phoneScreenshots/1.png differ
diff --git a/fastlane/metadata/android/nl/images/phoneScreenshots/2.png b/fastlane/metadata/android/nl/images/phoneScreenshots/2.png
new file mode 100644
index 000000000..0d12b0043
Binary files /dev/null and b/fastlane/metadata/android/nl/images/phoneScreenshots/2.png differ
diff --git a/fastlane/metadata/android/nl/images/phoneScreenshots/3.png b/fastlane/metadata/android/nl/images/phoneScreenshots/3.png
new file mode 100644
index 000000000..71352d755
Binary files /dev/null and b/fastlane/metadata/android/nl/images/phoneScreenshots/3.png differ
diff --git a/fastlane/metadata/android/nl/images/phoneScreenshots/4.png b/fastlane/metadata/android/nl/images/phoneScreenshots/4.png
new file mode 100644
index 000000000..d8fb4d410
Binary files /dev/null and b/fastlane/metadata/android/nl/images/phoneScreenshots/4.png differ
diff --git a/fastlane/metadata/android/nl/images/phoneScreenshots/5.png b/fastlane/metadata/android/nl/images/phoneScreenshots/5.png
new file mode 100644
index 000000000..0b67697c2
Binary files /dev/null and b/fastlane/metadata/android/nl/images/phoneScreenshots/5.png differ
diff --git a/fastlane/metadata/android/nl/images/phoneScreenshots/6.png b/fastlane/metadata/android/nl/images/phoneScreenshots/6.png
new file mode 100644
index 000000000..2bc15db64
Binary files /dev/null and b/fastlane/metadata/android/nl/images/phoneScreenshots/6.png differ
diff --git a/fastlane/metadata/android/nl/images/phoneScreenshots/7.png b/fastlane/metadata/android/nl/images/phoneScreenshots/7.png
new file mode 100644
index 000000000..f9c3c461a
Binary files /dev/null and b/fastlane/metadata/android/nl/images/phoneScreenshots/7.png differ
diff --git a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/1.png b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/1.png
index d86705a78..44b10be73 100644
Binary files a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/1.png and b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/1.png differ
diff --git a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/2.png b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/2.png
index e2fe95143..9ca056b3b 100644
Binary files a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/2.png and b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/2.png differ
diff --git a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/3.png b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/3.png
index cd473cb8f..e2d92740f 100644
Binary files a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/3.png and b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/3.png differ
diff --git a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/4.png b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/4.png
index 833120355..c5f7da758 100644
Binary files a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/4.png and b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/4.png differ
diff --git a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/5.png b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/5.png
index bebe66d01..79190592c 100644
Binary files a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/5.png and b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/5.png differ
diff --git a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/6.png b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/6.png
index 6e25bb7af..90ba8aaf1 100644
Binary files a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/6.png and b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/6.png differ
diff --git a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/7.png b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/7.png
new file mode 100644
index 000000000..ef878766c
Binary files /dev/null and b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/7.png differ
diff --git a/fastlane/metadata/android/ru/images/phoneScreenshots/1.png b/fastlane/metadata/android/ru/images/phoneScreenshots/1.png
index e3aba1e4e..a9aa6c997 100644
Binary files a/fastlane/metadata/android/ru/images/phoneScreenshots/1.png and b/fastlane/metadata/android/ru/images/phoneScreenshots/1.png differ
diff --git a/fastlane/metadata/android/ru/images/phoneScreenshots/2.png b/fastlane/metadata/android/ru/images/phoneScreenshots/2.png
index 2758b8b4a..9f0d34127 100644
Binary files a/fastlane/metadata/android/ru/images/phoneScreenshots/2.png and b/fastlane/metadata/android/ru/images/phoneScreenshots/2.png differ
diff --git a/fastlane/metadata/android/ru/images/phoneScreenshots/3.png b/fastlane/metadata/android/ru/images/phoneScreenshots/3.png
index f576406e9..962bb91c8 100644
Binary files a/fastlane/metadata/android/ru/images/phoneScreenshots/3.png and b/fastlane/metadata/android/ru/images/phoneScreenshots/3.png differ
diff --git a/fastlane/metadata/android/ru/images/phoneScreenshots/4.png b/fastlane/metadata/android/ru/images/phoneScreenshots/4.png
index b1a478dda..59f1e519b 100644
Binary files a/fastlane/metadata/android/ru/images/phoneScreenshots/4.png and b/fastlane/metadata/android/ru/images/phoneScreenshots/4.png differ
diff --git a/fastlane/metadata/android/ru/images/phoneScreenshots/5.png b/fastlane/metadata/android/ru/images/phoneScreenshots/5.png
index df6368d83..94174ea57 100644
Binary files a/fastlane/metadata/android/ru/images/phoneScreenshots/5.png and b/fastlane/metadata/android/ru/images/phoneScreenshots/5.png differ
diff --git a/fastlane/metadata/android/ru/images/phoneScreenshots/6.png b/fastlane/metadata/android/ru/images/phoneScreenshots/6.png
index e3454036d..9e44c218f 100644
Binary files a/fastlane/metadata/android/ru/images/phoneScreenshots/6.png and b/fastlane/metadata/android/ru/images/phoneScreenshots/6.png differ
diff --git a/fastlane/metadata/android/ru/images/phoneScreenshots/7.png b/fastlane/metadata/android/ru/images/phoneScreenshots/7.png
new file mode 100644
index 000000000..4951e7996
Binary files /dev/null and b/fastlane/metadata/android/ru/images/phoneScreenshots/7.png differ
diff --git a/fastlane/metadata/android/tr/images/phoneScreenshots/1.png b/fastlane/metadata/android/tr/images/phoneScreenshots/1.png
index a8ee2dbe5..b1fe36389 100644
Binary files a/fastlane/metadata/android/tr/images/phoneScreenshots/1.png and b/fastlane/metadata/android/tr/images/phoneScreenshots/1.png differ
diff --git a/fastlane/metadata/android/tr/images/phoneScreenshots/2.png b/fastlane/metadata/android/tr/images/phoneScreenshots/2.png
index 2d8ce44ea..9f7ff6e8e 100644
Binary files a/fastlane/metadata/android/tr/images/phoneScreenshots/2.png and b/fastlane/metadata/android/tr/images/phoneScreenshots/2.png differ
diff --git a/fastlane/metadata/android/tr/images/phoneScreenshots/3.png b/fastlane/metadata/android/tr/images/phoneScreenshots/3.png
index 4daa50af6..20de8e056 100644
Binary files a/fastlane/metadata/android/tr/images/phoneScreenshots/3.png and b/fastlane/metadata/android/tr/images/phoneScreenshots/3.png differ
diff --git a/fastlane/metadata/android/tr/images/phoneScreenshots/4.png b/fastlane/metadata/android/tr/images/phoneScreenshots/4.png
index 94f569bd6..0461a3cd1 100644
Binary files a/fastlane/metadata/android/tr/images/phoneScreenshots/4.png and b/fastlane/metadata/android/tr/images/phoneScreenshots/4.png differ
diff --git a/fastlane/metadata/android/tr/images/phoneScreenshots/5.png b/fastlane/metadata/android/tr/images/phoneScreenshots/5.png
index 2f293b32d..031655f8e 100644
Binary files a/fastlane/metadata/android/tr/images/phoneScreenshots/5.png and b/fastlane/metadata/android/tr/images/phoneScreenshots/5.png differ
diff --git a/fastlane/metadata/android/tr/images/phoneScreenshots/6.png b/fastlane/metadata/android/tr/images/phoneScreenshots/6.png
index afaff9751..541c304a2 100644
Binary files a/fastlane/metadata/android/tr/images/phoneScreenshots/6.png and b/fastlane/metadata/android/tr/images/phoneScreenshots/6.png differ
diff --git a/fastlane/metadata/android/tr/images/phoneScreenshots/7.png b/fastlane/metadata/android/tr/images/phoneScreenshots/7.png
new file mode 100644
index 000000000..001da561b
Binary files /dev/null and b/fastlane/metadata/android/tr/images/phoneScreenshots/7.png differ
diff --git a/fastlane/metadata/android/zh-CN/images/phoneScreenshots/1.png b/fastlane/metadata/android/zh-CN/images/phoneScreenshots/1.png
index f762e1bbb..7b493db17 100644
Binary files a/fastlane/metadata/android/zh-CN/images/phoneScreenshots/1.png and b/fastlane/metadata/android/zh-CN/images/phoneScreenshots/1.png differ
diff --git a/fastlane/metadata/android/zh-CN/images/phoneScreenshots/2.png b/fastlane/metadata/android/zh-CN/images/phoneScreenshots/2.png
index 297d5e4af..d8b0242d3 100644
Binary files a/fastlane/metadata/android/zh-CN/images/phoneScreenshots/2.png and b/fastlane/metadata/android/zh-CN/images/phoneScreenshots/2.png differ
diff --git a/fastlane/metadata/android/zh-CN/images/phoneScreenshots/3.png b/fastlane/metadata/android/zh-CN/images/phoneScreenshots/3.png
index b367d768f..3f0627ce5 100644
Binary files a/fastlane/metadata/android/zh-CN/images/phoneScreenshots/3.png and b/fastlane/metadata/android/zh-CN/images/phoneScreenshots/3.png differ
diff --git a/fastlane/metadata/android/zh-CN/images/phoneScreenshots/4.png b/fastlane/metadata/android/zh-CN/images/phoneScreenshots/4.png
index ef4ce2758..5ed4152f1 100644
Binary files a/fastlane/metadata/android/zh-CN/images/phoneScreenshots/4.png and b/fastlane/metadata/android/zh-CN/images/phoneScreenshots/4.png differ
diff --git a/fastlane/metadata/android/zh-CN/images/phoneScreenshots/5.png b/fastlane/metadata/android/zh-CN/images/phoneScreenshots/5.png
index a29c7d376..815a53272 100644
Binary files a/fastlane/metadata/android/zh-CN/images/phoneScreenshots/5.png and b/fastlane/metadata/android/zh-CN/images/phoneScreenshots/5.png differ
diff --git a/fastlane/metadata/android/zh-CN/images/phoneScreenshots/6.png b/fastlane/metadata/android/zh-CN/images/phoneScreenshots/6.png
index 9dd4e80de..462d85e04 100644
Binary files a/fastlane/metadata/android/zh-CN/images/phoneScreenshots/6.png and b/fastlane/metadata/android/zh-CN/images/phoneScreenshots/6.png differ
diff --git a/fastlane/metadata/android/zh-CN/images/phoneScreenshots/7.png b/fastlane/metadata/android/zh-CN/images/phoneScreenshots/7.png
new file mode 100644
index 000000000..44b97b4c7
Binary files /dev/null and b/fastlane/metadata/android/zh-CN/images/phoneScreenshots/7.png differ
diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb
index 0b6314a68..e965f4c6f 100644
--- a/lib/l10n/app_de.arb
+++ b/lib/l10n/app_de.arb
@@ -87,6 +87,7 @@
"entryInfoActionEditDate": "Datum & Uhrzeit bearbeiten",
"entryInfoActionEditLocation": "Standort bearbeiten",
+ "entryInfoActionEditDescription": "Beschreibung bearbeiten",
"entryInfoActionEditRating": "Bewertung bearbeiten",
"entryInfoActionEditTags": "Tags bearbeiten",
"entryInfoActionRemoveMetadata": "Metadaten entfernen",
@@ -96,6 +97,7 @@
"filterLocationEmptyLabel": "Ungeortet",
"filterTagEmptyLabel": "Unmarkiert",
"filterOnThisDayLabel": "Am heutigen Tag",
+ "filterRecentlyAddedLabel": "Kürzlich hinzugefügt",
"filterRatingUnratedLabel": "Nicht bewertet",
"filterRatingRejectedLabel": "Verworfen",
"filterTypeAnimatedLabel": "Animationen",
@@ -238,6 +240,8 @@
"renameEntryDialogLabel": "Neuer Name",
+ "editEntryDialogTargetFieldsHeader": "Zu ändernde Felder",
+
"editEntryDateDialogTitle": "Datum & Uhrzeit",
"editEntryDateDialogSetCustom": "Datum einstellen",
"editEntryDateDialogCopyField": "Von anderem Datum kopieren",
@@ -245,7 +249,6 @@
"editEntryDateDialogExtractFromTitle": "Auszug aus dem Titel",
"editEntryDateDialogShift": "Verschieben",
"editEntryDateDialogSourceFileModifiedDate": "Änderungsdatum der Datei",
- "editEntryDateDialogTargetFieldsHeader": "Zu ändernde Felder",
"editEntryDateDialogHours": "Stunden",
"editEntryDateDialogMinutes": "Minuten",
@@ -256,6 +259,8 @@
"locationPickerUseThisLocationButton": "Diesen Standort verwenden",
+ "editEntryDescriptionDialogTitle": "Beschreibung",
+
"editEntryRatingDialogTitle": "Bewertung",
"removeEntryMetadataDialogTitle": "Entfernung von Metadaten",
@@ -450,6 +455,7 @@
"settingsConfirmationDialogDeleteItems": "Vor dem endgültigen Löschen von Elementen fragen",
"settingsConfirmationDialogMoveToBinItems": "Vor dem Verschieben von Elementen in den Papierkorb fragen",
"settingsConfirmationDialogMoveUndatedItems": "Vor Verschiebung von Objekten ohne Metadaten-Datum fragen",
+ "settingsConfirmationAfterMoveToBinItems": "Nachricht nach dem Verschieben von Elementen in den Papierkorb anzeigen",
"settingsNavigationDrawerTile": "Menü Navigation",
"settingsNavigationDrawerEditorTitle": "Menü Navigation",
@@ -478,6 +484,7 @@
"settingsCollectionSelectionQuickActionEditorBanner": "Die Taste gedrückt halten, um die Schaltflächen zu bewegen und auszuwählen, welche Aktionen beim Durchsuchen von Elementen angezeigt werden.",
"settingsSectionViewer": "Anzeige",
+ "settingsViewerGestureSideTapNext": "Tippen auf den Bildschirmrand, um das vorheriges/nächstes Element anzuzeigen",
"settingsViewerUseCutout": "Ausgeschnittenen Bereich verwenden",
"settingsViewerMaximumBrightness": "Maximale Helligkeit",
"settingsMotionPhotoAutoPlay": "Automatische Wiedergabe bewegter Fotos",
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index 049cc3542..0028aeeca 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -115,6 +115,7 @@
"entryInfoActionEditDate": "Edit date & time",
"entryInfoActionEditLocation": "Edit location",
+ "entryInfoActionEditDescription": "Edit description",
"entryInfoActionEditRating": "Edit rating",
"entryInfoActionEditTags": "Edit tags",
"entryInfoActionRemoveMetadata": "Remove metadata",
@@ -124,6 +125,7 @@
"filterLocationEmptyLabel": "Unlocated",
"filterTagEmptyLabel": "Untagged",
"filterOnThisDayLabel": "On this day",
+ "filterRecentlyAddedLabel": "Recently added",
"filterRatingUnratedLabel": "Unrated",
"filterRatingRejectedLabel": "Rejected",
"filterTypeAnimatedLabel": "Animated",
@@ -368,6 +370,8 @@
"renameEntryDialogLabel": "New name",
+ "editEntryDialogTargetFieldsHeader": "Fields to modify",
+
"editEntryDateDialogTitle": "Date & Time",
"editEntryDateDialogSetCustom": "Set custom date",
"editEntryDateDialogCopyField": "Copy from other date",
@@ -375,7 +379,6 @@
"editEntryDateDialogExtractFromTitle": "Extract from title",
"editEntryDateDialogShift": "Shift",
"editEntryDateDialogSourceFileModifiedDate": "File modified date",
- "editEntryDateDialogTargetFieldsHeader": "Fields to modify",
"editEntryDateDialogHours": "Hours",
"editEntryDateDialogMinutes": "Minutes",
@@ -386,6 +389,8 @@
"locationPickerUseThisLocationButton": "Use this location",
+ "editEntryDescriptionDialogTitle": "Description",
+
"editEntryRatingDialogTitle": "Rating",
"removeEntryMetadataDialogTitle": "Metadata Removal",
@@ -630,6 +635,7 @@
"settingsConfirmationDialogDeleteItems": "Ask before deleting items forever",
"settingsConfirmationDialogMoveToBinItems": "Ask before moving items to the recycle bin",
"settingsConfirmationDialogMoveUndatedItems": "Ask before moving undated items",
+ "settingsConfirmationAfterMoveToBinItems": "Show message after moving items to the recycle bin",
"settingsNavigationDrawerTile": "Navigation menu",
"settingsNavigationDrawerEditorTitle": "Navigation Menu",
@@ -658,6 +664,7 @@
"settingsCollectionSelectionQuickActionEditorBanner": "Touch and hold to move buttons and select which actions are displayed when selecting items.",
"settingsSectionViewer": "Viewer",
+ "settingsViewerGestureSideTapNext": "Tap on screen edges to show previous/next item",
"settingsViewerUseCutout": "Use cutout area",
"settingsViewerMaximumBrightness": "Maximum brightness",
"settingsMotionPhotoAutoPlay": "Auto play motion photos",
diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb
index f9bfde672..09f052491 100644
--- a/lib/l10n/app_es.arb
+++ b/lib/l10n/app_es.arb
@@ -95,6 +95,7 @@
"filterFavouriteLabel": "Favorito",
"filterLocationEmptyLabel": "No localizado",
"filterTagEmptyLabel": "Sin etiquetar",
+ "filterOnThisDayLabel": "De este día",
"filterRatingUnratedLabel": "Sin clasificar",
"filterRatingRejectedLabel": "Rechazado",
"filterTypeAnimatedLabel": "Animado",
@@ -237,6 +238,8 @@
"renameEntryDialogLabel": "Renombrar",
+ "editEntryDialogTargetFieldsHeader": "Campos a modificar",
+
"editEntryDateDialogTitle": "Fecha y hora",
"editEntryDateDialogSetCustom": "Establecer fecha personalizada",
"editEntryDateDialogCopyField": "Copiar de otra fecha",
@@ -244,7 +247,6 @@
"editEntryDateDialogExtractFromTitle": "Extraer del título",
"editEntryDateDialogShift": "Cambiar",
"editEntryDateDialogSourceFileModifiedDate": "Fecha de modificación del archivo",
- "editEntryDateDialogTargetFieldsHeader": "Campos a modificar",
"editEntryDateDialogHours": "Horas",
"editEntryDateDialogMinutes": "Minutos",
@@ -477,6 +479,7 @@
"settingsCollectionSelectionQuickActionEditorBanner": "Toque y mantenga para mover botones y seleccionar cuáles acciones se muestran mientras selecciona elementos.",
"settingsSectionViewer": "Visor",
+ "settingsViewerGestureSideTapNext": "Toque en los bordes de la pantalla para mostrar el elemento anterior/siguiente",
"settingsViewerUseCutout": "Usar área recortada",
"settingsViewerMaximumBrightness": "Brillo máximo",
"settingsMotionPhotoAutoPlay": "Reproducir automáticamente fotos en movimiento",
@@ -503,6 +506,7 @@
"settingsViewerSlideshowTitle": "Presentación",
"settingsSlideshowRepeat": "Repetir",
"settingsSlideshowShuffle": "Mezclar",
+ "settingsSlideshowFillScreen": "Llenar pantalla",
"settingsSlideshowTransitionTile": "Transición",
"settingsSlideshowTransitionTitle": "Transición",
"settingsSlideshowIntervalTile": "Intervalo",
@@ -585,6 +589,11 @@
"settingsUnitSystemTile": "Unidades",
"settingsUnitSystemTitle": "Unidades",
+ "settingsScreenSaverPageTitle": "Protector de pantalla",
+
+ "settingsWidgetPageTitle": "Marco de foto",
+ "settingsWidgetShowOutline": "Borde",
+
"statsPageTitle": "Stats",
"statsWithGps": "{count, plural, =1{1 elemento con ubicación} other{{count} elementos con ubicación}}",
"statsTopCountries": "Países principales",
diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb
index 95c0e5529..5a91e964c 100644
--- a/lib/l10n/app_fr.arb
+++ b/lib/l10n/app_fr.arb
@@ -87,6 +87,7 @@
"entryInfoActionEditDate": "Modifier la date",
"entryInfoActionEditLocation": "Modifier le lieu",
+ "entryInfoActionEditDescription": "Modifier la description",
"entryInfoActionEditRating": "Modifier la notation",
"entryInfoActionEditTags": "Modifier les libellés",
"entryInfoActionRemoveMetadata": "Retirer les métadonnées",
@@ -96,6 +97,7 @@
"filterLocationEmptyLabel": "Sans lieu",
"filterTagEmptyLabel": "Sans libellé",
"filterOnThisDayLabel": "Ce jour-là",
+ "filterRecentlyAddedLabel": "Ajouté récemment",
"filterRatingUnratedLabel": "Sans notation",
"filterRatingRejectedLabel": "Rejeté",
"filterTypeAnimatedLabel": "Animation",
@@ -238,6 +240,8 @@
"renameEntryDialogLabel": "Nouveau nom",
+ "editEntryDialogTargetFieldsHeader": "Champs à modifier",
+
"editEntryDateDialogTitle": "Date & Heure",
"editEntryDateDialogSetCustom": "Régler une date personnalisée",
"editEntryDateDialogCopyField": "Copier d’une autre date",
@@ -245,7 +249,6 @@
"editEntryDateDialogExtractFromTitle": "Extraire du titre",
"editEntryDateDialogShift": "Décaler",
"editEntryDateDialogSourceFileModifiedDate": "Date de modification du fichier",
- "editEntryDateDialogTargetFieldsHeader": "Champs à modifier",
"editEntryDateDialogHours": "Heures",
"editEntryDateDialogMinutes": "Minutes",
@@ -256,6 +259,8 @@
"locationPickerUseThisLocationButton": "Utiliser ce lieu",
+ "editEntryDescriptionDialogTitle": "Description",
+
"editEntryRatingDialogTitle": "Notation",
"removeEntryMetadataDialogTitle": "Retrait de métadonnées",
@@ -450,6 +455,7 @@
"settingsConfirmationDialogDeleteItems": "Suppression définitive d’éléments",
"settingsConfirmationDialogMoveToBinItems": "Mise d’éléments à la corbeille",
"settingsConfirmationDialogMoveUndatedItems": "Déplacement d’éléments non datés",
+ "settingsConfirmationAfterMoveToBinItems": "Confirmation après mise d’éléments à la corbeille",
"settingsNavigationDrawerTile": "Menu de navigation",
"settingsNavigationDrawerEditorTitle": "Menu de navigation",
@@ -478,6 +484,7 @@
"settingsCollectionSelectionQuickActionEditorBanner": "Maintenez votre doigt appuyé pour déplacer les boutons et choisir les actions affichées lors de la sélection d’éléments.",
"settingsSectionViewer": "Visionneuse",
+ "settingsViewerGestureSideTapNext": "Appuyer sur les bords de l’écran pour passer à l’élément précédent/suivant",
"settingsViewerUseCutout": "Utiliser la zone d’encoche",
"settingsViewerMaximumBrightness": "Luminosité maximale",
"settingsMotionPhotoAutoPlay": "Lecture automatique des photos animées",
diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb
index c067ecd89..c39b2d872 100644
--- a/lib/l10n/app_id.arb
+++ b/lib/l10n/app_id.arb
@@ -95,6 +95,7 @@
"filterFavouriteLabel": "Favorit",
"filterLocationEmptyLabel": "Lokasi yang tidak ditemukan",
"filterTagEmptyLabel": "Tidak dilabel",
+ "filterOnThisDayLabel": "Di hari ini",
"filterRatingUnratedLabel": "Belum diberi nilai",
"filterRatingRejectedLabel": "Ditolak",
"filterTypeAnimatedLabel": "Teranimasi",
@@ -237,6 +238,8 @@
"renameEntryDialogLabel": "Nama baru",
+ "editEntryDialogTargetFieldsHeader": "Bidang untuk dimodifikasikan",
+
"editEntryDateDialogTitle": "Tanggal & Waktu",
"editEntryDateDialogSetCustom": "Atur tanggal khusus",
"editEntryDateDialogCopyField": "Salin dari tanggal lain",
@@ -244,7 +247,6 @@
"editEntryDateDialogExtractFromTitle": "Ekstrak dari judul",
"editEntryDateDialogShift": "Geser",
"editEntryDateDialogSourceFileModifiedDate": "Tanggal modifikasi file",
- "editEntryDateDialogTargetFieldsHeader": "Bidang untuk dimodifikasikan",
"editEntryDateDialogHours": "Jam",
"editEntryDateDialogMinutes": "Menit",
@@ -503,6 +505,7 @@
"settingsViewerSlideshowTitle": "Tampilan Slide",
"settingsSlideshowRepeat": "Ulangi",
"settingsSlideshowShuffle": "Acak",
+ "settingsSlideshowFillScreen": "Isi layar",
"settingsSlideshowTransitionTile": "Transisi",
"settingsSlideshowTransitionTitle": "Transisi",
"settingsSlideshowIntervalTile": "Interval",
@@ -585,6 +588,11 @@
"settingsUnitSystemTile": "Unit",
"settingsUnitSystemTitle": "Unit",
+ "settingsScreenSaverPageTitle": "Screensaver",
+
+ "settingsWidgetPageTitle": "Bingkai Foto",
+ "settingsWidgetShowOutline": "Garis luar",
+
"statsPageTitle": "Statistik",
"statsWithGps": "{count, plural, other{{count} benda dengan lokasi}}",
"statsTopCountries": "Negara Teratas",
diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb
index 6527e85bf..3d24c1639 100644
--- a/lib/l10n/app_it.arb
+++ b/lib/l10n/app_it.arb
@@ -87,6 +87,7 @@
"entryInfoActionEditDate": "Modifica data e ora",
"entryInfoActionEditLocation": "Modifica posizione",
+ "entryInfoActionEditDescription": "Modifica descrizione",
"entryInfoActionEditRating": "Modifica valutazione",
"entryInfoActionEditTags": "Modifica etichetta",
"entryInfoActionRemoveMetadata": "Rimuovi metadati",
@@ -96,6 +97,7 @@
"filterLocationEmptyLabel": "Senza posizione",
"filterTagEmptyLabel": "Senza etichetta",
"filterOnThisDayLabel": "In questo giorno",
+ "filterRecentlyAddedLabel": "Aggiunto di recente",
"filterRatingUnratedLabel": "Non valutato",
"filterRatingRejectedLabel": "Rifiutato",
"filterTypeAnimatedLabel": "Animato",
@@ -238,6 +240,8 @@
"renameEntryDialogLabel": "Nuovo nome",
+ "editEntryDialogTargetFieldsHeader": "Campi da modificare",
+
"editEntryDateDialogTitle": "Data e ora",
"editEntryDateDialogSetCustom": "Imposta data personalizzata",
"editEntryDateDialogCopyField": "Copia da un’altra data",
@@ -245,7 +249,6 @@
"editEntryDateDialogExtractFromTitle": "Estrai dal titolo",
"editEntryDateDialogShift": "Turno",
"editEntryDateDialogSourceFileModifiedDate": "Data di modifica del file",
- "editEntryDateDialogTargetFieldsHeader": "Campi da modificare",
"editEntryDateDialogHours": "Ore",
"editEntryDateDialogMinutes": "Minuti",
@@ -256,6 +259,8 @@
"locationPickerUseThisLocationButton": "Usa questa posizione",
+ "editEntryDescriptionDialogTitle": "Descrizione",
+
"editEntryRatingDialogTitle": "Valutazione",
"removeEntryMetadataDialogTitle": "Rimozione dei metadati",
@@ -450,6 +455,7 @@
"settingsConfirmationDialogDeleteItems": "Chiedi prima di cancellare gli elementi definitivamente",
"settingsConfirmationDialogMoveToBinItems": "Chiedi prima di spostare gli elementi nel cestino",
"settingsConfirmationDialogMoveUndatedItems": "Chiedi prima di spostare gli elementi senza data",
+ "settingsConfirmationAfterMoveToBinItems": "Mostra un messaggio dopo aver spostato gli elementi nel cestino",
"settingsNavigationDrawerTile": "Menu di navigazione",
"settingsNavigationDrawerEditorTitle": "Menu di navigazione",
@@ -478,6 +484,7 @@
"settingsCollectionSelectionQuickActionEditorBanner": "Tocca e tieni premuto per spostare i pulsanti e selezionare quali azioni vengono visualizzate quando si selezionano gli elementi",
"settingsSectionViewer": "Visualizzazione",
+ "settingsViewerGestureSideTapNext": "Tocca i bordi dello schermo per visualizzare l'elemento precedente/successivo",
"settingsViewerUseCutout": "Usa area di ritaglio",
"settingsViewerMaximumBrightness": "Luminosità massima",
"settingsMotionPhotoAutoPlay": "Riproduzione automatica delle foto in movimento",
diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb
index 70da86577..ea180e6f1 100644
--- a/lib/l10n/app_ja.arb
+++ b/lib/l10n/app_ja.arb
@@ -238,6 +238,8 @@
"renameEntryDialogLabel": "新しい名前",
+ "editEntryDialogTargetFieldsHeader": "更新するフィールド",
+
"editEntryDateDialogTitle": "日時",
"editEntryDateDialogSetCustom": "日を設定する",
"editEntryDateDialogCopyField": "他の日からコピーする",
@@ -245,7 +247,6 @@
"editEntryDateDialogExtractFromTitle": "タイトルから抽出する",
"editEntryDateDialogShift": "シフト",
"editEntryDateDialogSourceFileModifiedDate": "ファイル更新日",
- "editEntryDateDialogTargetFieldsHeader": "更新するフィールド",
"editEntryDateDialogHours": "時",
"editEntryDateDialogMinutes": "分",
diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb
index 81d606add..1d555672f 100644
--- a/lib/l10n/app_ko.arb
+++ b/lib/l10n/app_ko.arb
@@ -87,6 +87,7 @@
"entryInfoActionEditDate": "날짜 및 시간 수정",
"entryInfoActionEditLocation": "위치 수정",
+ "entryInfoActionEditDescription": "설명 수정",
"entryInfoActionEditRating": "별점 수정",
"entryInfoActionEditTags": "태그 수정",
"entryInfoActionRemoveMetadata": "메타데이터 삭제",
@@ -96,6 +97,7 @@
"filterLocationEmptyLabel": "장소 없음",
"filterTagEmptyLabel": "태그 없음",
"filterOnThisDayLabel": "이 날",
+ "filterRecentlyAddedLabel": "최근 추가된",
"filterRatingUnratedLabel": "별점 없음",
"filterRatingRejectedLabel": "거부됨",
"filterTypeAnimatedLabel": "애니메이션",
@@ -238,6 +240,8 @@
"renameEntryDialogLabel": "이름",
+ "editEntryDialogTargetFieldsHeader": "수정할 필드",
+
"editEntryDateDialogTitle": "날짜 및 시간",
"editEntryDateDialogSetCustom": "지정 날짜로 편집",
"editEntryDateDialogCopyField": "다른 날짜에서 지정",
@@ -245,7 +249,6 @@
"editEntryDateDialogExtractFromTitle": "제목에서 추출",
"editEntryDateDialogShift": "시간 이동",
"editEntryDateDialogSourceFileModifiedDate": "파일 수정한 날짜",
- "editEntryDateDialogTargetFieldsHeader": "수정할 필드",
"editEntryDateDialogHours": "시간",
"editEntryDateDialogMinutes": "분",
@@ -256,6 +259,8 @@
"locationPickerUseThisLocationButton": "이 위치 사용",
+ "editEntryDescriptionDialogTitle": "설명",
+
"editEntryRatingDialogTitle": "별점",
"removeEntryMetadataDialogTitle": "메타데이터 삭제",
@@ -450,6 +455,7 @@
"settingsConfirmationDialogDeleteItems": "항목을 완전히 삭제 시",
"settingsConfirmationDialogMoveToBinItems": "항목을 휴지통으로 이동 시",
"settingsConfirmationDialogMoveUndatedItems": "날짜가 지정되지 않은 항목을 이동 시",
+ "settingsConfirmationAfterMoveToBinItems": "항목을 휴지통으로 이동 후",
"settingsNavigationDrawerTile": "탐색 메뉴",
"settingsNavigationDrawerEditorTitle": "탐색 메뉴",
@@ -478,6 +484,7 @@
"settingsCollectionSelectionQuickActionEditorBanner": "버튼을 길게 누른 후 이동하여 항목 선택할 때 표시될 버튼을 선택하세요.",
"settingsSectionViewer": "뷰어",
+ "settingsViewerGestureSideTapNext": "화면 측면에서 탭해서 이전/다음 항목 보기",
"settingsViewerUseCutout": "컷아웃 영역 사용",
"settingsViewerMaximumBrightness": "최대 밝기",
"settingsMotionPhotoAutoPlay": "모션 사진 자동 재생",
diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb
new file mode 100644
index 000000000..6274340e6
--- /dev/null
+++ b/lib/l10n/app_nl.arb
@@ -0,0 +1,665 @@
+{
+ "appName": "Aves",
+ "welcomeMessage": "Welkom bij Aves",
+ "welcomeOptional": "Optioneel",
+ "welcomeTermsToggle": "Ik ga akkoord met de voorwaarden",
+ "itemCount": "{count, plural, =1{1 item} other{{count} items}}",
+
+ "timeSeconds": "{seconds, plural, =1{1 seconde} other{{seconds} seconden}}",
+ "timeMinutes": "{minutes, plural, =1{1 minuut} other{{minutes} minuten}}",
+ "timeDays": "{days, plural, =1{1 dag} other{{days} dagen}}",
+ "focalLength": "{length} mm",
+
+ "applyButtonLabel": "TOEPASSEN",
+ "deleteButtonLabel": "VERWIJDEREN",
+ "nextButtonLabel": "VOLGENDE",
+ "showButtonLabel": "TONEN",
+ "hideButtonLabel": "VERBERGEN",
+ "continueButtonLabel": "VERDER",
+
+ "cancelTooltip": "Annuleren",
+ "changeTooltip": "Aanpassen",
+ "clearTooltip": "Leegmaken",
+ "previousTooltip": "Vorige",
+ "nextTooltip": "Volgende",
+ "showTooltip": "Tonen",
+ "hideTooltip": "Verbergen",
+ "actionRemove": "Verwijderen",
+ "resetTooltip": "Resetten",
+ "saveTooltip": "Opslaan",
+
+ "doubleBackExitMessage": "Tap nogmaals “Terug” om te sluiten.",
+ "doNotAskAgain": "Niet opnieuw vragen",
+
+ "sourceStateLoading": "Laden",
+ "sourceStateCataloguing": "Catalogiseren",
+ "sourceStateLocatingCountries": "Landen lokaliseren",
+ "sourceStateLocatingPlaces": "Plaatsen lokaliseren",
+
+ "chipActionDelete": "Verwijderen",
+ "chipActionGoToAlbumPage": "Tonen Albums",
+ "chipActionGoToCountryPage": "Tonen in Landen",
+ "chipActionGoToTagPage": "Tonen in Labels",
+ "chipActionHide": "Verbergen",
+ "chipActionPin": "Bovenaan pinnen",
+ "chipActionUnpin": "Unpinnen",
+ "chipActionRename": "Hernoemen",
+ "chipActionSetCover": "Album achtergrond instellen",
+ "chipActionCreateAlbum": "Album aanmaken",
+
+ "entryActionCopyToClipboard": "Kopiëren naar Clipboard",
+ "entryActionDelete": "Verwijderen",
+ "entryActionConvert": "Converteren",
+ "entryActionExport": "Exporteren",
+ "entryActionInfo": "Info",
+ "entryActionRename": "Hernoemen",
+ "entryActionRestore": "Herstellen",
+ "entryActionRotateCCW": "Roteren tegen de klok in",
+ "entryActionRotateCW": "Roteren met de klok mee",
+ "entryActionFlip": "Horizontaal omdraaien",
+ "entryActionPrint": "Printen",
+ "entryActionShare": "Delen",
+ "entryActionViewSource": "Bron bekijken",
+ "entryActionShowGeoTiffOnMap": "Tonen als map overlay",
+ "entryActionConvertMotionPhotoToStillImage": "Converteren naar stilstaand beeld",
+ "entryActionViewMotionPhotoVideo": "Video openen",
+ "entryActionEdit": "Bewerken",
+ "entryActionOpen": "Openen als",
+ "entryActionSetAs": "Instellen als",
+ "entryActionOpenMap": "Tonen in map app",
+ "entryActionRotateScreen": "Scherm roteren",
+ "entryActionAddFavourite": "Toevoegen aan favorieten",
+ "entryActionRemoveFavourite": "Verwijderen uit favorieten",
+
+ "videoActionCaptureFrame": "Frame opnemen",
+ "videoActionMute": "Dempen",
+ "videoActionUnmute": "Dempen opheffen",
+ "videoActionPause": "Pauzeren",
+ "videoActionPlay": "Afspelen",
+ "videoActionReplay10": "10 seconden terug",
+ "videoActionSkip10": "10 seconden vooruit",
+ "videoActionSelectStreams": "Tracks selecteren",
+ "videoActionSetSpeed": "Afspeelsnelheid",
+ "videoActionSettings": "Instellingen",
+
+ "slideshowActionResume": "Hervatten",
+ "slideshowActionShowInCollection": "Tonen in Collectie",
+
+ "entryInfoActionEditDate": "Bewerk Datum & Tijd",
+ "entryInfoActionEditLocation": "Bewerk Locatie",
+ "entryInfoActionEditDescription": "Omschrijving wijzigen",
+ "entryInfoActionEditRating": "Bewerk waardering",
+ "entryInfoActionEditTags": "Bewerk labels",
+ "entryInfoActionRemoveMetadata": "Verwijder metadata",
+
+ "filterBinLabel": "Prullenbak",
+ "filterFavouriteLabel": "Favorieten",
+ "filterLocationEmptyLabel": "Geen locatie",
+ "filterTagEmptyLabel": "Geen label",
+ "filterOnThisDayLabel": "Op deze dag",
+ "filterRecentlyAddedLabel": "Recent toegevoegd",
+ "filterRatingUnratedLabel": "Geen rating",
+ "filterRatingRejectedLabel": "Afgekeurd",
+ "filterTypeAnimatedLabel": "Geanimeerd",
+ "filterTypeMotionPhotoLabel": "Bewegende Foto",
+ "filterTypePanoramaLabel": "Panorama",
+ "filterTypeRawLabel": "Raw",
+ "filterTypeSphericalVideoLabel": "360° Video",
+ "filterTypeGeotiffLabel": "GeoTIFF",
+ "filterMimeImageLabel": "Afbeelding",
+ "filterMimeVideoLabel": "Video",
+
+ "coordinateFormatDms": "DMS",
+ "coordinateFormatDecimal": "Decimale graden",
+ "coordinateDms": "{coordinate} {direction}",
+ "coordinateDmsNorth": "N",
+ "coordinateDmsSouth": "S",
+ "coordinateDmsEast": "E",
+ "coordinateDmsWest": "W",
+
+ "unitSystemMetric": "Metrisch",
+ "unitSystemImperial": "Imperiaal",
+
+ "videoLoopModeNever": "Nooit",
+ "videoLoopModeShortOnly": "Enkel korte videos",
+ "videoLoopModeAlways": "Altijd",
+
+ "videoControlsPlay": "Afspelen",
+ "videoControlsPlaySeek": "Speel & zoek terug/vooruit",
+ "videoControlsPlayOutside": "Openen met andere speler",
+ "videoControlsNone": "Geen",
+
+ "mapStyleGoogleNormal": "Google Maps",
+ "mapStyleGoogleHybrid": "Google Maps (Hybride)",
+ "mapStyleGoogleTerrain": "Google Maps (Terrein)",
+ "mapStyleHuaweiNormal": "Petal Maps",
+ "mapStyleHuaweiTerrain": "Petal Maps (Terrein)",
+ "mapStyleOsmHot": "Humanitarian OSM",
+ "mapStyleStamenToner": "Stamen Toner",
+ "mapStyleStamenWatercolor": "Stamen Waterkleur",
+
+ "nameConflictStrategyRename": "Hernoemen",
+ "nameConflictStrategyReplace": "Vervangen",
+ "nameConflictStrategySkip": "Overslaan",
+
+ "keepScreenOnNever": "Nooit",
+ "keepScreenOnViewerOnly": "Enkel Viewer pagina",
+ "keepScreenOnAlways": "Altijd",
+
+ "accessibilityAnimationsRemove": "Scherm effecten uitschakelen",
+ "accessibilityAnimationsKeep": "Scherm effecten houden",
+
+ "displayRefreshRatePreferHighest": "Hoogste waardering",
+ "displayRefreshRatePreferLowest": "Laagste waardering",
+
+ "slideshowVideoPlaybackSkip": "Overslaan",
+ "slideshowVideoPlaybackMuted": "Gedempte afspelen",
+ "slideshowVideoPlaybackWithSound": "Met geluid afspelen",
+
+ "themeBrightnessLight": "Licht",
+ "themeBrightnessDark": "Donker",
+ "themeBrightnessBlack": "Zwart",
+
+ "viewerTransitionSlide": "Slide",
+ "viewerTransitionParallax": "Parallax",
+ "viewerTransitionFade": "Vervagen",
+ "viewerTransitionZoomIn": "Inzoomen",
+
+ "wallpaperTargetHome": "Home scherm",
+ "wallpaperTargetLock": "Vergrendel scherm",
+ "wallpaperTargetHomeLock": "Home and Vergrendel schermen",
+
+ "albumTierNew": "Nieuw",
+ "albumTierPinned": "Gepint",
+ "albumTierSpecial": "Veelgebruikt",
+ "albumTierApps": "Apps",
+ "albumTierRegular": "Overige",
+
+ "storageVolumeDescriptionFallbackPrimary": "Internale opslag",
+ "storageVolumeDescriptionFallbackNonPrimary": "SD kaart",
+ "rootDirectoryDescription": "root map",
+ "otherDirectoryDescription": "“{name}” map",
+ "storageAccessDialogTitle": "Toegang tot opslag",
+ "storageAccessDialogMessage": "Selecteer de {directory} van “{volume}”, in het volgende scherm om deze app er toegang toe te geven.",
+ "restrictedAccessDialogTitle": "Beperkte toegang",
+ "restrictedAccessDialogMessage": "Deze applicatie mag geen bestanden wijzigen in de {directory} van “{volume}”,.\n\n Gebruik een vooraf geïnstalleerde filemanager of galerij-app om de items naar een andere map te verplaatsen.",
+ "notEnoughSpaceDialogTitle": "Te weinig vrije opslagruimte",
+ "notEnoughSpaceDialogMessage": "Deze bewerking heeft {neededSize} vrije ruimte op “{volume}”, nodig om te voltooien, maar er is nog slechts {freeSize} over.",
+ "missingSystemFilePickerDialogTitle": "Ontbrekende systeembestandkiezer",
+ "missingSystemFilePickerDialogMessage": "De systeembestandskiezer ontbreekt of is uitgeschakeld. Schakel het in en probeer het opnieuw.",
+
+ "unsupportedTypeDialogTitle": "Niet-ondersteunde Bestandstypen",
+ "unsupportedTypeDialogMessage": "{count, plural, other{Deze bewerking wordt niet ondersteund voor items van het volgende bestandstype: {types}.}}",
+
+ "nameConflictDialogSingleSourceMessage": "Sommige bestanden in de doelmap hebben dezelfde naam.",
+ "nameConflictDialogMultipleSourceMessage": "Sommige bestanden hebben dezelfde naam.",
+
+ "addShortcutDialogLabel": "Label snelkoppeling",
+ "addShortcutButtonLabel": "TOEVOEGEN",
+
+ "noMatchingAppDialogTitle": "Geen overeenkomende applicatie",
+ "noMatchingAppDialogMessage": "Er zijn geen apps die dit ondersteunen.",
+
+ "binEntriesConfirmationDialogMessage": "{count, plural, =1{Dit item naar de prullenbak verplaatsen??} other{Verplaats deze {count} items naar de prullenbak?}}",
+ "deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Verwijder dit item?} other{Verwijder deze {count} items?}}",
+ "moveUndatedConfirmationDialogMessage": "Datums opslaan voordat u doorgaat??",
+ "moveUndatedConfirmationDialogSetDate": "Datums opslaan",
+
+ "videoResumeDialogMessage": "Wil je het afspelen hervatten op {time}?",
+ "videoStartOverButtonLabel": "OPNIEUW BEGINNEN",
+ "videoResumeButtonLabel": "HERVAT",
+
+ "setCoverDialogLatest": "Laatste item",
+ "setCoverDialogAuto": "Auto",
+ "setCoverDialogCustom": "Aangepast",
+
+ "hideFilterConfirmationDialogMessage": "Overeenkomende foto’s en video’s worden verborgen binnen uw verzameling. Je kunt ze opnieuw weergeven via de “Privacy”-instellingen.\n\nWeet je zeker dat je ze wilt verbergen",
+
+ "newAlbumDialogTitle": "Nieuw Album",
+ "newAlbumDialogNameLabel": "Albumnaam",
+ "newAlbumDialogNameLabelAlreadyExistsHelper": "Map bestaat al",
+ "newAlbumDialogStorageLabel": "Opslag:",
+
+ "renameAlbumDialogLabel": "Nieuwe naam",
+ "renameAlbumDialogLabelAlreadyExistsHelper": "Map bestaat al",
+
+ "renameEntrySetPageTitle": "Hernoemen",
+ "renameEntrySetPagePatternFieldLabel": "Naamgevingspatroon",
+ "renameEntrySetPageInsertTooltip": "Veld invoegen",
+ "renameEntrySetPagePreview": "Voorbeeld",
+
+ "renameProcessorCounter": "Teller",
+ "renameProcessorName": "Naam",
+
+ "deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Verwijder dit album en het item binnen dit album?} other{Verwijder dit album en de {count} items binnen dit album?}}",
+ "deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Verwijder deze albums en het item binnen deze albums?} other{Verwijder dit album en de {count} items binnen deze albums?}}",
+
+ "exportEntryDialogFormat": "Formaat:",
+ "exportEntryDialogWidth": "Breedte",
+ "exportEntryDialogHeight": "Hoogte",
+
+ "renameEntryDialogLabel": "Nieuwe naam",
+
+ "editEntryDialogTargetFieldsHeader": "Velden om aan te passen",
+
+ "editEntryDateDialogTitle": "Datum & Tijd",
+ "editEntryDateDialogSetCustom": "Stel een custom datum in",
+ "editEntryDateDialogCopyField": "Kopiëren van andere datum",
+ "editEntryDateDialogCopyItem": "Kopiëren van ander item",
+ "editEntryDateDialogExtractFromTitle": "Uit titel halen",
+ "editEntryDateDialogShift": "Verschuiven",
+ "editEntryDateDialogSourceFileModifiedDate": "Wijzigingsdatum bestand",
+ "editEntryDateDialogHours": "Uren",
+ "editEntryDateDialogMinutes": "Minuten",
+
+ "editEntryLocationDialogTitle": "Locatie",
+ "editEntryLocationDialogChooseOnMapTooltip": "Kies op kaart",
+ "editEntryLocationDialogLatitude": "Breedtegraad",
+ "editEntryLocationDialogLongitude": "Lengtegraad",
+
+ "locationPickerUseThisLocationButton": "Gebruik deze locatie",
+
+ "editEntryDescriptionDialogTitle": "Omschrijving",
+
+ "editEntryRatingDialogTitle": "Beoordeling",
+
+ "removeEntryMetadataDialogTitle": "Verwijderen metadata",
+ "removeEntryMetadataDialogMore": "Meer",
+
+ "removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP is vereist om de video in een bewegende foto af te spelen.\n\nWeet je zeker dat je deze wilt verwijderen?",
+ "convertMotionPhotoToStillImageWarningDialogMessage": "Weet je het zeker?",
+
+ "videoSpeedDialogLabel": "Afspeelsnelheid",
+
+ "videoStreamSelectionDialogVideo": "Video",
+ "videoStreamSelectionDialogAudio": "Audio",
+ "videoStreamSelectionDialogText": "Ondertiteling",
+ "videoStreamSelectionDialogOff": "Uit",
+ "videoStreamSelectionDialogTrack": "Nummer",
+ "videoStreamSelectionDialogNoSelection": "Er zijn geen andere nummers.",
+
+ "genericSuccessFeedback": "Klaar!",
+ "genericFailureFeedback": "Fout",
+
+ "menuActionConfigureView": "Beeld",
+ "menuActionSelect": "Selecteer",
+ "menuActionSelectAll": "Selecteer alles",
+ "menuActionSelectNone": "Selectie ongedaan maken",
+ "menuActionMap": "Kaart",
+ "menuActionSlideshow": "Diavoorstelling",
+ "menuActionStats": "Statistieken",
+
+ "viewDialogTabSort": "Sorteer",
+ "viewDialogTabGroup": "Groeperen",
+ "viewDialogTabLayout": "Layout",
+
+ "tileLayoutGrid": "Raster",
+ "tileLayoutList": "Lijst",
+
+ "coverDialogTabCover": "Kaft",
+ "coverDialogTabApp": "Applicatie",
+ "coverDialogTabColor": "Kleur",
+
+ "appPickDialogTitle": "Kies applicatie",
+ "appPickDialogNone": "Geen",
+
+ "aboutPageTitle": "Over",
+ "aboutLinkSources": "Bronnen",
+ "aboutLinkLicense": "Licentie",
+ "aboutLinkPolicy": "Privacy Policy",
+
+ "aboutBug": "Bug Reporteren",
+ "aboutBugSaveLogInstruction": "Sla applicatielogs op in een bestand",
+ "aboutBugCopyInfoInstruction": "Kopieer systeem informatie",
+ "aboutBugCopyInfoButton": "Kopieer",
+ "aboutBugReportInstruction": "Reporteer op GitHub met de logs en systeeminformatie",
+ "aboutBugReportButton": "Reporteer",
+
+ "aboutCredits": "Credits",
+ "aboutCreditsWorldAtlas1": "Deze applicatie gebruikt een TopoJSON-bestand van",
+ "aboutCreditsWorldAtlas2": "Gebruik makend van de ISC License.",
+ "aboutCreditsTranslators": "Vdertalers",
+
+ "aboutLicenses": "Open-Source Licenties",
+ "aboutLicensesBanner": "Deze app maakt gebruik van de volgende open-sourcepakketten en bibliotheken.",
+ "aboutLicensesAndroidLibraries": "Android bibliotheken",
+ "aboutLicensesFlutterPlugins": "Flutter Plugins",
+ "aboutLicensesFlutterPackages": "Flutter Packages",
+ "aboutLicensesDartPackages": "Dart Packages",
+ "aboutLicensesShowAllButtonLabel": "Laat alle licenties zien",
+
+ "policyPageTitle": "Privacy Policy",
+
+ "collectionPageTitle": "Verzameling",
+ "collectionPickPageTitle": "Kies",
+ "collectionSelectPageTitle": "Selecteer items",
+
+ "collectionActionShowTitleSearch": "Laat titel filter zien",
+ "collectionActionHideTitleSearch": "Verberg titel filter",
+ "collectionActionAddShortcut": "Snelkoppeling aanmaken",
+ "collectionActionEmptyBin": "Prullenbak leegmaken",
+ "collectionActionCopy": "Kopieer naar Album",
+ "collectionActionMove": "Verplaats naar Album",
+ "collectionActionRescan": "Opnieuw indexeren",
+ "collectionActionEdit": "Wijzigen",
+
+ "collectionSearchTitlesHintText": "Zoek op titel",
+
+ "collectionSortDate": "Op datum",
+ "collectionSortSize": "Op grootte",
+ "collectionSortName": "Op album- en bestandsnaam",
+ "collectionSortRating": "Op rating",
+
+ "collectionGroupAlbum": "Op Albumnaam",
+ "collectionGroupMonth": "Op maand",
+ "collectionGroupDay": "Op dag",
+ "collectionGroupNone": "Niet groeperen",
+
+ "sectionUnknown": "Onbekend",
+ "dateToday": "Vandaag",
+ "dateYesterday": "Gisteren",
+ "dateThisMonth": "Deze maand",
+ "collectionDeleteFailureFeedback": "{count, plural, =1{Kan 1 item niet verwijderen} other{Kan {count} items niet verwijderen}}",
+ "collectionCopyFailureFeedback": "{count, plural, =1{Kan 1 item niet kopiëren} other{Kan {count} items niet kopiëren}}",
+ "collectionMoveFailureFeedback": "{count, plural, =1{Kan 1 item niet verplaatsen} other{Kan {count} items niet verplaatsen}}",
+ "collectionRenameFailureFeedback": "{count, plural, =1{Kan 1 item niet hernoemen} other{Kan {count} items niet hernoemen}}",
+ "collectionEditFailureFeedback": "{count, plural, =1{Kan 1 item niet wijzigen} other{Kan {count} items niet wijzigen}}",
+ "collectionExportFailureFeedback": "{count, plural, =1{Kan 1 pagina niet exporteren} other{Kan {count} pagina’s niet exporteren}}",
+ "collectionCopySuccessFeedback": "{count, plural, =1{1 item gekopieerd} other{{count} items gekopieerd}}",
+ "collectionMoveSuccessFeedback": "{count, plural, =1{1 item verplaatst} other{{count} items verplaatst}}",
+ "collectionRenameSuccessFeedback": "{count, plural, =1{1 item hernoemd} other{{count} items hernoemd}}",
+ "collectionEditSuccessFeedback": "{count, plural, =1{1 item gewijzigd} other{{count} items gewijzigd}}",
+
+ "collectionEmptyFavourites": "Geen favourieten",
+ "collectionEmptyVideos": "Geen video’s",
+ "collectionEmptyImages": "Geen afbeeldingen",
+ "collectionEmptyGrantAccessButtonLabel": "Toegang verlenen",
+
+ "collectionSelectSectionTooltip": "Selecteer sectie",
+ "collectionDeselectSectionTooltip": "Deselecteer sectie",
+
+ "drawerCollectionAll": "Alle verzamelingen",
+ "drawerCollectionFavourites": "Favourieten",
+ "drawerCollectionImages": "Afbeeldingen",
+ "drawerCollectionVideos": "Video’s",
+ "drawerCollectionAnimated": "Animaties",
+ "drawerCollectionMotionPhotos": "Bewegende foto’s",
+ "drawerCollectionPanoramas": "Panoramas",
+ "drawerCollectionRaws": "Raw foto’s",
+ "drawerCollectionSphericalVideos": "360° video’s",
+
+ "chipSortDate": "Op datum",
+ "chipSortName": "Op naam",
+ "chipSortCount": "Op aantal items",
+
+ "albumGroupTier": "Op rang",
+ "albumGroupVolume": "Op opslagvolume",
+ "albumGroupNone": "Niet groeperen",
+
+ "albumPickPageTitleCopy": "Kopieer naar Album",
+ "albumPickPageTitleExport": "Exporteer naar Album",
+ "albumPickPageTitleMove": "Verplaats naar Album",
+ "albumPickPageTitlePick": "Kies Album",
+
+ "albumCamera": "Camera",
+ "albumDownload": "Opslaan",
+ "albumScreenshots": "Schermafbeeldingen",
+ "albumScreenRecordings": "Schermopnames",
+ "albumVideoCaptures": "Video opnames",
+
+ "albumPageTitle": "Albums",
+ "albumEmpty": "Geen albums",
+ "createAlbumTooltip": "Album aanmaken",
+ "createAlbumButtonLabel": "AANMAKEN",
+ "newFilterBanner": "nieuw",
+
+ "countryPageTitle": "Landen",
+ "countryEmpty": "Geen landen",
+
+ "tagPageTitle": "Labels",
+ "tagEmpty": "Geen labels",
+
+ "binPageTitle": "Prullenbak",
+
+ "searchCollectionFieldHint": "Doorzoek collectie",
+ "searchSectionRecent": "Recent",
+ "searchSectionDate": "Datum",
+ "searchSectionAlbums": "Albums",
+ "searchSectionCountries": "Landen",
+ "searchSectionPlaces": "Plaatsen",
+ "searchSectionTags": "Labels",
+ "searchSectionRating": "Beoordeling",
+
+ "settingsPageTitle": "Instellingen",
+ "settingsSystemDefault": "Systeem",
+ "settingsDefault": "Standaard",
+
+ "settingsSearchFieldLabel": "Instellingen doorzoeken",
+ "settingsSearchEmpty": "Geen instellingen gevonden",
+ "settingsActionExport": "Exporteer",
+ "settingsActionImport": "Importeer",
+
+ "appExportCovers": "Omslagen",
+ "appExportFavourites": "Favorieten",
+ "appExportSettings": "Instellingen",
+
+ "settingsSectionNavigation": "Navigatie",
+ "settingsHome": "Startscherm",
+ "settingsShowBottomNavigationBar": "Laat onderste navigatiebalk zien",
+ "settingsKeepScreenOnTile": "Houd het scherm aan",
+ "settingsKeepScreenOnTitle": "Houd het scherm aan",
+ "settingsDoubleBackExit": "Tik twee keer op “terug” om af te sluiten",
+
+ "settingsConfirmationDialogTile": "Bevestigingsscherm",
+ "settingsConfirmationDialogTitle": "Bevestigingsschermen",
+ "settingsConfirmationDialogDeleteItems": "Bevestig voordat je items voor altijd verwijdert",
+ "settingsConfirmationDialogMoveToBinItems": "Bevestig voordat u items naar de prullenbak verplaatst",
+ "settingsConfirmationDialogMoveUndatedItems": "Bevestigvoordat u ongedateerde items verplaatst",
+ "settingsConfirmationAfterMoveToBinItems": "Toon bevestigingsbericht na het verplaatsen van items naar de prullenbak",
+
+ "settingsNavigationDrawerTile": "Navigatiemenu",
+ "settingsNavigationDrawerEditorTitle": "Navigatiemenu",
+ "settingsNavigationDrawerBanner": "Houd ingedrukt om menu-items te verplaatsen en opnieuw te ordenen.",
+ "settingsNavigationDrawerTabTypes": "Typen",
+ "settingsNavigationDrawerTabAlbums": "Albums",
+ "settingsNavigationDrawerTabPages": "Pagina’s",
+ "settingsNavigationDrawerAddAlbum": "Album toevoegen",
+
+ "settingsSectionThumbnails": "Miniaturen",
+ "settingsThumbnailOverlayTile": "Overlay",
+ "settingsThumbnailOverlayTitle": "Overlay",
+ "settingsThumbnailShowFavouriteIcon": "Favorieten icoon zichtbaar",
+ "settingsThumbnailShowTagIcon": "Label icoon zichtbaar",
+ "settingsThumbnailShowLocationIcon": "Locatie icoon zichtbaar",
+ "settingsThumbnailShowMotionPhotoIcon": "Bewegende foto icoon zichtbaar",
+ "settingsThumbnailShowRating": "Rating zichtbaar",
+ "settingsThumbnailShowRawIcon": "RAW icoon zichtbaar",
+ "settingsThumbnailShowVideoDuration": "Videoduur zichtbaar",
+
+ "settingsCollectionQuickActionsTile": "Snelle bewerkingen",
+ "settingsCollectionQuickActionEditorTitle": "Snelle bewerkingen",
+ "settingsCollectionQuickActionTabBrowsing": "Blader",
+ "settingsCollectionQuickActionTabSelecting": "Selecteren",
+ "settingsCollectionBrowsingQuickActionEditorBanner": "Houd ingedrukt om knoppen te verplaatsen en te selecteren welke acties worden weergegeven bij het bladeren door items.",
+ "settingsCollectionSelectionQuickActionEditorBanner": "Houd ingedrukt om knoppen te verplaatsen en te selecteren welke acties worden weergegeven bij het selecteren van items.",
+
+ "settingsSectionViewer": "Voorbeeld",
+ "settingsViewerGestureSideTapNext": "Druk op het scherm om het vorige/volgende item weer te geven",
+ "settingsViewerUseCutout": "Uitgesneden gebied gebruiken",
+ "settingsViewerMaximumBrightness": "Maximale helderheid",
+ "settingsMotionPhotoAutoPlay": "Bewegingsfoto’s automatisch afspelen",
+ "settingsImageBackground": "Afbeeldingsachtergrond",
+
+ "settingsViewerQuickActionsTile": "Snelle bewerkingen",
+ "settingsViewerQuickActionEditorTitle": "Snelle bewerkingen",
+ "settingsViewerQuickActionEditorBanner": "Houd ingedrukt om knoppen te verplaatsen en te selecteren welke acties in de viewer worden weergegeven.",
+ "settingsViewerQuickActionEditorDisplayedButtons": "Zichtbare knoppen",
+ "settingsViewerQuickActionEditorAvailableButtons": "Beschikbare knoppen",
+ "settingsViewerQuickActionEmpty": "Geen knoppen",
+
+ "settingsViewerOverlayTile": "Overlay",
+ "settingsViewerOverlayTitle": "Overlay",
+ "settingsViewerShowOverlayOnOpening": "Zichtbaar bij openen",
+ "settingsViewerShowMinimap": "Laat kleine kaart zien",
+ "settingsViewerShowInformation": "Laat informatie zien",
+ "settingsViewerShowInformationSubtitle": "Laat titel, datum, locatie, etc zien.",
+ "settingsViewerShowShootingDetails": "Laat opnamedetails zien",
+ "settingsViewerShowOverlayThumbnails": "Laat miniaturen zien",
+ "settingsViewerEnableOverlayBlurEffect": "Vervagingseffect",
+
+ "settingsViewerSlideshowTile": "Diavoorstelling",
+ "settingsViewerSlideshowTitle": "Diavoorstelling",
+ "settingsSlideshowRepeat": "Herhalen",
+ "settingsSlideshowShuffle": "Shuffle",
+ "settingsSlideshowFillScreen": "Volledig scherm",
+ "settingsSlideshowTransitionTile": "Overgang",
+ "settingsSlideshowTransitionTitle": "Overgang",
+ "settingsSlideshowIntervalTile": "Interval",
+ "settingsSlideshowIntervalTitle": "Interval",
+ "settingsSlideshowVideoPlaybackTile": "Video afspelen",
+ "settingsSlideshowVideoPlaybackTitle": "Video afspelen",
+
+ "settingsVideoPageTitle": "Video Instellingen",
+ "settingsSectionVideo": "Video",
+ "settingsVideoShowVideos": "Videos",
+ "settingsVideoEnableHardwareAcceleration": "Hardware acceleratie",
+ "settingsVideoEnableAutoPlay": "Automatisch afspelen",
+ "settingsVideoLoopModeTile": "Herhaald afspelen",
+ "settingsVideoLoopModeTitle": "Herhaald afspelen",
+
+ "settingsSubtitleThemeTile": "Ondertiteling",
+ "settingsSubtitleThemeTitle": "Ondertiteling",
+ "settingsSubtitleThemeSample": "Dit is een voorbeeld",
+ "settingsSubtitleThemeTextAlignmentTile": "Tekst uitlijnen",
+ "settingsSubtitleThemeTextAlignmentTitle": "Tekst uitlijnen",
+ "settingsSubtitleThemeTextSize": "Tekstgroote",
+ "settingsSubtitleThemeShowOutline": "Laat omtrek en schaduw zien",
+ "settingsSubtitleThemeTextColor": "Tekstkleur",
+ "settingsSubtitleThemeTextOpacity": "Tekstdoorzichtigheid",
+ "settingsSubtitleThemeBackgroundColor": "Achtergrondkleur",
+ "settingsSubtitleThemeBackgroundOpacity": "Achtergronddoorzichtigheid",
+ "settingsSubtitleThemeTextAlignmentLeft": "Links",
+ "settingsSubtitleThemeTextAlignmentCenter": "Midden",
+ "settingsSubtitleThemeTextAlignmentRight": "Rechts",
+
+ "settingsVideoControlsTile": "Bediening",
+ "settingsVideoControlsTitle": "Bediening",
+ "settingsVideoButtonsTile": "Knoppen",
+ "settingsVideoButtonsTitle": "Knoppen",
+ "settingsVideoGestureDoubleTapTogglePlay": "Dubbeltik om te spelen/pauzeren",
+ "settingsVideoGestureSideDoubleTapSeek": "Dubbeltik op schermranden om achteruit/vooruit te zoeken",
+
+ "settingsSectionPrivacy": "Privacy",
+ "settingsAllowInstalledAppAccess": "Toegang tot app-inventaris toestaan",
+ "settingsAllowInstalledAppAccessSubtitle": "Gebruikt om de albumweergave te verbeteren",
+ "settingsAllowErrorReporting": "Anonieme foutrapportage toestaan",
+ "settingsSaveSearchHistory": "Bewaar zoekgeschiedenis",
+ "settingsEnableBin": "Prullenbak gebruiken",
+ "settingsEnableBinSubtitle": "Bewaar verwijderde items 30 dagen",
+
+ "settingsHiddenItemsTile": "Verborgen items",
+ "settingsHiddenItemsTitle": "Verborgen Items",
+
+ "settingsHiddenFiltersTitle": "Verborgen Filters",
+ "settingsHiddenFiltersBanner": "Foto’s en video’s die overeenkomen met verborgen filters, worden niet weergegeven in uw verzameling.",
+ "settingsHiddenFiltersEmpty": "Geen verborgen filters",
+
+ "settingsHiddenPathsTitle": "Verborgen paden",
+ "settingsHiddenPathsBanner": "Foto’s en video’s in deze mappen, of een van hun submappen, verschijnen niet in uw verzameling.",
+ "addPathTooltip": "Pad toevoegen",
+
+ "settingsStorageAccessTile": "Toegang tot opslag",
+ "settingsStorageAccessTitle": "Toegang tot opslag",
+ "settingsStorageAccessBanner": "Sommige mappen vereisen een expliciete toegangstoekenning om bestanden erin te wijzigen. U kunt hier directory’s bekijken waartoe u eerder toegang heeft verleend.",
+ "settingsStorageAccessEmpty": "Geen toegang verleend",
+ "settingsStorageAccessRevokeTooltip": "Herroepen",
+
+ "settingsSectionAccessibility": "Toegankelijkheid",
+ "settingsRemoveAnimationsTile": "Animaties verwijderen",
+ "settingsRemoveAnimationsTitle": "Animaties verwijderen",
+ "settingsTimeToTakeActionTile": "Tijd om actie te ondernemen",
+ "settingsTimeToTakeActionTitle": "Tijd om actie te ondernemen",
+
+ "settingsSectionDisplay": "Scherm",
+ "settingsThemeBrightness": "Thema",
+ "settingsThemeColorHighlights": "Kleur highlights",
+ "settingsThemeEnableDynamicColor": "Dynamische kleur",
+ "settingsDisplayRefreshRateModeTile": "Vernieuwingsfrequentie weergeven",
+ "settingsDisplayRefreshRateModeTitle": "Vernieuwingsfrequentie",
+
+ "settingsSectionLanguage": "Taal & landinstellingen",
+ "settingsLanguage": "Taal",
+ "settingsCoordinateFormatTile": "Coördineer formaat",
+ "settingsCoordinateFormatTitle": "Coördineer formaat",
+ "settingsUnitSystemTile": "Eenheden",
+ "settingsUnitSystemTitle": "Eenheden",
+
+ "settingsScreenSaverPageTitle": "Schermbeveiliging",
+
+ "settingsWidgetPageTitle": "Foto Lijstje",
+ "settingsWidgetShowOutline": "Contour",
+
+ "statsPageTitle": "Stats",
+ "statsWithGps": "{count, plural, =1{1 item met locatie} other{{count} items met locatie}}",
+ "statsTopCountries": "Top Landen",
+ "statsTopPlaces": "Top Plaatsen",
+ "statsTopTags": "Top Labels",
+
+ "viewerOpenPanoramaButtonLabel": "OPEN PANORAMA",
+ "viewerSetWallpaperButtonLabel": "ALS ACHTERGROND INSTELLEN",
+ "viewerErrorUnknown": "Oei!",
+ "viewerErrorDoesNotExist": "Het bestand bestaat niet meer.",
+
+ "viewerInfoPageTitle": "Info",
+ "viewerInfoBackToViewerTooltip": "Terug naar viewer",
+
+ "viewerInfoUnknown": "onbekendd",
+ "viewerInfoLabelTitle": "Titel",
+ "viewerInfoLabelDate": "Datum",
+ "viewerInfoLabelResolution": "Resolutie",
+ "viewerInfoLabelSize": "Grootte",
+ "viewerInfoLabelUri": "URI",
+ "viewerInfoLabelPath": "Pad",
+ "viewerInfoLabelDuration": "Duur",
+ "viewerInfoLabelOwner": "Eigenaar",
+ "viewerInfoLabelCoordinates": "Coördinaten",
+ "viewerInfoLabelAddress": "Adres",
+
+ "mapStyleTitle": "Kaartstijl",
+ "mapStyleTooltip": "Selecteer kaart stijl",
+ "mapZoomInTooltip": "Inzoomen",
+ "mapZoomOutTooltip": "Uitzoomen",
+ "mapPointNorthUpTooltip": "Noorden boven",
+ "mapAttributionOsmHot": "Kaartgegevens © [OpenStreetMap](https://www.openstreetmap.org/copyright) bijdragers • Tegels door [HOT](https://www.hotosm.org/) • Gehost door [OSM France](https://openstreetmap.fr/)",
+ "mapAttributionStamen": "Kaartgegevens © [OpenStreetMap](https://www.openstreetmap.org/copyright) bijdragers • Tegels door [Stamen Design](http://stamen.com), [CC BY 3.0](http://creativecommons.org/licenses/by/3.0)",
+ "openMapPageTooltip": "Bekijk op kaartpagina",
+ "mapEmptyRegion": "Geen afbeeldingen in de geselecteerde regio",
+
+ "viewerInfoOpenEmbeddedFailureFeedback": "Kan ingesloten gegevens niet extraheren",
+ "viewerInfoOpenLinkText": "Open",
+ "viewerInfoViewXmlLinkText": "Bekijk XML",
+
+ "viewerInfoSearchFieldLabel": "Doorzoek metadata",
+ "viewerInfoSearchEmpty": "Geen overeenkomstige zoeksleutels",
+ "viewerInfoSearchSuggestionDate": "Datum & tijd",
+ "viewerInfoSearchSuggestionDescription": "Beschrijving",
+ "viewerInfoSearchSuggestionDimensions": "Afmetingen",
+ "viewerInfoSearchSuggestionResolution": "Resolutie",
+ "viewerInfoSearchSuggestionRights": "Rechten",
+
+ "tagEditorPageTitle": "Wijzig Labels",
+ "tagEditorPageNewTagFieldLabel": "Nieuw label",
+ "tagEditorPageAddTagTooltip": "Label toevoegen",
+ "tagEditorSectionRecent": "Recent",
+
+ "panoramaEnableSensorControl": "Sensor control inschakelen",
+ "panoramaDisableSensorControl": "Sensor control uitschakelen",
+
+ "sourceViewerPageTitle": "Source",
+
+ "filePickerShowHiddenFiles": "Verborgen bestanden laten zien",
+ "filePickerDoNotShowHiddenFiles": "Verborgen bestanden niet laten zien",
+ "filePickerOpenFrom": "Openen met",
+ "filePickerNoItems": "Geen items",
+ "filePickerUseThisFolder": "Deze map gebruiken"
+}
diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb
index fc09762ba..9ce02959a 100644
--- a/lib/l10n/app_pt.arb
+++ b/lib/l10n/app_pt.arb
@@ -238,6 +238,8 @@
"renameEntryDialogLabel": "Novo nome",
+ "editEntryDialogTargetFieldsHeader": "Campos para modificar",
+
"editEntryDateDialogTitle": "Data e hora",
"editEntryDateDialogSetCustom": "Definir data personalizada",
"editEntryDateDialogCopyField": "Copiar de outra data",
@@ -245,7 +247,6 @@
"editEntryDateDialogExtractFromTitle": "Extrair do título",
"editEntryDateDialogShift": "Mudança",
"editEntryDateDialogSourceFileModifiedDate": "Data de modificação do arquivo",
- "editEntryDateDialogTargetFieldsHeader": "Campos para modificar",
"editEntryDateDialogHours": "Horas",
"editEntryDateDialogMinutes": "Minutos",
diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb
index 3a3a14338..6c552fe37 100644
--- a/lib/l10n/app_ru.arb
+++ b/lib/l10n/app_ru.arb
@@ -237,6 +237,8 @@
"renameEntryDialogLabel": "Новое название",
+ "editEntryDialogTargetFieldsHeader": "Поля для изменения",
+
"editEntryDateDialogTitle": "Дата и время",
"editEntryDateDialogSetCustom": "Установить дату",
"editEntryDateDialogCopyField": "Копировать с другой даты",
@@ -244,7 +246,6 @@
"editEntryDateDialogExtractFromTitle": "Извлечь из названия",
"editEntryDateDialogShift": "Сдвиг",
"editEntryDateDialogSourceFileModifiedDate": "Дата изменения файла",
- "editEntryDateDialogTargetFieldsHeader": "Поля для изменения",
"editEntryDateDialogHours": "Часов",
"editEntryDateDialogMinutes": "Минут",
@@ -585,6 +586,8 @@
"settingsUnitSystemTile": "Единицы измерения",
"settingsUnitSystemTitle": "Единицы измерения",
+ "settingsWidgetPageTitle": "Фоторамка",
+
"statsPageTitle": "Статистика",
"statsWithGps": "{count, plural, =1{1 объект с местоположением} few{{count} объекта с местоположением} other{{count} объектов с местоположением}}",
"statsTopCountries": "Топ стран",
diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb
index fdd7fb364..4da8322a6 100644
--- a/lib/l10n/app_tr.arb
+++ b/lib/l10n/app_tr.arb
@@ -230,6 +230,8 @@
"renameEntryDialogLabel": "Yeni ad",
+ "editEntryDialogTargetFieldsHeader": "Değiştirilecek alanlar",
+
"editEntryDateDialogTitle": "Tarih ve Saat",
"editEntryDateDialogSetCustom": "Özel tarih ayarla",
"editEntryDateDialogCopyField": "Başka bir tarihten kopyala",
@@ -237,7 +239,6 @@
"editEntryDateDialogExtractFromTitle": "Başlıktan ayıkla",
"editEntryDateDialogShift": "Değişim",
"editEntryDateDialogSourceFileModifiedDate": "Dosya değiştirilme tarihi",
- "editEntryDateDialogTargetFieldsHeader": "Değiştirilecek alanlar",
"editEntryDateDialogHours": "Saat",
"editEntryDateDialogMinutes": "Dakika",
@@ -575,6 +576,8 @@
"settingsUnitSystemTile": "Birimler",
"settingsUnitSystemTitle": "Birimler",
+ "settingsWidgetPageTitle": "Fotoğraf Çerçevesi",
+
"statsPageTitle": "İstatistikler",
"statsWithGps": "{count, plural, =1{1 konuma sahip öğe} other{{count} konuma sahip öğe}}",
"statsTopCountries": "Başlıca Ülkeler",
diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb
index 9a6f0ea2a..2e6ebfd63 100644
--- a/lib/l10n/app_zh.arb
+++ b/lib/l10n/app_zh.arb
@@ -87,6 +87,7 @@
"entryInfoActionEditDate": "编辑日期和时间",
"entryInfoActionEditLocation": "编辑位置",
+ "entryInfoActionEditDescription": "编辑备注",
"entryInfoActionEditRating": "修改评分",
"entryInfoActionEditTags": "编辑标签",
"entryInfoActionRemoveMetadata": "移除元数据",
@@ -96,6 +97,7 @@
"filterLocationEmptyLabel": "未定位",
"filterTagEmptyLabel": "无标签",
"filterOnThisDayLabel": "选择日期",
+ "filterRecentlyAddedLabel": "最近添加",
"filterRatingUnratedLabel": "未评分",
"filterRatingRejectedLabel": "拒绝",
"filterTypeAnimatedLabel": "动画",
@@ -238,6 +240,8 @@
"renameEntryDialogLabel": "新名称",
+ "editEntryDialogTargetFieldsHeader": "待修改的字段",
+
"editEntryDateDialogTitle": "日期和时间",
"editEntryDateDialogSetCustom": "设置自定义日期",
"editEntryDateDialogCopyField": "复制自其他日期",
@@ -245,7 +249,6 @@
"editEntryDateDialogExtractFromTitle": "从标题提取",
"editEntryDateDialogShift": "转移",
"editEntryDateDialogSourceFileModifiedDate": "文件修改日期",
- "editEntryDateDialogTargetFieldsHeader": "待修改的字段",
"editEntryDateDialogHours": "时",
"editEntryDateDialogMinutes": "分",
@@ -256,6 +259,8 @@
"locationPickerUseThisLocationButton": "使用此位置",
+ "editEntryDescriptionDialogTitle": "备注",
+
"editEntryRatingDialogTitle": "评分",
"removeEntryMetadataDialogTitle": "元数据移除工具",
@@ -450,6 +455,7 @@
"settingsConfirmationDialogDeleteItems": "永久删除项目之前询问",
"settingsConfirmationDialogMoveToBinItems": "移至回收站之前询问",
"settingsConfirmationDialogMoveUndatedItems": "移动未注明日期的项目之前询问",
+ "settingsConfirmationAfterMoveToBinItems": "移至回收站后显示消息",
"settingsNavigationDrawerTile": "导航栏菜单",
"settingsNavigationDrawerEditorTitle": "导航栏菜单",
@@ -478,6 +484,7 @@
"settingsCollectionSelectionQuickActionEditorBanner": "按住并拖拽可移动按钮并选择选择项目时显示的操作",
"settingsSectionViewer": "查看器",
+ "settingsViewerGestureSideTapNext": "轻触屏幕边缘显示上/下一个项目",
"settingsViewerUseCutout": "使用剪切区域",
"settingsViewerMaximumBrightness": "最大亮度",
"settingsMotionPhotoAutoPlay": "自动播放动态照片",
diff --git a/lib/model/actions/chip_set_actions.dart b/lib/model/actions/chip_set_actions.dart
index 43fb11ecf..a1e919801 100644
--- a/lib/model/actions/chip_set_actions.dart
+++ b/lib/model/actions/chip_set_actions.dart
@@ -10,6 +10,7 @@ enum ChipSetAction {
selectNone,
// browsing
search,
+ toggleTitleSearch,
createAlbum,
// browsing or selecting
map,
@@ -35,6 +36,7 @@ class ChipSetActions {
static const browsing = [
ChipSetAction.search,
+ ChipSetAction.toggleTitleSearch,
ChipSetAction.createAlbum,
ChipSetAction.map,
ChipSetAction.slideshow,
@@ -69,6 +71,9 @@ extension ExtraChipSetAction on ChipSetAction {
// browsing
case ChipSetAction.search:
return MaterialLocalizations.of(context).searchFieldLabel;
+ case ChipSetAction.toggleTitleSearch:
+ // different data depending on toggle state
+ return context.l10n.collectionActionShowTitleSearch;
case ChipSetAction.createAlbum:
return context.l10n.chipActionCreateAlbum;
// browsing or selecting
@@ -111,6 +116,9 @@ extension ExtraChipSetAction on ChipSetAction {
// browsing
case ChipSetAction.search:
return AIcons.search;
+ case ChipSetAction.toggleTitleSearch:
+ // different data depending on toggle state
+ return AIcons.filter;
case ChipSetAction.createAlbum:
return AIcons.add;
// browsing or selecting
diff --git a/lib/model/actions/entry_info_actions.dart b/lib/model/actions/entry_info_actions.dart
index bba0cf0de..eca523345 100644
--- a/lib/model/actions/entry_info_actions.dart
+++ b/lib/model/actions/entry_info_actions.dart
@@ -7,6 +7,7 @@ enum EntryInfoAction {
// general
editDate,
editLocation,
+ editDescription,
editRating,
editTags,
removeMetadata,
@@ -23,6 +24,7 @@ class EntryInfoActions {
static const common = [
EntryInfoAction.editDate,
EntryInfoAction.editLocation,
+ EntryInfoAction.editDescription,
EntryInfoAction.editRating,
EntryInfoAction.editTags,
EntryInfoAction.removeMetadata,
@@ -43,6 +45,8 @@ extension ExtraEntryInfoAction on EntryInfoAction {
return context.l10n.entryInfoActionEditDate;
case EntryInfoAction.editLocation:
return context.l10n.entryInfoActionEditLocation;
+ case EntryInfoAction.editDescription:
+ return context.l10n.entryInfoActionEditDescription;
case EntryInfoAction.editRating:
return context.l10n.entryInfoActionEditRating;
case EntryInfoAction.editTags:
@@ -84,6 +88,8 @@ extension ExtraEntryInfoAction on EntryInfoAction {
return AIcons.date;
case EntryInfoAction.editLocation:
return AIcons.location;
+ case EntryInfoAction.editDescription:
+ return AIcons.description;
case EntryInfoAction.editRating:
return AIcons.editRating;
case EntryInfoAction.editTags:
diff --git a/lib/model/actions/entry_set_actions.dart b/lib/model/actions/entry_set_actions.dart
index 997f819ec..f6f25d39d 100644
--- a/lib/model/actions/entry_set_actions.dart
+++ b/lib/model/actions/entry_set_actions.dart
@@ -31,6 +31,7 @@ enum EntrySetAction {
flip,
editDate,
editLocation,
+ editDescription,
editRating,
editTags,
removeMetadata,
@@ -99,6 +100,7 @@ class EntrySetActions {
static const edit = [
EntrySetAction.editDate,
EntrySetAction.editLocation,
+ EntrySetAction.editDescription,
EntrySetAction.editRating,
EntrySetAction.editTags,
EntrySetAction.removeMetadata,
@@ -162,6 +164,8 @@ extension ExtraEntrySetAction on EntrySetAction {
return context.l10n.entryInfoActionEditDate;
case EntrySetAction.editLocation:
return context.l10n.entryInfoActionEditLocation;
+ case EntrySetAction.editDescription:
+ return context.l10n.entryInfoActionEditDescription;
case EntrySetAction.editRating:
return context.l10n.entryInfoActionEditRating;
case EntrySetAction.editTags:
@@ -229,6 +233,8 @@ extension ExtraEntrySetAction on EntrySetAction {
return AIcons.date;
case EntrySetAction.editLocation:
return AIcons.location;
+ case EntrySetAction.editDescription:
+ return AIcons.description;
case EntrySetAction.editRating:
return AIcons.editRating;
case EntrySetAction.editTags:
diff --git a/lib/model/db/db_metadata.dart b/lib/model/db/db_metadata.dart
index cc23707bf..7f5d70ea4 100644
--- a/lib/model/db/db_metadata.dart
+++ b/lib/model/db/db_metadata.dart
@@ -10,6 +10,8 @@ import 'package:aves/model/video_playback.dart';
abstract class MetadataDb {
int get nextId;
+ int get timestampSecs;
+
Future init();
Future dbFileSize();
diff --git a/lib/model/db/db_metadata_sqflite.dart b/lib/model/db/db_metadata_sqflite.dart
index 1acb8b5b1..8ad1aa3fe 100644
--- a/lib/model/db/db_metadata_sqflite.dart
+++ b/lib/model/db/db_metadata_sqflite.dart
@@ -34,6 +34,9 @@ class SqfliteMetadataDb implements MetadataDb {
@override
int get nextId => ++_lastId;
+ @override
+ int get timestampSecs => DateTime.now().millisecondsSinceEpoch ~/ 1000;
+
@override
Future init() async {
_db = await openDatabase(
@@ -50,6 +53,7 @@ class SqfliteMetadataDb implements MetadataDb {
', sourceRotationDegrees INTEGER'
', sizeBytes INTEGER'
', title TEXT'
+ ', dateAddedSecs INTEGER DEFAULT (strftime(\'%s\',\'now\'))'
', dateModifiedSecs INTEGER'
', sourceDateTakenMillis INTEGER'
', durationMillis INTEGER'
@@ -66,7 +70,7 @@ class SqfliteMetadataDb implements MetadataDb {
', flags INTEGER'
', rotationDegrees INTEGER'
', xmpSubjects TEXT'
- ', xmpTitleDescription TEXT'
+ ', xmpTitle TEXT'
', latitude REAL'
', longitude REAL'
', rating INTEGER'
@@ -99,7 +103,7 @@ class SqfliteMetadataDb implements MetadataDb {
')');
},
onUpgrade: MetadataDbUpgrader.upgradeDb,
- version: 8,
+ version: 9,
);
final maxIdRows = await _db.rawQuery('SELECT max(id) AS maxId FROM $entryTable');
diff --git a/lib/model/db/db_metadata_sqflite_upgrade.dart b/lib/model/db/db_metadata_sqflite_upgrade.dart
index 1ffe9af32..489c6f5c7 100644
--- a/lib/model/db/db_metadata_sqflite_upgrade.dart
+++ b/lib/model/db/db_metadata_sqflite_upgrade.dart
@@ -38,6 +38,9 @@ class MetadataDbUpgrader {
case 7:
await _upgradeFrom7(db);
break;
+ case 8:
+ await _upgradeFrom8(db);
+ break;
}
oldVersion++;
}
@@ -278,4 +281,57 @@ class MetadataDbUpgrader {
await db.execute('ALTER TABLE $coverTable ADD COLUMN packageName TEXT;');
await db.execute('ALTER TABLE $coverTable ADD COLUMN color INTEGER;');
}
+
+ static Future _upgradeFrom8(Database db) async {
+ debugPrint('upgrading DB from v8');
+
+ // new column `dateAddedSecs`
+ await db.transaction((txn) async {
+ const newEntryTable = '${entryTable}TEMP';
+ await db.execute('CREATE TABLE $newEntryTable('
+ 'id INTEGER PRIMARY KEY'
+ ', contentId INTEGER'
+ ', uri TEXT'
+ ', path TEXT'
+ ', sourceMimeType TEXT'
+ ', width INTEGER'
+ ', height INTEGER'
+ ', sourceRotationDegrees INTEGER'
+ ', sizeBytes INTEGER'
+ ', title TEXT'
+ ', dateAddedSecs INTEGER DEFAULT (strftime(\'%s\',\'now\'))'
+ ', dateModifiedSecs INTEGER'
+ ', sourceDateTakenMillis INTEGER'
+ ', durationMillis INTEGER'
+ ', trashed INTEGER DEFAULT 0'
+ ')');
+ await db.rawInsert('INSERT INTO $newEntryTable(id,contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis,trashed)'
+ ' SELECT id,contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis,trashed'
+ ' FROM $entryTable;');
+ await db.execute('DROP TABLE $entryTable;');
+ await db.execute('ALTER TABLE $newEntryTable RENAME TO $entryTable;');
+ });
+
+ // rename column `xmpTitleDescription` to `xmpTitle`
+ await db.transaction((txn) async {
+ const newMetadataTable = '${metadataTable}TEMP';
+ await db.execute('CREATE TABLE $newMetadataTable('
+ 'id INTEGER PRIMARY KEY'
+ ', mimeType TEXT'
+ ', dateMillis INTEGER'
+ ', flags INTEGER'
+ ', rotationDegrees INTEGER'
+ ', xmpSubjects TEXT'
+ ', xmpTitle TEXT'
+ ', latitude REAL'
+ ', longitude REAL'
+ ', rating INTEGER'
+ ')');
+ await db.rawInsert('INSERT INTO $newMetadataTable(id,mimeType,dateMillis,flags,rotationDegrees,xmpSubjects,xmpTitle,latitude,longitude,rating)'
+ ' SELECT id,mimeType,dateMillis,flags,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude,rating'
+ ' FROM $metadataTable;');
+ await db.execute('DROP TABLE $metadataTable;');
+ await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;');
+ });
+ }
}
diff --git a/lib/model/entry.dart b/lib/model/entry.dart
index 38781677d..606ac3a09 100644
--- a/lib/model/entry.dart
+++ b/lib/model/entry.dart
@@ -37,7 +37,7 @@ class AvesEntry {
int? pageId, contentId;
final String sourceMimeType;
int width, height, sourceRotationDegrees;
- int? sizeBytes, _dateModifiedSecs, sourceDateTakenMillis, _durationMillis;
+ int? sizeBytes, dateAddedSecs, _dateModifiedSecs, sourceDateTakenMillis, _durationMillis;
bool trashed;
int? _catalogDateMillis;
@@ -61,6 +61,7 @@ class AvesEntry {
required this.sourceRotationDegrees,
required this.sizeBytes,
required String? sourceTitle,
+ required this.dateAddedSecs,
required int? dateModifiedSecs,
required this.sourceDateTakenMillis,
required int? durationMillis,
@@ -83,6 +84,7 @@ class AvesEntry {
String? path,
int? contentId,
String? title,
+ int? dateAddedSecs,
int? dateModifiedSecs,
List? burstEntries,
}) {
@@ -99,6 +101,7 @@ class AvesEntry {
sourceRotationDegrees: sourceRotationDegrees,
sizeBytes: sizeBytes,
sourceTitle: title ?? sourceTitle,
+ dateAddedSecs: dateAddedSecs ?? this.dateAddedSecs,
dateModifiedSecs: dateModifiedSecs ?? this.dateModifiedSecs,
sourceDateTakenMillis: sourceDateTakenMillis,
durationMillis: durationMillis,
@@ -126,6 +129,7 @@ class AvesEntry {
sourceRotationDegrees: map['sourceRotationDegrees'] as int? ?? 0,
sizeBytes: map['sizeBytes'] as int?,
sourceTitle: map['title'] as String?,
+ dateAddedSecs: map['dateAddedSecs'] as int?,
dateModifiedSecs: map['dateModifiedSecs'] as int?,
sourceDateTakenMillis: map['sourceDateTakenMillis'] as int?,
durationMillis: map['durationMillis'] as int?,
@@ -153,6 +157,23 @@ class AvesEntry {
};
}
+ Map toPlatformEntryMap() {
+ return {
+ 'uri': uri,
+ 'path': path,
+ 'pageId': pageId,
+ 'mimeType': mimeType,
+ 'width': width,
+ 'height': height,
+ 'rotationDegrees': rotationDegrees,
+ 'isFlipped': isFlipped,
+ 'dateModifiedSecs': dateModifiedSecs,
+ 'sizeBytes': sizeBytes,
+ 'trashed': trashed,
+ 'trashPath': trashDetails?.path,
+ };
+ }
+
void dispose() {
imageChangeNotifier.dispose();
metadataChangeNotifier.dispose();
@@ -218,6 +239,7 @@ class AvesEntry {
MimeTypes.heic,
MimeTypes.heif,
MimeTypes.jpeg,
+ MimeTypes.png,
MimeTypes.webp,
MimeTypes.arw,
MimeTypes.cr2,
@@ -257,6 +279,8 @@ class AvesEntry {
bool get canEditLocation => canEdit && canEditExif;
+ bool get canEditDescription => canEdit && (canEditExif || canEditXmp);
+
bool get canEditRating => canEdit && canEditXmp;
bool get canEditTags => canEdit && canEditXmp;
@@ -461,7 +485,7 @@ class AvesEntry {
String? _bestTitle;
String? get bestTitle {
- _bestTitle ??= _catalogMetadata?.xmpTitleDescription?.isNotEmpty == true ? _catalogMetadata!.xmpTitleDescription : (filenameWithoutExtension ?? sourceTitle);
+ _bestTitle ??= _catalogMetadata?.xmpTitle?.isNotEmpty == true ? _catalogMetadata!.xmpTitle : (filenameWithoutExtension ?? sourceTitle);
return _bestTitle;
}
@@ -751,7 +775,10 @@ class AvesEntry {
bool get isBurst => burstEntries?.isNotEmpty == true;
- bool get isMotionPhoto => isMultiPage && !isBurst && mimeType == MimeTypes.jpeg;
+ // for backwards compatibility
+ bool get _isMotionPhotoLegacy => isMultiPage && !isBurst && mimeType == MimeTypes.jpeg;
+
+ bool get isMotionPhoto => (_catalogMetadata?.isMotionPhoto ?? false) || _isMotionPhotoLegacy;
String? get burstKey {
if (filenameWithoutExtension != null) {
diff --git a/lib/model/entry_metadata_edition.dart b/lib/model/entry_metadata_edition.dart
index a1f8bf829..f6fdc4cc1 100644
--- a/lib/model/entry_metadata_edition.dart
+++ b/lib/model/entry_metadata_edition.dart
@@ -140,6 +140,54 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
return _changeOrientation(() => metadataEditService.flip(this));
}
+ // write:
+ // - Exif / ImageDescription
+ // - IPTC / caption-abstract, if IPTC exists
+ // - XMP / dc:description
+ Future> editDescription(String? description) async {
+ final Set dataTypes = {};
+ final Map metadata = {};
+
+ final missingDate = await _missingDateCheckAndExifEdit(dataTypes);
+
+ if (canEditExif) {
+ metadata[MetadataType.exif] = {MetadataField.exifImageDescription.exifInterfaceTag!: description};
+ }
+
+ if (canEditIptc) {
+ final iptc = await metadataFetchService.getIptc(this);
+ if (iptc != null) {
+ editIptcValues(iptc, IPTC.applicationRecord, IPTC.captionAbstractTag, {if (description != null) description});
+ metadata[MetadataType.iptc] = iptc;
+ }
+ }
+
+ if (canEditXmp) {
+ metadata[MetadataType.xmp] = await _editXmp((descriptions) {
+ final modified = XMP.setAttribute(
+ descriptions,
+ XMP.dcDescription,
+ description,
+ namespace: Namespaces.dc,
+ strat: XmpEditStrategy.always,
+ );
+ if (modified && missingDate != null) {
+ editCreateDateXmp(descriptions, missingDate);
+ }
+ return modified;
+ });
+ }
+
+ final newFields = await metadataEditService.editMetadata(this, metadata);
+ if (newFields.isNotEmpty) {
+ dataTypes.addAll({
+ EntryDataType.basic,
+ });
+ }
+
+ return dataTypes;
+ }
+
// write:
// - IPTC / keywords, if IPTC exists
// - XMP / dc:subject
@@ -152,7 +200,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
if (canEditIptc) {
final iptc = await metadataFetchService.getIptc(this);
if (iptc != null) {
- editTagsIptc(iptc, tags);
+ editIptcValues(iptc, IPTC.applicationRecord, IPTC.keywordsTag, tags);
metadata[MetadataType.iptc] = iptc;
}
}
@@ -245,9 +293,18 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
};
}
+ static void editIptcValues(List