Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2022-11-11 10:43:52 +01:00
commit f14e83ed8e
193 changed files with 15199 additions and 4262 deletions

View file

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

View file

@ -19,7 +19,7 @@ jobs:
# Available versions may lag behind https://github.com/flutter/flutter.git
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.3.4'
flutter-version: '3.3.8'
channel: 'stable'
# 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.4.sksl.json
flutter build appbundle -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_3.3.8.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.4.sksl.json
flutter build apk -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_3.3.8.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.4.sksl.json
flutter build apk -t lib/main_huawei.dart --flavor huawei --bundle-sksl-path shaders_3.3.8.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.4.sksl.json
flutter build apk -t lib/main_izzy.dart --flavor izzy --split-per-abi --bundle-sksl-path shaders_3.3.8.sksl.json
cp build/app/outputs/apk/izzy/release/*.apk outputs
rm $AVES_STORE_FILE
env:

View file

@ -4,6 +4,30 @@ All notable changes to this project will be documented in this file.
## <a id="unreleased"></a>[Unreleased]
## <a id="v1.7.2"></a>[v1.7.2] - 2022-11-11
### Added
- Info: edit MP4 metadata (date / location / title / description / rating / tags / rotation)
- Info: edit location by copying from other item
- Info: edit tags with dynamic placeholders for country / place
- Widget: option to open collection on tap
- optional MANAGE_MEDIA permission to modify media without asking
### Changed
- higher quality thumbnails
- upgraded Flutter to stable v3.3.8
### Fixed
- rendering of panoramas with inconsistent metadata
- failing scan of items copied to SD card on older devices
- unreplaceable covers set before v1.7.1
- inconsistent background height for multi-script subtitles
- launch crash on Android KitKat
- ExifInterface: producing invalid WebP files
## <a id="v1.7.1"></a>[v1.7.1] - 2022-10-09
### Added

View file

@ -89,11 +89,16 @@ Aves requires a few permissions to do its job:
### Code
At this stage this project does *not* accept PRs, except for translations.
At this stage this project does *not* accept PRs.
### 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 & Dutch are handled by generous volunteers.
Translations are powered by [Weblate](https://hosted.weblate.org/engage/aves/) and the effort of wonderfully generous volunteers.
<a href="https://hosted.weblate.org/engage/aves/">
<img src="https://hosted.weblate.org/widgets/aves/-/multi-auto.svg" alt="Translation status" />
</a>
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).
### Donations

View file

@ -148,23 +148,44 @@ flutter {
}
repositories {
maven { url 'https://jitpack.io' }
maven { url 'https://s3.amazonaws.com/repo.commonsware.com' }
maven {
url 'https://jitpack.io'
content {
includeGroup "com.github.deckerst"
includeGroup "com.github.deckerst.mp4parser"
}
}
maven {
url 'https://s3.amazonaws.com/repo.commonsware.com'
content {
excludeGroupByRegex "com\\.github\\.deckerst.*"
}
}
}
dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.exifinterface:exifinterface:1.3.4'
implementation 'androidx.exifinterface:exifinterface:1.3.5'
implementation 'androidx.lifecycle:lifecycle-process:2.5.1'
implementation 'androidx.multidex:multidex:2.0.1'
implementation 'com.caverock:androidsvg-aar:1.4'
implementation 'com.commonsware.cwac:document:0.5.0'
implementation 'com.drewnoakes:metadata-extractor:2.18.0'
// forked, built by JitPack, cf https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a'
// forked, built by JitPack, cf https://jitpack.io/p/deckerst/pixymeta-android
implementation 'com.github.deckerst:pixymeta-android:706bd73d6e'
implementation 'com.github.bumptech.glide:glide:4.14.2'
// SLF4J implementation for `mp4parser`
implementation 'org.slf4j:slf4j-simple:2.0.3'
// forked, built by JitPack:
// - https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
// - https://jitpack.io/p/deckerst/mp4parser
// - https://jitpack.io/p/deckerst/pixymeta-android
implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a'
implementation 'com.github.deckerst.mp4parser:isoparser:64b571fdfb'
implementation 'com.github.deckerst.mp4parser:muxer:64b571fdfb'
implementation 'com.github.deckerst:pixymeta-android:706bd73d6e'
// huawei flavor only
huaweiImplementation 'com.huawei.agconnect:agconnect-core:1.7.2.300'

View file

@ -24,9 +24,14 @@ This change eventually prevents building the app with Flutter v3.3.3.
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29"
tools:ignore="ScopedStorage" />
<!-- from Android 12 (API 31), users can optionally grant access to the media management special permission -->
<uses-permission
android:name="android.permission.MANAGE_MEDIA"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.SET_WALLPAPER" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- to show foreground service progress via notification -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

View file

@ -9,6 +9,8 @@ import android.content.ServiceConnection
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import deckers.thibault.aves.AnalysisService
import deckers.thibault.aves.AnalysisServiceBinder
import deckers.thibault.aves.AnalysisServiceListener
@ -63,6 +65,11 @@ class AnalysisHandler(private val activity: Activity, private val onAnalysisComp
.putExtra(AnalysisService.KEY_ENTRY_IDS, entryIds?.toIntArray())
.putExtra(AnalysisService.KEY_FORCE, force)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val appState = ProcessLifecycleOwner.get().lifecycle.currentState
if (!appState.isAtLeast(Lifecycle.State.STARTED)) {
result.error("startAnalysis-background", "cannot start foreground service from background", null)
return
}
activity.startForegroundService(intent)
} else {
activity.startService(intent)

View file

@ -19,10 +19,10 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.MainActivity
import deckers.thibault.aves.MainActivity.Companion.EXTRA_STRING_ARRAY_SEPARATOR
import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_FILTERS_ARRAY
import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_FILTERS_STRING
import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_PAGE
import deckers.thibault.aves.MainActivity.Companion.EXTRA_STRING_ARRAY_SEPARATOR
import deckers.thibault.aves.R
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
@ -50,7 +50,6 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
when (call.method) {
"getPackages" -> ioScope.launch { safe(call, result, ::getPackages) }
"getAppIcon" -> ioScope.launch { safeSuspend(call, result, ::getAppIcon) }
"getAppInstaller" -> ioScope.launch { safe(call, result, ::getAppInstaller) }
"copyToClipboard" -> ioScope.launch { safe(call, result, ::copyToClipboard) }
"edit" -> safe(call, result, ::edit)
"open" -> safe(call, result, ::open)
@ -160,7 +159,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
.build()
val options = RequestOptions()
.format(DecodeFormat.PREFER_RGB_565)
.format(DecodeFormat.PREFER_ARGB_8888)
.override(size, size)
val target = Glide.with(context)
.asBitmap()
@ -187,23 +186,6 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
}
}
private fun getAppInstaller(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
val packageName = context.packageName
val pm = context.packageManager
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val info = pm.getInstallSourceInfo(packageName)
result.success(info.initiatingPackageName ?: info.installingPackageName)
} else {
@Suppress("deprecation")
result.success(pm.getInstallerPackageName(packageName))
}
} catch (e: Exception) {
result.error("getAppInstaller-exception", "failed to get installer for packageName=$packageName", e.message)
return
}
}
private fun copyToClipboard(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val label = call.argument<String>("label")

View file

@ -18,10 +18,12 @@ import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.metadata.ExifInterfaceHelper
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.metadata.Mp4ParserHelper.dumpBoxes
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
import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface
import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor
import deckers.thibault.aves.utils.MimeTypes.canReadWithPixyMeta
@ -38,7 +40,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import org.mp4parser.IsoFile
import java.io.IOException
import java.nio.channels.Channels
class DebugHandler(private val context: Context) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@ -60,6 +64,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
"getExifInterfaceMetadata" -> ioScope.launch { safe(call, result, ::getExifInterfaceMetadata) }
"getMediaMetadataRetrieverMetadata" -> ioScope.launch { safe(call, result, ::getMediaMetadataRetrieverMetadata) }
"getMetadataExtractorSummary" -> ioScope.launch { safe(call, result, ::getMetadataExtractorSummary) }
"getMp4ParserDump" -> ioScope.launch { safe(call, result, ::getMp4ParserDump) }
"getPixyMetadata" -> ioScope.launch { safe(call, result, ::getPixyMetadata) }
"getTiffStructure" -> ioScope.launch { safe(call, result, ::getTiffStructure) }
else -> result.notImplemented()
@ -319,6 +324,32 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
result.success(metadataMap)
}
private fun getMp4ParserDump(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (mimeType == null || uri == null) {
result.error("getMp4ParserDump-args", "missing arguments", null)
return
}
val sb = StringBuilder()
if (mimeType == MimeTypes.MP4) {
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
Channels.newChannel(input).use { channel ->
IsoFile(channel).use { isoFile ->
isoFile.dumpBoxes(sb)
}
}
}
} catch (e: Exception) {
result.error("getMp4ParserDump-exception", e.message, e.stackTraceToString())
return
}
}
result.success(sb.toString())
}
private fun getPixyMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }

View file

@ -3,7 +3,11 @@ package deckers.thibault.aves.channel.calls
import android.content.Context
import android.content.Intent
import android.content.res.Resources
import android.location.Geocoder
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.provider.Settings
import androidx.core.content.pm.ShortcutManagerCompat
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.model.FieldMap
@ -15,15 +19,21 @@ import java.util.*
class DeviceHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"canManageMedia" -> safe(call, result, ::canManageMedia)
"getCapabilities" -> safe(call, result, ::getCapabilities)
"getDefaultTimeZone" -> safe(call, result, ::getDefaultTimeZone)
"getLocales" -> safe(call, result, ::getLocales)
"getPerformanceClass" -> safe(call, result, ::getPerformanceClass)
"isSystemFilePickerEnabled" -> safe(call, result, ::isSystemFilePickerEnabled)
"requestMediaManagePermission" -> safe(call, result, ::requestMediaManagePermission)
else -> result.notImplemented()
}
}
private fun canManageMedia(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) MediaStore.canManageMedia(context) else false)
}
private fun getCapabilities(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
val sdkInt = Build.VERSION.SDK_INT
result.success(
@ -32,7 +42,9 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
"canPinShortcut" to ShortcutManagerCompat.isRequestPinShortcutSupported(context),
"canPrint" to (sdkInt >= Build.VERSION_CODES.KITKAT),
"canRenderFlagEmojis" to (sdkInt >= Build.VERSION_CODES.LOLLIPOP),
"canRequestManageMedia" to (sdkInt >= Build.VERSION_CODES.S),
"canSetLockScreenWallpaper" to (sdkInt >= Build.VERSION_CODES.N),
"hasGeocoder" to Geocoder.isPresent(),
"isDynamicColorAvailable" to (sdkInt >= Build.VERSION_CODES.S),
"showPinShortcutFeedback" to (sdkInt >= Build.VERSION_CODES.O),
"supportEdgeToEdgeUIMode" to (sdkInt >= Build.VERSION_CODES.Q),
@ -90,6 +102,17 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
result.success(enabled)
}
private fun requestMediaManagePermission(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
result.error("requestMediaManagePermission-unsupported", "media management permission is not available before Android 12", null)
return
}
val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA, Uri.parse("package:${context.packageName}"))
context.startActivity(intent)
result.success(true)
}
companion object {
const val CHANNEL = "deckers.thibault/aves/device"
}

View file

@ -42,8 +42,9 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
val heightDip = call.argument<Number>("heightDip")?.toDouble()
val pageId = call.argument<Int>("pageId")
val defaultSizeDip = call.argument<Number>("defaultSizeDip")?.toDouble()
val quality = call.argument<Int>("quality")
if (uri == null || mimeType == null || dateModifiedSecs == null || rotationDegrees == null || isFlipped == null || widthDip == null || heightDip == null || defaultSizeDip == null) {
if (uri == null || mimeType == null || dateModifiedSecs == null || rotationDegrees == null || isFlipped == null || widthDip == null || heightDip == null || defaultSizeDip == null || quality == null) {
result.error("getThumbnail-args", "missing arguments", null)
return
}
@ -60,6 +61,7 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
height = (heightDip * density).roundToInt(),
pageId = pageId,
defaultSize = (defaultSizeDip * density).roundToInt(),
quality = quality,
result = result,
).fetch()
}
@ -68,6 +70,7 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val mimeType = call.argument<String>("mimeType")
val pageId = call.argument<Int>("pageId")
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val sampleSize = call.argument<Int>("sampleSize")
val x = call.argument<Int>("regionX")
val y = call.argument<Int>("regionY")
@ -85,6 +88,7 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
when (mimeType) {
MimeTypes.SVG -> SvgRegionFetcher(context).fetch(
uri = uri,
sizeBytes = sizeBytes,
regionRect = regionRect,
imageWidth = imageWidth,
imageHeight = imageHeight,

View file

@ -68,7 +68,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
provider.editOrientation(contextWrapper, path, uri, mimeType, op, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("editOrientation-failure", "failed to change orientation for mimeType=$mimeType uri=$uri", throwable.message)
override fun onFailure(throwable: Throwable) = result.error("editOrientation-failure", "failed to change orientation for mimeType=$mimeType uri=$uri", throwable)
})
}
@ -98,7 +98,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
provider.editDate(contextWrapper, path, uri, mimeType, dateMillis, shiftMinutes, fields, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("editDate-failure", "failed to edit date for mimeType=$mimeType uri=$uri", throwable.message)
override fun onFailure(throwable: Throwable) = result.error("editDate-failure", "failed to edit date for mimeType=$mimeType uri=$uri", throwable)
})
}
@ -127,7 +127,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
provider.editMetadata(contextWrapper, path, uri, mimeType, metadata, autoCorrectTrailerOffset, callback = object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("editMetadata-failure", "failed to edit metadata for mimeType=$mimeType uri=$uri", throwable.message)
override fun onFailure(throwable: Throwable) = result.error("editMetadata-failure", "failed to edit metadata for mimeType=$mimeType uri=$uri", throwable)
})
}
@ -154,7 +154,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
provider.removeTrailerVideo(contextWrapper, path, uri, mimeType, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("removeTrailerVideo-failure", "failed to remove trailer video for mimeType=$mimeType uri=$uri", throwable.message)
override fun onFailure(throwable: Throwable) = result.error("removeTrailerVideo-failure", "failed to remove trailer video for mimeType=$mimeType uri=$uri", throwable)
})
}
@ -182,7 +182,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
provider.removeMetadataTypes(contextWrapper, path, uri, mimeType, types.toSet(), object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("removeTypes-failure", "failed to remove metadata for mimeType=$mimeType uri=$uri", throwable.message)
override fun onFailure(throwable: Throwable) = result.error("removeTypes-failure", "failed to remove metadata for mimeType=$mimeType uri=$uri", throwable)
})
}

View file

@ -125,7 +125,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
var foundExif = false
var foundXmp = false
fun processXmp(xmpMeta: XMPMeta, dirMap: MutableMap<String, String>) {
fun processXmp(xmpMeta: XMPMeta, dirMap: MutableMap<String, String>, allowMultiple: Boolean = false) {
if (foundXmp && !allowMultiple) return
foundXmp = true
try {
for (prop in xmpMeta) {
if (prop is XMPPropertyInfo) {
@ -148,14 +150,66 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
dirMap["schemaRegistryPrefixes"] = JSONObject(prefixes).toString()
}
val mp4UuidDirCount = HashMap<String, Int>()
fun processMp4Uuid(dir: Mp4UuidBoxDirectory) {
var thisDirName: String
when (val uuid = dir.getString(Mp4UuidBoxDirectory.TAG_UUID)) {
GSpherical.SPHERICAL_VIDEO_V1_UUID -> {
val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA)
thisDirName = "Spherical Video"
metadataMap[thisDirName] = HashMap(GSpherical(bytes).describe())
}
QuickTimeMetadata.PROF_UUID -> {
// redundant with info derived on the Dart side
}
QuickTimeMetadata.USMT_UUID -> {
val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA)
val blocks = QuickTimeMetadata.parseUuidUsmt(bytes)
if (blocks.isNotEmpty()) {
thisDirName = "QuickTime User Media"
val usmt = metadataMap[thisDirName] ?: HashMap()
metadataMap[thisDirName] = usmt
blocks.forEach {
var key = it.type
var value = it.value
val language = it.language
var i = 0
while (usmt.containsKey(key)) {
key = it.type + " (${++i})"
}
if (language != "und") {
value += " ($language)"
}
usmt[key] = value
}
}
}
else -> {
val uuidPart = uuid.substringBefore('-')
thisDirName = "${dir.name} $uuidPart"
val count = mp4UuidDirCount[uuidPart] ?: 0
mp4UuidDirCount[uuidPart] = count + 1
if (count > 0) {
thisDirName += " ($count)"
}
val dirMap = metadataMap[thisDirName] ?: HashMap()
metadataMap[thisDirName] = dirMap
dirMap.putAll(dir.tags.map { Pair(it.tagName, it.description) })
}
}
}
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = Helper.safeRead(input)
foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 }
foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
val uuidDirCount = HashMap<String, Int>()
val dirByName = metadata.directories.filter {
(it.tagCount > 0 || it.errorCount > 0)
&& it !is FileTypeDirectory
@ -177,157 +231,116 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
// directory name
var thisDirName = baseDirName
if (dir is Mp4UuidBoxDirectory) {
val uuid = dir.getString(Mp4UuidBoxDirectory.TAG_UUID).substringBefore('-')
thisDirName += " $uuid"
val count = uuidDirCount[uuid] ?: 0
uuidDirCount[uuid] = count + 1
if (count > 0) {
thisDirName += " ($count)"
}
} else if (sameNameDirCount > 1 && !allMetadataMergeableDirNames.contains(baseDirName)) {
if (sameNameDirCount > 1 && !allMetadataMergeableDirNames.contains(baseDirName)) {
// optional count for multiple directories of the same type
thisDirName = "$thisDirName[${dirIndex + 1}]"
}
// optional parent to distinguish child directories of the same type
dir.parent?.name?.let { thisDirName = "$it/$thisDirName" }
var dirMap = metadataMap[thisDirName] ?: HashMap()
metadataMap[thisDirName] = dirMap
if (dir !is Mp4UuidBoxDirectory) {
metadataMap[thisDirName] = dirMap
// tags
val tags = dir.tags
when {
dir is ExifDirectoryBase -> {
when {
dir.containsGeoTiffTags() -> {
// split GeoTIFF tags in their own directory
val geoTiffDirMap = metadataMap[DIR_EXIF_GEOTIFF] ?: HashMap()
metadataMap[DIR_EXIF_GEOTIFF] = geoTiffDirMap
val byGeoTiff = tags.groupBy { ExifTags.isGeoTiffTag(it.tagType) }
byGeoTiff[true]?.flatMap { tag ->
when (tag.tagType) {
ExifGeoTiffTags.TAG_GEO_KEY_DIRECTORY -> {
val geoTiffTags = (dir as ExifIFD0Directory).extractGeoKeys(dir.getIntArray(tag.tagType))
geoTiffTags.map { geoTag ->
val name = GeoTiffKeys.getTagName(geoTag.key) ?: "0x${geoTag.key.toString(16)}"
val value = geoTag.value
val description = if (value is DoubleArray) value.joinToString(" ") { doubleFormat.format(it) } else "$value"
Pair(name, description)
// tags
val tags = dir.tags
when {
dir is ExifDirectoryBase -> {
when {
dir.containsGeoTiffTags() -> {
// split GeoTIFF tags in their own directory
val geoTiffDirMap = metadataMap[DIR_EXIF_GEOTIFF] ?: HashMap()
metadataMap[DIR_EXIF_GEOTIFF] = geoTiffDirMap
val byGeoTiff = tags.groupBy { ExifTags.isGeoTiffTag(it.tagType) }
byGeoTiff[true]?.flatMap { tag ->
when (tag.tagType) {
ExifGeoTiffTags.TAG_GEO_KEY_DIRECTORY -> {
val geoTiffTags = (dir as ExifIFD0Directory).extractGeoKeys(dir.getIntArray(tag.tagType))
geoTiffTags.map { geoTag ->
val name = GeoTiffKeys.getTagName(geoTag.key) ?: "0x${geoTag.key.toString(16)}"
val value = geoTag.value
val description = if (value is DoubleArray) value.joinToString(" ") { doubleFormat.format(it) } else "$value"
Pair(name, description)
}
}
// skip `Geo double/ascii params`, as their content is split and presented through various GeoTIFF keys
ExifGeoTiffTags.TAG_GEO_DOUBLE_PARAMS,
ExifGeoTiffTags.TAG_GEO_ASCII_PARAMS -> ArrayList()
else -> listOf(exifTagMapper(tag))
}
// skip `Geo double/ascii params`, as their content is split and presented through various GeoTIFF keys
ExifGeoTiffTags.TAG_GEO_DOUBLE_PARAMS,
ExifGeoTiffTags.TAG_GEO_ASCII_PARAMS -> ArrayList()
else -> listOf(exifTagMapper(tag))
}
}?.let { geoTiffDirMap.putAll(it) }
byGeoTiff[false]?.map { exifTagMapper(it) }?.let { dirMap.putAll(it) }
}?.let { geoTiffDirMap.putAll(it) }
byGeoTiff[false]?.map { exifTagMapper(it) }?.let { dirMap.putAll(it) }
}
mimeType == MimeTypes.DNG -> {
// split DNG tags in their own directory
val dngDirMap = metadataMap[DIR_DNG] ?: HashMap()
metadataMap[DIR_DNG] = dngDirMap
val byDng = tags.groupBy { ExifTags.isDngTag(it.tagType) }
byDng[true]?.map { exifTagMapper(it) }?.let { dngDirMap.putAll(it) }
byDng[false]?.map { exifTagMapper(it) }?.let { dirMap.putAll(it) }
}
else -> dirMap.putAll(tags.map { exifTagMapper(it) })
}
mimeType == MimeTypes.DNG -> {
// split DNG tags in their own directory
val dngDirMap = metadataMap[DIR_DNG] ?: HashMap()
metadataMap[DIR_DNG] = dngDirMap
val byDng = tags.groupBy { ExifTags.isDngTag(it.tagType) }
byDng[true]?.map { exifTagMapper(it) }?.let { dngDirMap.putAll(it) }
byDng[false]?.map { exifTagMapper(it) }?.let { dirMap.putAll(it) }
}
else -> dirMap.putAll(tags.map { exifTagMapper(it) })
}
}
dir.isPngTextDir() -> {
metadataMap.remove(thisDirName)
dirMap = metadataMap[DIR_PNG_TEXTUAL_DATA] ?: HashMap()
metadataMap[DIR_PNG_TEXTUAL_DATA] = dirMap
dir.isPngTextDir() -> {
metadataMap.remove(thisDirName)
dirMap = metadataMap[DIR_PNG_TEXTUAL_DATA] ?: HashMap()
metadataMap[DIR_PNG_TEXTUAL_DATA] = dirMap
for (tag in tags) {
val tagType = tag.tagType
if (tagType == PngDirectory.TAG_TEXTUAL_DATA) {
val pairs = dir.getObject(tagType) as List<*>
val textPairs = pairs.map { pair ->
val kv = pair as KeyValuePair
val key = kv.key
// `PNG-iTXt` uses UTF-8, contrary to `PNG-tEXt` and `PNG-zTXt` using Latin-1 / ISO-8859-1
val charset = if (baseDirName == PNG_ITXT_DIR_NAME) {
@SuppressLint("ObsoleteSdkInt")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
StandardCharsets.UTF_8
} else {
Charset.forName("UTF-8")
}
} else {
kv.value.charset
}
val valueString = String(kv.value.bytes, charset)
val dirs = extractPngProfile(key, valueString)
if (dirs?.any() == true) {
dirs.forEach { profileDir ->
val profileDirName = "${dir.name}/${profileDir.name}"
val profileDirMap = metadataMap[profileDirName] ?: HashMap()
metadataMap[profileDirName] = profileDirMap
val profileTags = profileDir.tags
if (profileDir is ExifDirectoryBase) {
profileDirMap.putAll(profileTags.map { exifTagMapper(it) })
for (tag in tags) {
val tagType = tag.tagType
if (tagType == PngDirectory.TAG_TEXTUAL_DATA) {
val pairs = dir.getObject(tagType) as List<*>
val textPairs = pairs.map { pair ->
val kv = pair as KeyValuePair
val key = kv.key
// `PNG-iTXt` uses UTF-8, contrary to `PNG-tEXt` and `PNG-zTXt` using Latin-1 / ISO-8859-1
val charset = if (baseDirName == PNG_ITXT_DIR_NAME) {
@SuppressLint("ObsoleteSdkInt")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
StandardCharsets.UTF_8
} else {
profileDirMap.putAll(profileTags.map { Pair(it.tagName, it.description) })
Charset.forName("UTF-8")
}
} else {
kv.value.charset
}
val valueString = String(kv.value.bytes, charset)
val dirs = extractPngProfile(key, valueString)
if (dirs?.any() == true) {
dirs.forEach { profileDir ->
val profileDirName = "${dir.name}/${profileDir.name}"
val profileDirMap = metadataMap[profileDirName] ?: HashMap()
metadataMap[profileDirName] = profileDirMap
val profileTags = profileDir.tags
if (profileDir is ExifDirectoryBase) {
profileDirMap.putAll(profileTags.map { exifTagMapper(it) })
} else {
profileDirMap.putAll(profileTags.map { Pair(it.tagName, it.description) })
}
}
null
} else {
Pair(key, valueString)
}
null
} else {
Pair(key, valueString)
}
dirMap.putAll(textPairs.filterNotNull())
} else {
dirMap[tag.tagName] = tag.description
}
dirMap.putAll(textPairs.filterNotNull())
} else {
dirMap[tag.tagName] = tag.description
}
}
else -> dirMap.putAll(tags.map { Pair(it.tagName, it.description) })
}
else -> dirMap.putAll(tags.map { Pair(it.tagName, it.description) })
}
if (dir is XmpDirectory) {
processXmp(dir.xmpMeta, dirMap)
}
if (!isLargeMp4(mimeType, sizeBytes)) {
if (dir is Mp4UuidBoxDirectory) {
processMp4Uuid(dir)
}
if (dir is Mp4UuidBoxDirectory) {
when (dir.getString(Mp4UuidBoxDirectory.TAG_UUID)) {
GSpherical.SPHERICAL_VIDEO_V1_UUID -> {
val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA)
metadataMap["Spherical Video"] = HashMap(GSpherical(bytes).describe())
metadataMap.remove(thisDirName)
}
QuickTimeMetadata.PROF_UUID -> {
// redundant with info derived on the Dart side
metadataMap.remove(thisDirName)
}
QuickTimeMetadata.USMT_UUID -> {
val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA)
val blocks = QuickTimeMetadata.parseUuidUsmt(bytes)
if (blocks.isNotEmpty()) {
metadataMap.remove(thisDirName)
thisDirName = "QuickTime User Media"
val usmt = metadataMap[thisDirName] ?: HashMap()
metadataMap[thisDirName] = usmt
blocks.forEach {
var key = it.type
var value = it.value
val language = it.language
var i = 0
while (usmt.containsKey(key)) {
key = it.type + " (${++i})"
}
if (language != "und") {
value += " ($language)"
}
usmt[key] = value
}
}
}
if (dir is XmpDirectory) {
processXmp(dir.xmpMeta, dirMap, allowMultiple = true)
}
}
@ -367,13 +380,25 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
XMP.checkHeic(context, uri, mimeType, foundXmp) { xmpMeta ->
fun fallbackProcessXmp(xmpMeta: XMPMeta) {
val thisDirName = XmpDirectory().name
val dirMap = metadataMap[thisDirName] ?: HashMap()
metadataMap[thisDirName] = dirMap
processXmp(xmpMeta, dirMap)
}
XMP.checkHeic(context, mimeType, uri, foundXmp, ::fallbackProcessXmp)
if (isLargeMp4(mimeType, sizeBytes)) {
XMP.checkMp4(context, mimeType, uri) { dirs ->
for (dir in dirs.filterIsInstance<XmpDirectory>()) {
fallbackProcessXmp(dir.xmpMeta)
}
for (dir in dirs.filterIsInstance<Mp4UuidBoxDirectory>()) {
processMp4Uuid(dir)
}
}
}
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
@ -447,9 +472,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
val metadataMap = HashMap<String, Any>()
getCatalogMetadataByMetadataExtractor(uri, mimeType, path, sizeBytes, metadataMap)
getCatalogMetadataByMetadataExtractor(mimeType, uri, path, sizeBytes, metadataMap)
if (isVideo(mimeType) || isHeic(mimeType)) {
getMultimediaCatalogMetadataByMediaMetadataRetriever(uri, mimeType, metadataMap)
getMultimediaCatalogMetadataByMediaMetadataRetriever(mimeType, uri, metadataMap)
}
// report success even when empty
@ -457,8 +482,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
private fun getCatalogMetadataByMetadataExtractor(
uri: Uri,
mimeType: String,
uri: Uri,
path: String?,
sizeBytes: Long?,
metadataMap: HashMap<String, Any>,
@ -467,7 +492,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
var foundExif = false
var foundXmp = false
fun processXmp(xmpMeta: XMPMeta) {
fun processXmp(xmpMeta: XMPMeta, allowMultiple: Boolean = false) {
if (foundXmp && !allowMultiple) return
foundXmp = true
try {
if (xmpMeta.doesPropExist(XMP.DC_SUBJECT_PROP_NAME)) {
val values = xmpMeta.getPropArrayItemValues(XMP.DC_SUBJECT_PROP_NAME)
@ -504,12 +531,18 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
fun processMp4Uuid(dir: Mp4UuidBoxDirectory) {
// identification of spherical video (aka 360° video)
if (dir.getString(Mp4UuidBoxDirectory.TAG_UUID) == GSpherical.SPHERICAL_VIDEO_V1_UUID) {
flags = flags or MASK_IS_360
}
}
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { 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)) {
@ -565,16 +598,20 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
// XMP
metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp)
if (!isLargeMp4(mimeType, sizeBytes)) {
metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach {
processXmp(it, allowMultiple = true)
}
// XMP fallback to IPTC
if (!metadataMap.containsKey(KEY_XMP_TITLE) || !metadataMap.containsKey(KEY_XMP_SUBJECTS)) {
for (dir in metadata.getDirectoriesOfType(IptcDirectory::class.java)) {
if (!metadataMap.containsKey(KEY_XMP_TITLE)) {
dir.getSafeString(IptcDirectory.TAG_OBJECT_NAME) { metadataMap[KEY_XMP_TITLE] = it }
}
if (!metadataMap.containsKey(KEY_XMP_SUBJECTS)) {
dir.keywords?.let { metadataMap[KEY_XMP_SUBJECTS] = it.joinToString(XMP_SUBJECTS_SEPARATOR) }
// XMP fallback to IPTC
if (!metadataMap.containsKey(KEY_XMP_TITLE) || !metadataMap.containsKey(KEY_XMP_SUBJECTS)) {
for (dir in metadata.getDirectoriesOfType(IptcDirectory::class.java)) {
if (!metadataMap.containsKey(KEY_XMP_TITLE)) {
dir.getSafeString(IptcDirectory.TAG_OBJECT_NAME) { metadataMap[KEY_XMP_TITLE] = it }
}
if (!metadataMap.containsKey(KEY_XMP_SUBJECTS)) {
dir.keywords?.let { metadataMap[KEY_XMP_SUBJECTS] = it.joinToString(XMP_SUBJECTS_SEPARATOR) }
}
}
}
}
@ -620,12 +657,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
// identification of spherical video (aka 360° video)
if (metadata.getDirectoriesOfType(Mp4UuidBoxDirectory::class.java).any {
it.getString(Mp4UuidBoxDirectory.TAG_UUID) == GSpherical.SPHERICAL_VIDEO_V1_UUID
}) {
flags = flags or MASK_IS_360
}
metadata.getDirectoriesOfType(Mp4UuidBoxDirectory::class.java).forEach(::processMp4Uuid)
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
@ -662,7 +694,17 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp)
XMP.checkHeic(context, mimeType, uri, foundXmp, ::processXmp)
if (isLargeMp4(mimeType, sizeBytes)) {
XMP.checkMp4(context, mimeType, uri) { dirs ->
for (dir in dirs.filterIsInstance<XmpDirectory>()) {
processXmp(dir.xmpMeta)
}
for (dir in dirs.filterIsInstance<Mp4UuidBoxDirectory>()) {
processMp4Uuid(dir)
}
}
}
if (mimeType == MimeTypes.TIFF && MultiPage.isMultiPageTiff(context, uri)) flags = flags or MASK_IS_MULTIPAGE
@ -670,8 +712,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
private fun getMultimediaCatalogMetadataByMediaMetadataRetriever(
uri: Uri,
mimeType: String,
uri: Uri,
metadataMap: HashMap<String, Any>,
) {
val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return
@ -862,10 +904,12 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
return
}
var foundXmp = false
val fields: FieldMap = hashMapOf()
var foundXmp = false
fun processXmp(xmpMeta: XMPMeta) {
fun processXmp(xmpMeta: XMPMeta, allowMultiple: Boolean = false) {
if (foundXmp && !allowMultiple) return
foundXmp = true
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 }
@ -879,12 +923,13 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
if (canReadWithMetadataExtractor(mimeType)) {
if (canReadWithMetadataExtractor(mimeType) && !isLargeMp4(mimeType, sizeBytes)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = Helper.safeRead(input)
foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp)
metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach {
processXmp(it, allowMultiple = true)
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
@ -895,7 +940,14 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp)
XMP.checkHeic(context, mimeType, uri, foundXmp, ::processXmp)
if (isLargeMp4(mimeType, sizeBytes)) {
XMP.checkMp4(context, mimeType, uri) { dirs ->
for (dir in dirs.filterIsInstance<XmpDirectory>()) {
processXmp(dir.xmpMeta)
}
}
}
if (fields.isEmpty()) {
result.error("getPanoramaInfo-empty", "failed to get info for mimeType=$mimeType uri=$uri", null)
@ -929,6 +981,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
result.success(null)
}
// return XMP components
// return an empty list if there is no XMP
private fun getXmp(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
@ -938,10 +992,12 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
return
}
var foundXmp = false
val xmpStrings = mutableListOf<String>()
var foundXmp = false
fun processXmp(xmpMeta: XMPMeta) {
fun processXmp(xmpMeta: XMPMeta, allowMultiple: Boolean = false) {
if (foundXmp && !allowMultiple) return
foundXmp = true
try {
xmpStrings.add(XMPMetaFactory.serializeToString(xmpMeta, xmpSerializeOptions))
} catch (e: XMPException) {
@ -949,12 +1005,13 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
if (canReadWithMetadataExtractor(mimeType)) {
if (canReadWithMetadataExtractor(mimeType) && !isLargeMp4(mimeType, sizeBytes)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = Helper.safeRead(input)
foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp)
metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach {
processXmp(it, allowMultiple = true)
}
}
} catch (e: Exception) {
result.error("getXmp-exception", "failed to read XMP for mimeType=$mimeType uri=$uri", e.message)
@ -968,13 +1025,16 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp)
if (xmpStrings.isEmpty()) {
result.success(null)
} else {
result.success(xmpStrings)
XMP.checkHeic(context, mimeType, uri, foundXmp, ::processXmp)
if (isLargeMp4(mimeType, sizeBytes)) {
XMP.checkMp4(context, mimeType, uri) { dirs ->
for (dir in dirs.filterIsInstance<XmpDirectory>()) {
processXmp(dir.xmpMeta)
}
}
}
result.success(xmpStrings)
}
private fun hasContentResolverProp(call: MethodCall, result: MethodChannel.Result) {
@ -1161,6 +1221,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
omitXmpMetaElement = false // e.g. <x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core Test.SNAPSHOT">...</x:xmpmeta>
}
private fun isLargeMp4(mimeType: String, sizeBytes: Long?) = mimeType == MimeTypes.MP4 && Metadata.isDangerouslyLarge(sizeBytes)
private fun exifTagMapper(it: Tag): Pair<String, String> {
val name = if (it.hasTagName()) {
it.tagName

View file

@ -12,6 +12,7 @@ import com.caverock.androidsvg.SVG
import com.caverock.androidsvg.SVGParseException
import deckers.thibault.aves.metadata.SvgHelper.normalizeSize
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.MemoryUtils
import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.MethodChannel
import kotlin.math.ceil
@ -23,11 +24,18 @@ class SvgRegionFetcher internal constructor(
suspend fun fetch(
uri: Uri,
sizeBytes: Long?,
regionRect: Rect,
imageWidth: Int,
imageHeight: Int,
result: MethodChannel.Result,
) {
if (!MemoryUtils.canAllocate(sizeBytes)) {
// opening an SVG that large would yield an OOM during parsing from `com.caverock.androidsvg.SVGParser`
result.error("fetch-read-large", "SVG too large at $sizeBytes bytes, for uri=$uri regionRect=$regionRect", null)
return
}
var currentSvgRef = lastSvgRef
if (currentSvgRef != null && currentSvgRef.uri != uri) {
currentSvgRef = null

View file

@ -39,6 +39,7 @@ class ThumbnailFetcher internal constructor(
height: Int?,
private val pageId: Int?,
private val defaultSize: Int,
private val quality: Int,
private val result: MethodChannel.Result,
) {
private val uri: Uri = Uri.parse(uri)
@ -79,7 +80,7 @@ class ThumbnailFetcher internal constructor(
}
if (bitmap != null) {
result.success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false))
result.success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false, quality = quality))
} else {
var errorDetails: String? = exception?.message
if (errorDetails?.isNotEmpty() == true) {
@ -119,7 +120,7 @@ class ThumbnailFetcher internal constructor(
private fun getByGlide(): Bitmap? {
// add signature to ignore cache for images which got modified but kept the same URI
var options = RequestOptions()
.format(DecodeFormat.PREFER_RGB_565)
.format(if (quality == 100) DecodeFormat.PREFER_ARGB_8888 else DecodeFormat.PREFER_RGB_565)
.signature(ObjectKey("$dateModifiedSecs-$rotationDegrees-$isFlipped-$width-$pageId"))
.override(width, height)

View file

@ -19,22 +19,24 @@ class TiffRegionFetcher internal constructor(
result: MethodChannel.Result,
) {
try {
@Suppress("BlockingMethodInNonBlockingContext")
val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
if (fd == null) {
val pfd = context.contentResolver.openFileDescriptor(uri, "r")
if (pfd == null) {
result.error("getRegion-tiff-fd", "failed to get file descriptor for uri=$uri", null)
return
}
val options = TiffBitmapFactory.Options().apply {
inDirectoryNumber = page
inSampleSize = sampleSize
inDecodeArea = DecodeArea(regionRect.left, regionRect.top, regionRect.width(), regionRect.height())
}
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
if (bitmap != null) {
result.success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
} else {
result.error("getRegion-tiff-null", "failed to decode region for uri=$uri page=$page regionRect=$regionRect", null)
pfd.use {
val fd = pfd.detachFd()
val options = TiffBitmapFactory.Options().apply {
inDirectoryNumber = page
inSampleSize = sampleSize
inDecodeArea = DecodeArea(regionRect.left, regionRect.top, regionRect.width(), regionRect.height())
}
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
if (bitmap != null) {
result.success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
} else {
result.error("getRegion-tiff-null", "failed to decode region for uri=$uri page=$page regionRect=$regionRect", null)
}
}
} catch (e: Exception) {
result.error("getRegion-tiff-read-exception", "failed to read from uri=$uri page=$page regionRect=$regionRect", e.message)

View file

@ -188,7 +188,7 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
}
private fun pickCollectionFilters() {
val initialFilters = (args["initialFilters"] as List<*>?)?.mapNotNull { if (it is String) it else null } ?: listOf()
val initialFilters = (args["initialFilters"] as? List<*>)?.mapNotNull { if (it is String) it else null } ?: listOf()
val intent = Intent(MainActivity.INTENT_ACTION_PICK_COLLECTION_FILTERS, null, activity, MainActivity::class.java)
.putExtra(MainActivity.EXTRA_KEY_FILTERS_ARRAY, initialFilters.toTypedArray())
.putExtra(MainActivity.EXTRA_KEY_FILTERS_STRING, initialFilters.joinToString(MainActivity.EXTRA_STRING_ARRAY_SEPARATOR))

View file

@ -15,6 +15,7 @@ import deckers.thibault.aves.decoder.VideoThumbnail
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MemoryUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.canDecodeWithFlutter
import deckers.thibault.aves.utils.MimeTypes.isHeic
@ -97,18 +98,23 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
}
if (isVideo(mimeType)) {
streamVideoByGlide(uri, mimeType)
streamVideoByGlide(uri, mimeType, sizeBytes)
} else if (!canDecodeWithFlutter(mimeType, rotationDegrees, isFlipped)) {
// decode exotic format on platform side, then encode it in portable format for Flutter
streamImageByGlide(uri, pageId, mimeType, sizeBytes, rotationDegrees, isFlipped)
} else {
// to be decoded by Flutter
streamImageAsIs(uri, mimeType)
streamImageAsIs(uri, mimeType, sizeBytes)
}
endOfStream()
}
private fun streamImageAsIs(uri: Uri, mimeType: String) {
private fun streamImageAsIs(uri: Uri, mimeType: String, sizeBytes: Long?) {
if (!MemoryUtils.canAllocate(sizeBytes)) {
error("streamImage-image-read-large", "original image too large at $sizeBytes bytes, for mimeType=$mimeType uri=$uri", null)
return
}
try {
StorageUtils.openInputStream(context, uri)?.use { input -> streamBytes(input) }
} catch (e: Exception) {
@ -144,7 +150,12 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
}
if (bitmap != null) {
success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false))
val bytes = bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false)
if (MemoryUtils.canAllocate(sizeBytes)) {
success(bytes)
} else {
error("streamImage-image-decode-large", "decoded image too large at $sizeBytes bytes, for mimeType=$mimeType uri=$uri", null)
}
} else {
error("streamImage-image-decode-null", "failed to get image for mimeType=$mimeType uri=$uri", null)
}
@ -155,7 +166,7 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
}
}
private suspend fun streamVideoByGlide(uri: Uri, mimeType: String) {
private suspend fun streamVideoByGlide(uri: Uri, mimeType: String, sizeBytes: Long?) {
val target = Glide.with(context)
.asBitmap()
.apply(glideOptions)
@ -165,7 +176,12 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
@Suppress("BlockingMethodInNonBlockingContext")
val bitmap = target.get()
if (bitmap != null) {
success(bitmap.getBytes(canHaveAlpha = false, recycle = false))
val bytes = bitmap.getBytes(canHaveAlpha = false, recycle = false)
if (MemoryUtils.canAllocate(sizeBytes)) {
success(bytes)
} else {
error("streamImage-video-large", "decoded image too large at $sizeBytes bytes, for mimeType=$mimeType uri=$uri", null)
}
} else {
error("streamImage-video-null", "failed to get image for mimeType=$mimeType uri=$uri", null)
}

View file

@ -42,6 +42,7 @@ object Metadata {
const val TYPE_JFIF = "jfif"
const val TYPE_JPEG_ADOBE = "jpeg_adobe"
const val TYPE_JPEG_DUCKY = "jpeg_ducky"
const val TYPE_MP4 = "mp4"
const val TYPE_PHOTOSHOP_IRB = "photoshop_irb"
const val TYPE_XMP = "xmp"
@ -116,14 +117,16 @@ object Metadata {
return date.time + parseSubSecond(subSecond)
}
// opening large PSD/TIFF files yields an OOM (both with `metadata-extractor` v2.15.0 and `ExifInterface` v1.3.1),
// Opening large PSD/TIFF files yields an OOM (both with `metadata-extractor` v2.15.0 and `ExifInterface` v1.3.1),
// so we define an arbitrary threshold to avoid a crash on launch.
// It is not clear whether it is because of the file itself or its metadata.
private const val fileSizeBytesMax = 100 * (1 shl 20) // MB
private const val FILE_SIZE_MAX = 100 * (1 shl 20) // MB
fun isDangerouslyLarge(sizeBytes: Long?) = sizeBytes == null || sizeBytes > FILE_SIZE_MAX
// we try and read metadata from large files by copying an arbitrary amount from its beginning
// to a temporary file, and reusing that preview file for all metadata reading purposes
private const val previewSize: Long = 5 * (1 shl 20) // MB
private const val PREVIEW_SIZE: Long = 5 * (1 shl 20) // MB
private val previewFiles = HashMap<Uri, File>()
@ -134,10 +137,7 @@ object Metadata {
MimeTypes.PSD_VND,
MimeTypes.PSD_X,
MimeTypes.TIFF -> {
if (sizeBytes != null && sizeBytes < fileSizeBytesMax) {
// small enough to be safe as it is
uri
} else {
if (isDangerouslyLarge(sizeBytes)) {
// make a preview from the beginning of the file,
// hoping the metadata is accessible in the copied chunk
var previewFile = previewFiles[uri]
@ -146,6 +146,9 @@ object Metadata {
previewFiles[uri] = previewFile
}
Uri.fromFile(previewFile)
} else {
// small enough to be safe as it is
uri
}
}
// *probably* safe
@ -156,7 +159,7 @@ object Metadata {
fun createPreviewFile(context: Context, uri: Uri): File {
return File.createTempFile("aves", null, context.cacheDir).apply {
deleteOnExit()
transferFrom(StorageUtils.openInputStream(context, uri), previewSize)
transferFrom(StorageUtils.openInputStream(context, uri), PREVIEW_SIZE)
}
}

View file

@ -0,0 +1,221 @@
package deckers.thibault.aves.metadata
import android.content.Context
import android.net.Uri
import deckers.thibault.aves.utils.StorageUtils
import org.mp4parser.*
import org.mp4parser.boxes.UserBox
import org.mp4parser.boxes.apple.AppleGPSCoordinatesBox
import org.mp4parser.boxes.iso14496.part12.*
import org.mp4parser.support.AbstractBox
import org.mp4parser.support.Matrix
import org.mp4parser.tools.Path
import java.io.ByteArrayOutputStream
import java.io.FileInputStream
import java.nio.channels.Channels
object Mp4ParserHelper {
fun computeEdits(context: Context, uri: Uri, modifier: (isoFile: IsoFile) -> Unit): List<Pair<Long, ByteArray>> {
// we can skip uninteresting boxes with a seekable data source
val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri")
pfd.use {
FileInputStream(it.fileDescriptor).use { stream ->
stream.channel.use { channel ->
val boxParser = PropertyBoxParserImpl().apply {
skippingBoxes(MediaDataBox.TYPE)
}
// creating `IsoFile` with a `File` or a `File.inputStream()` yields `No such device`
IsoFile(channel, boxParser).use { isoFile ->
val lastContentBox = isoFile.boxes.reversed().firstOrNull { box ->
when {
box == isoFile.movieBox -> false
testXmpBox(box) -> false
box is FreeBox -> false
else -> true
}
}
lastContentBox ?: throw Exception("failed to find last context box")
val oldFileSize = isoFile.size
var appendOffset = (isoFile.getBoxOffset { box -> box == lastContentBox })!! + lastContentBox.size
val edits = arrayListOf<Pair<Long, ByteArray>>()
fun addFreeBoxEdit(offset: Long, size: Long) = edits.add(Pair(offset, FreeBox(size.toInt() - 8).toBytes()))
// replace existing movie box by a free box
isoFile.getBoxOffset { box -> box.type == MovieBox.TYPE }?.let { offset ->
addFreeBoxEdit(offset, isoFile.movieBox.size)
}
// replace existing XMP box by a free box
isoFile.getBoxOffset { box -> testXmpBox(box) }?.let { offset ->
addFreeBoxEdit(offset, isoFile.xmpBox!!.size)
}
modifier(isoFile)
// write edited movie box
val movieBoxBytes = isoFile.movieBox.toBytes()
edits.removeAll { (offset, _) -> offset == appendOffset }
edits.add(Pair(appendOffset, movieBoxBytes))
appendOffset += movieBoxBytes.size
// write edited XMP box
isoFile.xmpBox?.let { box ->
edits.removeAll { (offset, _) -> offset == appendOffset }
edits.add(Pair(appendOffset, box.toBytes()))
appendOffset += box.size
}
// write trailing free box instead of truncating
val trailing = oldFileSize - appendOffset
if (trailing > 0) {
addFreeBoxEdit(appendOffset, trailing)
}
return edits
}
}
}
}
}
// according to XMP Specification Part 3 - Storage in Files,
// XMP is embedded in MPEG-4 files using a top-level UUID box
private fun testXmpBox(box: Box): Boolean {
if (box is UserBox) {
if (!box.isParsed) {
box.parseDetails()
}
return box.userType.contentEquals(XMP.mp4Uuid)
}
return false
}
// extensions
fun IsoFile.updateLocation(locationIso6709: String?) {
// Apple GPS Coordinates Box can be in various locations:
// - moov[0]/udta[0]/©xyz
// - moov[0]/meta[0]/ilst/©xyz
// - others?
removeBoxes(AppleGPSCoordinatesBox::class.java, true)
locationIso6709 ?: return
var userDataBox = Path.getPath<UserDataBox>(movieBox, UserDataBox.TYPE)
if (userDataBox == null) {
userDataBox = UserDataBox()
movieBox.addBox(userDataBox)
}
userDataBox.addBox(AppleGPSCoordinatesBox().apply {
value = locationIso6709
})
}
fun IsoFile.updateRotation(degrees: Int): Boolean {
val matrix: Matrix = when (degrees) {
0 -> Matrix.ROTATE_0
90 -> Matrix.ROTATE_90
180 -> Matrix.ROTATE_180
270 -> Matrix.ROTATE_270
else -> throw Exception("failed because of invalid rotation degrees=$degrees")
}
var success = false
movieBox.getBoxes(TrackHeaderBox::class.java, true).filter { tkhd ->
if (!tkhd.isParsed) {
tkhd.parseDetails()
}
tkhd.width > 0 && tkhd.height > 0
}.forEach { tkhd ->
if (!setOf(Matrix.ROTATE_0, Matrix.ROTATE_90, Matrix.ROTATE_180, Matrix.ROTATE_270).contains(tkhd.matrix)) {
throw Exception("failed because existing matrix is not a simple rotation matrix")
}
tkhd.matrix = matrix
success = true
}
return success
}
fun IsoFile.updateXmp(xmp: String?) {
val xmpBox = xmpBox
if (xmp != null) {
val xmpData = xmp.toByteArray(Charsets.UTF_8)
if (xmpBox == null) {
addBox(UserBox(XMP.mp4Uuid).apply {
data = xmpData
})
} else {
xmpBox.data = xmpData
}
} else if (xmpBox != null) {
removeBox(xmpBox)
}
}
private fun IsoFile.getBoxOffset(test: (box: Box) -> Boolean): Long? {
var offset = 0L
for (box in boxes) {
if (test(box)) {
return offset
}
offset += box.size
}
return null
}
private val IsoFile.xmpBox: UserBox?
get() = boxes.firstOrNull { testXmpBox(it) } as UserBox?
fun <T : Box> Container.processBoxes(clazz: Class<T>, recursive: Boolean, apply: (box: T, parent: Container) -> Unit) {
// use a copy, in case box processing removes boxes
for (box in ArrayList(boxes)) {
if (clazz.isInstance(box)) {
@Suppress("unchecked_cast")
apply(box as T, this)
}
if (recursive && box is Container) {
box.processBoxes(clazz, true, apply)
}
}
}
private fun <T : Box> Container.removeBoxes(clazz: Class<T>, recursive: Boolean) {
processBoxes(clazz, recursive) { box, parent -> parent.removeBox(box) }
}
private fun Container.removeBox(box: Box) {
boxes = boxes.apply { remove(box) }
}
fun Container.dumpBoxes(sb: StringBuilder, indent: Int = 0) {
for (box in boxes) {
val boxType = box.type
try {
if (box is AbstractBox && !box.isParsed) {
box.parseDetails()
}
when (box) {
is BasicContainer -> {
sb.appendLine("${"\t".repeat(indent)}[$boxType] ${box.javaClass.simpleName}")
box.dumpBoxes(sb, indent + 1)
}
is UserBox -> {
val userTypeHex = box.userType.joinToString("") { "%02x".format(it) }
sb.appendLine("${"\t".repeat(indent)}[$boxType] userType=$userTypeHex $box")
}
else -> sb.appendLine("${"\t".repeat(indent)}[$boxType] $box")
}
} catch (e: Exception) {
sb.appendLine("${"\t".repeat(indent)}failed to access box type=$boxType exception=${e.message}")
}
}
}
fun Box.toBytes(): ByteArray {
val stream = ByteArrayOutputStream(size.toInt())
Channels.newChannel(stream).use { getBox(it) }
return stream.toByteArray()
}
}

View file

@ -204,7 +204,7 @@ object MultiPage {
Log.w(LOG_TAG, "failed to get motion photo offset from uri=$uri", e)
}
XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp)
XMP.checkHeic(context, mimeType, uri, foundXmp, ::processXmp)
return offsetFromEnd
}

View file

@ -10,16 +10,28 @@ 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 com.drew.metadata.Directory
import deckers.thibault.aves.metadata.Mp4ParserHelper.processBoxes
import deckers.thibault.aves.metadata.Mp4ParserHelper.toBytes
import deckers.thibault.aves.metadata.metadataextractor.SafeMp4UuidBoxHandler
import deckers.thibault.aves.metadata.metadataextractor.SafeXmpReader
import deckers.thibault.aves.utils.ContextUtils.queryContentResolverProp
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils
import org.mp4parser.IsoFile
import org.mp4parser.PropertyBoxParserImpl
import org.mp4parser.boxes.UserBox
import org.mp4parser.boxes.iso14496.part12.MediaDataBox
import java.io.FileInputStream
import java.util.*
object XMP {
private val LOG_TAG = LogUtils.createTag<XMP>()
// BE7ACFCB 97A942E8 9C719994 91E3AFAC / BE7ACFCB-97A9-42E8-9C71-999491E3AFAC
val mp4Uuid = 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())
// standard namespaces
// cf com.adobe.internal.xmp.XMPConst
private const val DC_NS_URI = "http://purl.org/dc/elements/1.1/"
@ -94,7 +106,13 @@ object XMP {
// 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) {
fun checkHeic(
context: Context,
mimeType: String,
uri: Uri,
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)
@ -108,6 +126,43 @@ object XMP {
}
}
// as of `metadata-extractor` v2.18.0, processing large MP4 files may crash,
// so we fall back to parsing with `mp4parser`
fun checkMp4(
context: Context,
mimeType: String,
uri: Uri,
processDirs: (dirs: List<Directory>) -> Unit,
) {
if (mimeType != MimeTypes.MP4) return
try {
// we can skip uninteresting boxes with a seekable data source
val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri")
pfd.use {
FileInputStream(it.fileDescriptor).use { stream ->
stream.channel.use { channel ->
val boxParser = PropertyBoxParserImpl().apply {
skippingBoxes(MediaDataBox.TYPE)
}
// creating `IsoFile` with a `File` or a `File.inputStream()` yields `No such device`
IsoFile(channel, boxParser).use { isoFile ->
isoFile.processBoxes(UserBox::class.java, true) { box, _ ->
val bytes = box.toBytes()
val payload = bytes.copyOfRange(8, bytes.size)
val metadata = com.drew.metadata.Metadata()
SafeMp4UuidBoxHandler(metadata).processBox("", payload, -1, null)
processDirs(metadata.directories.filter { dir -> dir.tagCount > 0 }.toList())
}
}
}
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get XMP by MP4 parser for mimeType=$mimeType uri=$uri", e)
}
}
// extensions
fun XMPMeta.isMotionPhoto(): Boolean {

View file

@ -4,20 +4,17 @@ import com.drew.imaging.mp4.Mp4Handler
import com.drew.metadata.Metadata
import com.drew.metadata.mp4.Mp4Context
import com.drew.metadata.mp4.media.Mp4UuidBoxHandler
import deckers.thibault.aves.metadata.XMP
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)) {
if (payloadUuid.contentEquals(XMP.mp4Uuid)) {
SafeXmpReader().extract(payload, 16, payload.size - 16, metadata, directory)
return this
}
}
return super.processBox(type, payload, boxSize, context)
}
companion object {
val xmpUuid = byteArrayOf(0xbe.toByte(), 0x7a, 0xcf.toByte(), 0xcb.toByte(), 0x97.toByte(), 0xa9.toByte(), 0x42, 0xe8.toByte(), 0x9c.toByte(), 0x71, 0x99.toByte(), 0x94.toByte(), 0x91.toByte(), 0xe3.toByte(), 0xaf.toByte(), 0xac.toByte())
}
}

View file

@ -28,7 +28,7 @@ object SafePngMetadataReader {
private val LOG_TAG = LogUtils.createTag<SafePngMetadataReader>()
// arbitrary size to detect chunks that may yield an OOM
private const val chunkSizeDangerThreshold = SafeXmpReader.segmentTypeSizeDangerThreshold
private const val chunkSizeDangerThreshold = SafeXmpReader.SEGMENT_TYPE_SIZE_DANGER_THRESHOLD
private val latin1Encoding = Charsets.ISO_8859_1
private val desiredChunkTypes: Set<PngChunkType> = hashSetOf(

View file

@ -48,12 +48,8 @@ class SafeXmpReader : XmpReader() {
extendedXMPBuffer?.let { xmpBytes ->
val totalSize = xmpBytes.size
if (totalSize > segmentTypeSizeDangerThreshold) {
val error = "Extended XMP is too large, with a total size of $totalSize B"
Log.w(LOG_TAG, error)
metadata.addDirectory(XmpDirectory().apply {
addError(error)
})
if (totalSize > SEGMENT_TYPE_SIZE_DANGER_THRESHOLD) {
logError(metadata, totalSize)
} else {
extract(xmpBytes, metadata)
}
@ -99,7 +95,7 @@ class SafeXmpReader : XmpReader() {
return null
}
// adapted from `XmpReader` because original is private
// adapted from `XmpReader` to prevent large allocation
private fun processExtendedXMPChunk(metadata: Metadata, segmentBytes: ByteArray, extendedXMPGUID: String, extendedXMPBufferIn: ByteArray?): ByteArray? {
var extendedXMPBuffer: ByteArray? = extendedXMPBufferIn
val extensionPreambleLength = XMP_EXTENSION_JPEG_PREAMBLE.length
@ -113,7 +109,15 @@ class SafeXmpReader : XmpReader() {
if (extendedXMPGUID == segmentGUID) {
val fullLength = reader.uInt32.toInt()
val chunkOffset = reader.uInt32.toInt()
if (extendedXMPBuffer == null) extendedXMPBuffer = ByteArray(fullLength)
if (extendedXMPBuffer == null) {
// TLAD insert start
if (fullLength > SEGMENT_TYPE_SIZE_DANGER_THRESHOLD) {
logError(metadata, fullLength)
return null
}
// TLAD insert end
extendedXMPBuffer = ByteArray(fullLength)
}
if (extendedXMPBuffer.size == fullLength) {
System.arraycopy(segmentBytes, totalOffset, extendedXMPBuffer, chunkOffset, segmentLength - totalOffset)
} else {
@ -131,11 +135,19 @@ class SafeXmpReader : XmpReader() {
return extendedXMPBuffer
}
private fun logError(metadata: Metadata, size: Int) {
val error = "Extended XMP is too large, with a size of $size B"
Log.w(LOG_TAG, error)
metadata.addDirectory(XmpDirectory().apply {
addError(error)
})
}
companion object {
private val LOG_TAG = LogUtils.createTag<SafeXmpReader>()
// arbitrary size to detect extended XMP that may yield an OOM
const val segmentTypeSizeDangerThreshold = 3 * (1 shl 20) // MB
const val SEGMENT_TYPE_SIZE_DANGER_THRESHOLD = 3 * (1 shl 20) // MB
// tighter node limits for faster loading
val PARSE_OPTIONS: ParseOptions = ParseOptions().setXMPNodesToLimit(

View file

@ -22,6 +22,13 @@ import deckers.thibault.aves.decoder.SvgImage
import deckers.thibault.aves.decoder.TiffImage
import deckers.thibault.aves.metadata.*
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF
import deckers.thibault.aves.metadata.Metadata.TYPE_IPTC
import deckers.thibault.aves.metadata.Metadata.TYPE_MP4
import deckers.thibault.aves.metadata.Metadata.TYPE_XMP
import deckers.thibault.aves.metadata.Mp4ParserHelper.updateLocation
import deckers.thibault.aves.metadata.Mp4ParserHelper.updateRotation
import deckers.thibault.aves.metadata.Mp4ParserHelper.updateXmp
import deckers.thibault.aves.metadata.PixyMetaHelper.extendedXmpDocString
import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString
import deckers.thibault.aves.model.AvesEntry
@ -37,10 +44,8 @@ import deckers.thibault.aves.utils.MimeTypes.canEditXmp
import deckers.thibault.aves.utils.MimeTypes.canRemoveMetadata
import deckers.thibault.aves.utils.MimeTypes.extensionFor
import deckers.thibault.aves.utils.MimeTypes.isVideo
import java.io.ByteArrayInputStream
import java.io.File
import java.io.IOException
import java.io.OutputStream
import java.io.*
import java.nio.channels.Channels
import java.util.*
abstract class ImageProvider {
@ -350,6 +355,7 @@ abstract class ImageProvider {
// copy the edited temporary file back to the original
DocumentFileCompat.fromFile(editableFile).copyTo(targetDocFile)
editableFile.delete()
}
val fileName = targetDocFile.name
@ -457,11 +463,12 @@ abstract class ImageProvider {
}
// copy the edited temporary file back to the original
copyFileTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path)
editableFile.transferTo(outputStream(context, mimeType, uri, path))
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
return false
}
editableFile.delete()
} catch (e: IOException) {
callback.onFailure(e)
return false
@ -524,7 +531,7 @@ abstract class ImageProvider {
iptc != null ->
PixyMetaHelper.setIptc(input, output, iptc)
canRemoveMetadata(mimeType) ->
PixyMetaHelper.removeMetadata(input, output, setOf(Metadata.TYPE_IPTC))
PixyMetaHelper.removeMetadata(input, output, setOf(TYPE_IPTC))
else -> {
Log.w(LOG_TAG, "setting empty IPTC for mimeType=$mimeType")
PixyMetaHelper.setIptc(input, output, null)
@ -539,11 +546,12 @@ abstract class ImageProvider {
}
// copy the edited temporary file back to the original
copyFileTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path)
editableFile.transferTo(outputStream(context, mimeType, uri, path))
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
return false
}
editableFile.delete()
} catch (e: IOException) {
callback.onFailure(e)
return false
@ -552,6 +560,67 @@ abstract class ImageProvider {
return true
}
private fun editMp4Metadata(
context: Context,
path: String,
uri: Uri,
mimeType: String,
callback: ImageOpCallback,
fieldsToEdit: Map<*, *>,
newFields: FieldMap? = null,
): Boolean {
if (mimeType != MimeTypes.MP4) {
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
return false
}
try {
val edits = Mp4ParserHelper.computeEdits(context, uri) { isoFile ->
fieldsToEdit.forEach { kv ->
val tag = kv.key as String
val value = kv.value as String?
when (tag) {
"gpsCoordinates" -> isoFile.updateLocation(value)
"rotationDegrees" -> {
val degrees = value?.toIntOrNull() ?: throw Exception("failed because of invalid rotation=$value")
if (isoFile.updateRotation(degrees) && newFields != null) {
newFields["rotationDegrees"] = degrees
}
}
"xmp" -> isoFile.updateXmp(value)
}
}
}
val pfd = StorageUtils.openOutputFileDescriptor(
context = context,
mimeType = mimeType,
uri = uri,
path = path,
// do not truncate
mode = "w",
) ?: throw Exception("failed to open file descriptor for uri=$uri path=$path")
pfd.use {
FileOutputStream(it.fileDescriptor).use { outputStream ->
outputStream.channel.use { outputChannel ->
edits.forEach { (offset, bytes) ->
bytes.inputStream().use { inputStream ->
Channels.newChannel(inputStream).use { inputChannel ->
outputChannel.transferFrom(inputChannel, offset, bytes.size.toLong())
}
}
}
}
}
}
} catch (e: Exception) {
callback.onFailure(e)
return false
}
return true
}
// provide `editCoreXmp` to modify existing core XMP,
// or provide `coreXmp` and `extendedXmp` to set them
private fun editXmp(
@ -571,41 +640,31 @@ abstract class ImageProvider {
return false
}
if (mimeType == MimeTypes.MP4) {
return editMp4Metadata(
context = context,
path = path,
uri = uri,
mimeType = mimeType,
callback = callback,
fieldsToEdit = mapOf("xmp" to coreXmp),
)
}
val originalFileSize = File(path).length()
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
val editableFile = File.createTempFile("aves", null).apply {
deleteOnExit()
try {
var editedXmpString = coreXmp
var editedExtendedXmp = extendedXmp
if (editCoreXmp != null) {
val pixyXmp = StorageUtils.openInputStream(context, uri)?.use { input -> PixyMetaHelper.getXmp(input) }
if (pixyXmp != null) {
editedXmpString = editCoreXmp(pixyXmp.xmpDocString())
if (pixyXmp.hasExtendedXmp()) {
editedExtendedXmp = pixyXmp.extendedXmpDocString()
}
}
}
outputStream().use { output ->
// reopen input to read from start
StorageUtils.openInputStream(context, uri)?.use { input ->
if (editedXmpString != null) {
if (editedExtendedXmp != null && mimeType != MimeTypes.JPEG) {
Log.w(LOG_TAG, "extended XMP is not supported by mimeType=$mimeType")
PixyMetaHelper.setXmp(input, output, editedXmpString, null)
} else {
PixyMetaHelper.setXmp(input, output, editedXmpString, editedExtendedXmp)
}
} else if (canRemoveMetadata(mimeType)) {
PixyMetaHelper.removeMetadata(input, output, setOf(Metadata.TYPE_XMP))
} else {
Log.w(LOG_TAG, "setting empty XMP for mimeType=$mimeType")
PixyMetaHelper.setXmp(input, output, null, null)
}
}
}
editXmpWithPixy(
context = context,
uri = uri,
mimeType = mimeType,
coreXmp = coreXmp,
extendedXmp = extendedXmp,
editCoreXmp = editCoreXmp,
editableFile = this
)
} catch (e: Exception) {
callback.onFailure(e)
return false
@ -614,11 +673,12 @@ abstract class ImageProvider {
try {
// copy the edited temporary file back to the original
copyFileTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path)
editableFile.transferTo(outputStream(context, mimeType, uri, path))
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
return false
}
editableFile.delete()
} catch (e: IOException) {
callback.onFailure(e)
return false
@ -627,6 +687,47 @@ abstract class ImageProvider {
return true
}
private fun editXmpWithPixy(
context: Context,
uri: Uri,
mimeType: String,
coreXmp: String?,
extendedXmp: String?,
editCoreXmp: ((xmp: String) -> String)?,
editableFile: File
) {
var editedXmpString = coreXmp
var editedExtendedXmp = extendedXmp
if (editCoreXmp != null) {
val pixyXmp = StorageUtils.openInputStream(context, uri)?.use { input -> PixyMetaHelper.getXmp(input) }
if (pixyXmp != null) {
editedXmpString = editCoreXmp(pixyXmp.xmpDocString())
if (pixyXmp.hasExtendedXmp()) {
editedExtendedXmp = pixyXmp.extendedXmpDocString()
}
}
}
editableFile.outputStream().use { output ->
// reopen input to read from start
StorageUtils.openInputStream(context, uri)?.use { input ->
if (editedXmpString != null) {
if (editedExtendedXmp != null && mimeType != MimeTypes.JPEG) {
Log.w(LOG_TAG, "extended XMP is not supported by mimeType=$mimeType")
PixyMetaHelper.setXmp(input, output, editedXmpString, null)
} else {
PixyMetaHelper.setXmp(input, output, editedXmpString, editedExtendedXmp)
}
} else if (canRemoveMetadata(mimeType)) {
PixyMetaHelper.removeMetadata(input, output, setOf(TYPE_XMP))
} else {
Log.w(LOG_TAG, "setting empty XMP for mimeType=$mimeType")
PixyMetaHelper.setXmp(input, output, null, null)
}
}
}
}
// A few bytes are sometimes appended when writing to a document output stream.
// In that case, we need to adjust the trailer video offset accordingly and rewrite the file.
// returns whether the file at `path` is fine
@ -807,8 +908,9 @@ abstract class ImageProvider {
autoCorrectTrailerOffset: Boolean,
callback: ImageOpCallback,
) {
if (modifier.containsKey("exif")) {
val fields = modifier["exif"] as Map<*, *>?
val newFields: FieldMap = hashMapOf()
if (modifier.containsKey(TYPE_EXIF)) {
val fields = modifier[TYPE_EXIF] as Map<*, *>?
if (fields != null && fields.isNotEmpty()) {
if (!editExif(
context = context,
@ -825,7 +927,7 @@ abstract class ImageProvider {
val value = kv.value
if (value == null) {
// remove attribute
exif.setAttribute(tag, value)
exif.setAttribute(tag, null)
} else {
when (tag) {
ExifInterface.TAG_GPS_LATITUDE,
@ -864,8 +966,8 @@ abstract class ImageProvider {
}
}
if (modifier.containsKey("iptc")) {
val iptc = (modifier["iptc"] as List<*>?)?.filterIsInstance<FieldMap>()
if (modifier.containsKey(TYPE_IPTC)) {
val iptc = (modifier[TYPE_IPTC] as List<*>?)?.filterIsInstance<FieldMap>()
if (!editIptc(
context = context,
path = path,
@ -878,8 +980,24 @@ abstract class ImageProvider {
) return
}
if (modifier.containsKey("xmp")) {
val xmp = modifier["xmp"] as Map<*, *>?
if (modifier.containsKey(TYPE_MP4)) {
val fieldsToEdit = modifier[TYPE_MP4] as Map<*, *>?
if (fieldsToEdit != null && fieldsToEdit.isNotEmpty()) {
if (!editMp4Metadata(
context = context,
path = path,
uri = uri,
mimeType = mimeType,
callback = callback,
fieldsToEdit = fieldsToEdit,
newFields = newFields,
)
) return
}
}
if (modifier.containsKey(TYPE_XMP)) {
val xmp = modifier[TYPE_XMP] as Map<*, *>?
if (xmp != null) {
val coreXmp = xmp["xmp"] as String?
val extendedXmp = xmp["extendedXmp"] as String?
@ -897,7 +1015,6 @@ abstract class ImageProvider {
}
}
val newFields: FieldMap = hashMapOf()
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
}
@ -930,7 +1047,8 @@ abstract class ImageProvider {
try {
// copy the edited temporary file back to the original
copyFileTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path)
editableFile.transferTo(outputStream(context, mimeType, uri, path))
editableFile.delete()
} catch (e: IOException) {
callback.onFailure(e)
return
@ -973,11 +1091,12 @@ abstract class ImageProvider {
try {
// copy the edited temporary file back to the original
copyFileTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path)
editableFile.transferTo(outputStream(context, mimeType, uri, path))
if (!types.contains(Metadata.TYPE_XMP) && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
if (!types.contains(TYPE_XMP) && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
return
}
editableFile.delete()
} catch (e: IOException) {
callback.onFailure(e)
return
@ -987,21 +1106,20 @@ abstract class ImageProvider {
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
}
private fun copyFileTo(
private fun outputStream(
context: Context,
mimeType: String,
sourceFile: File,
targetUri: Uri,
targetPath: String
) {
uri: Uri,
path: String
): OutputStream {
// truncate is necessary when overwriting a longer file
val targetStream = if (isMediaUriPermissionGranted(context, targetUri, mimeType)) {
StorageUtils.openOutputStream(context, targetUri, mimeType, "wt") ?: throw Exception("failed to open output stream for uri=$targetUri")
val mode = "wt"
return if (isMediaUriPermissionGranted(context, uri, mimeType)) {
StorageUtils.openOutputStream(context, mimeType, uri, mode) ?: throw Exception("failed to open output stream for uri=$uri")
} else {
val documentUri = StorageUtils.getDocumentFile(context, targetPath, targetUri)?.uri ?: throw Exception("failed to get document file for path=$targetPath, uri=$targetUri")
context.contentResolver.openOutputStream(documentUri, "wt") ?: throw Exception("failed to open output stream from documentUri=$documentUri for path=$targetPath, uri=$targetUri")
val documentUri = StorageUtils.getDocumentFile(context, path, uri)?.uri ?: throw Exception("failed to get document file for path=$path, uri=$uri")
context.contentResolver.openOutputStream(documentUri, mode) ?: throw Exception("failed to open output stream from documentUri=$documentUri for path=$path, uri=$uri")
}
sourceFile.transferTo(targetStream)
}
interface ImageOpCallback {

View file

@ -32,6 +32,7 @@ import java.io.File
import java.io.OutputStream
import java.util.*
import java.util.concurrent.CompletableFuture
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
@ -784,61 +785,82 @@ class MediaStoreImageProvider : ImageProvider() {
}
suspend fun scanNewPath(context: Context, path: String, mimeType: String): FieldMap =
suspendCoroutine { cont ->
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, newUri: Uri? ->
fun scanUri(uri: Uri?): FieldMap? {
uri ?: return null
suspendCoroutine { cont -> tryScanNewPath(context, path = path, mimeType = mimeType, cont) }
// we retrieve updated fields as the renamed/moved file became a new entry in the Media Store
val projection = arrayOf(
MediaStore.MediaColumns.DATE_ADDED,
MediaStore.MediaColumns.DATE_MODIFIED,
)
try {
val cursor = context.contentResolver.query(uri, projection, null, null, null)
if (cursor != null && cursor.moveToFirst()) {
val newFields = HashMap<String, Any?>()
newFields["uri"] = uri.toString()
newFields["contentId"] = uri.tryParseId()
newFields["path"] = path
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED).let { if (it != -1) newFields["dateAddedSecs"] = cursor.getInt(it) }
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) }
cursor.close()
return newFields
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to scan uri=$uri", e)
}
return null
}
if (newUri != null) {
var contentUri: Uri? = null
// `newURI` is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872")
// but we need an image/video media URI (e.g. "content://media/external/images/media/62872")
val contentId = newUri.tryParseId()
if (contentId != null) {
if (isImage(mimeType)) {
contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, contentId)
} else if (isVideo(mimeType)) {
contentUri = ContentUris.withAppendedId(VIDEO_CONTENT_URI, contentId)
}
}
// prefer image/video content URI, fallback to original URI (possibly a file content URI)
val newFields = scanUri(contentUri) ?: scanUri(newUri)
if (newFields != null) {
cont.resume(newFields)
} else {
cont.resumeWithException(Exception("failed to get item details from provider at contentUri=$contentUri (from newUri=$newUri)"))
}
} else {
cont.resumeWithException(Exception("failed to get URI of item at path=$path"))
private fun tryScanNewPath(context: Context, path: String, mimeType: String, cont: Continuation<FieldMap>, iteration: Int = 0) {
// `scanFile` may (e.g. when copying to SD card on Android 10 (API 29)):
// 1) yield no URI,
// 2) yield a temporary URI that fails when queried,
// 3) yield a temporary URI that succeeds when queried right away, but the Media Store actually won't have an entry for it until device reboot.
if (iteration > 5) {
// give up
cont.resumeWithException(Exception("failed to scan new path=$path after $iteration iterations"))
return
} else if (iteration > 0) {
// waiting and retrying just once usually works out for cases 1) and 2)
Thread.sleep(iteration * 100L)
} else if (iteration == 0 && Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
// waiting before the first scan usually works out for case 3)
StorageUtils.getVolumePath(context, path)?.let { volumePath ->
if (volumePath != StorageUtils.getPrimaryVolumePath(context)) {
Thread.sleep(100L)
}
}
}
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, newUri: Uri? ->
fun scanUri(uri: Uri?): FieldMap? {
uri ?: return null
// we retrieve updated fields as the renamed/moved file became a new entry in the Media Store
val projection = arrayOf(
MediaStore.MediaColumns.DATE_ADDED,
MediaStore.MediaColumns.DATE_MODIFIED,
)
try {
val cursor = context.contentResolver.query(uri, projection, null, null, null)
if (cursor != null && cursor.moveToFirst()) {
val newFields = HashMap<String, Any?>()
newFields["uri"] = uri.toString()
newFields["contentId"] = uri.tryParseId()
newFields["path"] = path
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED).let { if (it != -1) newFields["dateAddedSecs"] = cursor.getInt(it) }
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) }
cursor.close()
return newFields
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to scan uri=$uri", e)
}
return null
}
if (newUri != null) {
var contentUri: Uri? = null
// `newURI` is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872")
// but we need an image/video media URI (e.g. "content://media/external/images/media/62872")
val contentId = newUri.tryParseId()
if (contentId != null) {
if (isImage(mimeType)) {
contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, contentId)
} else if (isVideo(mimeType)) {
contentUri = ContentUris.withAppendedId(VIDEO_CONTENT_URI, contentId)
}
}
// prefer image/video content URI, fallback to original URI (possibly a file content URI)
val newFields = scanUri(contentUri) ?: scanUri(newUri)
if (newFields != null) {
cont.resume(newFields)
return@scanFile
}
}
tryScanNewPath(context, path = path, mimeType = mimeType, cont, iteration + 1)
}
}
fun getContentUriForPath(context: Context, path: String): Uri? {
val projection = arrayOf(MediaStore.MediaColumns._ID)
val selection = "${MediaColumns.PATH} = ?"

View file

@ -14,6 +14,9 @@ object BitmapUtils {
private val LOG_TAG = LogUtils.createTag<BitmapUtils>()
private const val INITIAL_BUFFER_SIZE = 2 shl 17 // 256kB
// arbitrary size to detect buffer that may yield an OOM
private const val BUFFER_SIZE_DANGER_THRESHOLD = 3 * (1 shl 20) // MB
private val freeBaos = ArrayList<ByteArrayOutputStream>()
private val mutex = Mutex()
@ -39,6 +42,12 @@ object BitmapUtils {
this.compress(Bitmap.CompressFormat.JPEG, quality, stream)
}
if (recycle) this.recycle()
val bufferSize = stream.size()
if (bufferSize > BUFFER_SIZE_DANGER_THRESHOLD && !MemoryUtils.canAllocate(bufferSize)) {
throw Exception("bitmap compressed to $bufferSize bytes, which cannot be allocated to a new byte array")
}
val byteArray = stream.toByteArray()
stream.reset()
mutex.withLock {
@ -59,7 +68,7 @@ object BitmapUtils {
}
fun centerSquareCrop(context: Context, bitmap: Bitmap?, size: Int): Bitmap? {
bitmap ?: return bitmap
bitmap ?: return null
return TransformationUtils.centerCrop(getBitmapPool(context), bitmap, size, size)
}

View file

@ -24,7 +24,7 @@ object ContextUtils {
}
fun Context.isMyServiceRunning(serviceClass: Class<out Service>): Boolean {
val am = this.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager?
val am = this.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
am ?: return false
@Suppress("deprecation")
return am.getRunningServices(Integer.MAX_VALUE).any { it.service.className == serviceClass.name }

View file

@ -0,0 +1,17 @@
package deckers.thibault.aves.utils
import android.util.Log
object MemoryUtils {
private val LOG_TAG = LogUtils.createTag<MemoryUtils>()
fun canAllocate(byteSize: Number?): Boolean {
byteSize ?: return true
val availableHeapSize = Runtime.getRuntime().let { it.maxMemory() - (it.totalMemory() - it.freeMemory()) }
val danger = byteSize.toLong() > availableHeapSize
if (danger) {
Log.e(LOG_TAG, "trying to handle $byteSize bytes, with only $availableHeapSize free bytes")
}
return !danger
}
}

View file

@ -104,28 +104,28 @@ object MimeTypes {
else -> false
}
// as of androidx.exifinterface:exifinterface:1.3.4
fun canEditExif(mimeType: String) = when (mimeType) {
JPEG,
PNG,
WEBP -> true
// as of androidx.exifinterface:exifinterface:1.3.4
JPEG, PNG, WEBP -> true
else -> false
}
// as of latest PixyMeta
fun canEditIptc(mimeType: String) = when (mimeType) {
// as of latest PixyMeta
JPEG, TIFF -> true
else -> false
}
// as of latest PixyMeta
fun canEditXmp(mimeType: String) = when (mimeType) {
// as of latest PixyMeta
JPEG, TIFF, PNG, GIF -> true
// using `mp4parser`
MP4 -> true
else -> false
}
// as of latest PixyMeta
fun canRemoveMetadata(mimeType: String) = when (mimeType) {
// as of latest PixyMeta
JPEG, TIFF -> true
else -> false
}

View file

@ -10,6 +10,7 @@ import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.ParcelFileDescriptor
import android.os.storage.StorageManager
import android.provider.DocumentsContract
import android.provider.MediaStore
@ -17,6 +18,7 @@ import android.text.TextUtils
import android.util.Log
import androidx.annotation.RequiresApi
import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.model.provider.ImageProvider
import deckers.thibault.aves.utils.FileUtils.transferFrom
import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isVideo
@ -580,19 +582,47 @@ object StorageUtils {
} catch (e: Exception) {
// among various other exceptions,
// opening a file marked pending and owned by another package throws an `IllegalStateException`
Log.w(LOG_TAG, "failed to open input stream for uri=$uri effectiveUri=$effectiveUri", e)
Log.w(LOG_TAG, "failed to open input stream from effectiveUri=$effectiveUri for uri=$uri", e)
null
}
}
fun openOutputStream(context: Context, uri: Uri, mimeType: String, mode: String): OutputStream? {
fun openOutputStream(context: Context, mimeType: String, uri: Uri, mode: String): OutputStream? {
val effectiveUri = getMediaStoreScopedStorageSafeUri(uri, mimeType)
return try {
context.contentResolver.openOutputStream(effectiveUri, mode)
} catch (e: Exception) {
// among various other exceptions,
// opening a file marked pending and owned by another package throws an `IllegalStateException`
Log.w(LOG_TAG, "failed to open output stream for uri=$uri effectiveUri=$effectiveUri mode=$mode", e)
Log.w(LOG_TAG, "failed to open output stream from effectiveUri=$effectiveUri for uri=$uri mode=$mode", e)
null
}
}
fun openInputFileDescriptor(context: Context, uri: Uri): ParcelFileDescriptor? {
val effectiveUri = getOriginalUri(context, uri)
return try {
context.contentResolver.openFileDescriptor(effectiveUri, "r")
} catch (e: Exception) {
// among various other exceptions,
// opening a file marked pending and owned by another package throws an `IllegalStateException`
Log.w(LOG_TAG, "failed to open input file descriptor from effectiveUri=$effectiveUri for uri=$uri", e)
null
}
}
fun openOutputFileDescriptor(context: Context, mimeType: String, uri: Uri, path: String, mode: String): ParcelFileDescriptor? {
val effectiveUri = if (ImageProvider.isMediaUriPermissionGranted(context, uri, mimeType)) {
getMediaStoreScopedStorageSafeUri(uri, mimeType)
} else {
getDocumentFile(context, path, uri)?.uri ?: throw Exception("failed to get document file for path=$path, uri=$uri")
}
return try {
context.contentResolver.openFileDescriptor(effectiveUri, mode)
} catch (e: Exception) {
// among various other exceptions,
// opening a file marked pending and owned by another package throws an `IllegalStateException`
Log.w(LOG_TAG, "failed to open output file descriptor from effectiveUri=$effectiveUri for uri=$uri path=$path", e)
null
}
}

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="app_widget_label">Bilderrahmen</string>

View file

@ -4,7 +4,7 @@
<string name="app_widget_label">Marco de foto</string>
<string name="wallpaper">Fondo de pantalla</string>
<string name="search_shortcut_short_label">Búsqueda</string>
<string name="videos_shortcut_short_label">Videos</string>
<string name="videos_shortcut_short_label">Vídeos</string>
<string name="analysis_channel_name">Explorar medios</string>
<string name="analysis_service_description">Explorar imágenes &amp; videos</string>
<string name="analysis_notification_default_title">Explorando medios</string>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="videos_shortcut_short_label">ویدئو ها</string>
<string name="analysis_channel_name">کنکاش رسانه</string>
<string name="analysis_service_description">کنکاش تصاویر و ویدئو ها</string>
<string name="search_shortcut_short_label">جستجو</string>
<string name="wallpaper">کاغذدیواری</string>
<string name="analysis_notification_default_title">در حال کنکاش رسانه‌ها</string>
<string name="analysis_notification_action_stop">توقف کردن</string>
<string name="app_widget_label">قاب عکس</string>
<string name="app_name">Aves</string>
</resources>

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="app_widget_label">Cadre photo</string>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="app_widget_label">Marco de fotos</string>
<string name="wallpaper">Fondo da pantalla</string>
<string name="search_shortcut_short_label">Procura</string>
<string name="videos_shortcut_short_label">Vídeos</string>
<string name="analysis_channel_name">Escaneo multimedia</string>
<string name="analysis_service_description">Escanealas imaxes e os vídeos</string>
<string name="analysis_notification_default_title">Escaneando medios</string>
<string name="analysis_notification_action_stop">Pare</string>
</resources>

View file

@ -9,4 +9,4 @@
<string name="analysis_service_description">Scansione immagini &amp; videos</string>
<string name="analysis_notification_default_title">Scansione in corso</string>
<string name="analysis_notification_action_stop">Annulla</string>
</resources>
</resources>

View file

@ -1,12 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="app_widget_label">フォトフレーム</string>
<string name="wallpaper">壁紙</string>
<string name="search_shortcut_short_label">検索</string>
<string name="videos_shortcut_short_label">動画</string>
<string name="analysis_channel_name">メディアスキャン</string>
<string name="analysis_service_description">画像と動画をスキャン</string>
<string name="analysis_notification_default_title">メディアをスキャン中</string>
<string name="analysis_notification_action_stop">停止</string>
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="app_widget_label">フォトフレーム</string>
<string name="wallpaper">壁紙</string>
<string name="search_shortcut_short_label">検索</string>
<string name="videos_shortcut_short_label">動画</string>
<string name="analysis_channel_name">メディアスキャン</string>
<string name="analysis_service_description">画像と動画をスキャン</string>
<string name="analysis_notification_default_title">メディアをスキャン中</string>
<string name="analysis_notification_action_stop">停止</string>
</resources>

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">아베스</string>
<string name="app_widget_label">사진 액자</string>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="videos_shortcut_short_label">Videoer</string>
<string name="analysis_channel_name">Mediaskanning</string>
<string name="analysis_service_description">Skann bilder og videoer</string>
<string name="analysis_notification_default_title">Skanning av media</string>
<string name="app_widget_label">Bilderamme</string>
<string name="wallpaper">Bakgrunnsbilde</string>
<string name="search_shortcut_short_label">Søk</string>
<string name="analysis_notification_action_stop">Stopp</string>
</resources>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_widget_label">Ramka Zdjęcia</string>
<string name="search_shortcut_short_label">Szukaj</string>
<string name="videos_shortcut_short_label">Filmy</string>
<string name="analysis_channel_name">Skan mediów</string>
<string name="analysis_service_description">Skan obrazów &amp; filmów</string>
<string name="analysis_notification_default_title">Skanowanie mediów</string>
<string name="analysis_notification_action_stop">Zatrzymaj</string>
<string name="app_name">Aves</string>
<string name="wallpaper">Tapeta</string>
</resources>

View file

@ -9,4 +9,4 @@
<string name="analysis_service_description">Digitalizar imagens &amp; vídeos</string>
<string name="analysis_notification_default_title">Digitalizando mídia</string>
<string name="analysis_notification_action_stop">Pare</string>
</resources>
</resources>

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="app_widget_label">Фоторамка</string>

View file

@ -9,4 +9,4 @@
<string name="analysis_service_description">扫描图像 &amp; 视频</string>
<string name="analysis_notification_default_title">正在扫描媒体库</string>
<string name="analysis_notification_action_stop">停止</string>
</resources>
</resources>

View file

@ -7,7 +7,8 @@ buildscript {
maven { url 'https://developer.huawei.com/repo/' }
}
dependencies {
classpath 'com.android.tools.build:gradle:7.3.0'
// TODO TLAD upgrade Android Gradle plugin >=7.3 when this is fixed: https://github.com/flutter/flutter/issues/115100
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.14'

View file

@ -1 +1 @@
Συλλογή φωτογραφιών και εξερεύνηση των μεταδεδομένων.
Συλλογή φωτογραφιών και εξερεύνηση των μεταδεδομένων

View file

@ -0,0 +1,5 @@
In v1.7.2:
- tag your MP4, rate your MP4, date your MP4, locate your MP4, rotate your MP4
- give media management access (on Android 12+) to skip some confirmation dialogs
- enjoy higher quality thumbnails
Full changelog available on GitHub

View file

@ -1,5 +1,5 @@
<i>Aves</i> puede manejar todo tipo de imágenes y videos, incluyendo los típicos JPEG y MP4, pero además cosas mas exóticas como <b>TIFF multipágina, SVG, viejos AVI y más</b>! Inspecciona su colección multimedia para identificar <b>fotos en movimiento</b>, <b>panoramas</b> (conocidas como fotos esféricas), <b>videos en 360°</b> y también archivos <b>GeoTIFF</b>.
La <b>navegación y búsqueda</b> son partes importantes de <i>Aves</i>. Su propósito es que los usuarios puedan fácimente ir de álbumes a fotos, etiquetas, mapas, etc.
La <b>navegación y búsqueda</b> son las partes más importantes de <i>Aves</i>. Su propósito es que los usuarios puedan fácilmente ir de álbumes a fotos, etiquetas, mapas, etc.
<i>Aves</i> se integra con Android (desde <b>API 19 a 33</b>, por ej. desde KitKat hasta Android 13) con características como <b>vínculos de aplicación</b> y manejo de <b>búsqueda global</b>. También funciona como un <b>visor y seleccionador multimedia</b>.
<i>Aves</i> se integra con Android (desde <b>API 19 a 33</b>, es decir, desde KitKat a Android 13) con funciones como <b>widgets</b>, <b>accesos directos a aplicaciones </b>, manejo de <b>salvapantallas</b> y <b>búsqueda global</b>. También funciona como un <b>visor y selector de medios</b>.

View file

@ -0,0 +1,5 @@
<i>Aves</i> can handle all sorts of images and videos, including your typical JPEGs and MP4s, but also more exotic things like <b>multi-page TIFFs, SVGs, old AVIs and more</b>! It scans your media collection to identify <b>motion photos</b>, <b>panoramas</b> (aka photo spheres), <b>360° videos</b>, as well as <b>GeoTIFF</b> files.
<b>Navigation and search</b> is an important part of <i>Aves</i>. The goal is for users to easily flow from albums to photos to tags to maps, etc.
<i>Aves</i> integrates with Android (from <b>API 19 to 33</b>, i.e. from KitKat to Android 13) with features such as <b>widgets</b>, <b>app shortcuts</b>, <b>screen saver</b> and <b>global search</b> handling. It also works as a <b>media viewer and picker</b>.

View file

@ -0,0 +1 @@
پویشگر گالری و فراداده

View file

@ -0,0 +1,5 @@
<i>Aves</i> supporte toutes sortes dimages et de vidéos comme les JPEG et les MP4 typiques, mais aussi des formats plus exotiques comme <b>les TIFF multipages, les SVG, les vieux AVI et bien plus encore</b>! Votre collection multimédia est analysée pour identifier les <b>photos animées</b>, les <b>panoramas</b> (alias sphères photo), les <b>vidéos à 360°</b> ainsi que les fichiers <b>GeoTIFF</b>.
<b>La navigation et la recherche</b> sont une partie importante d<i>Aves</i>. Le but est que les utilisateurs puissent passer facilement des albums aux photos, des photos aux tags, des tags aux cartes, etc.
<i>Aves</i> sintègre avec Android (de l<b>API 19 à 33</b>, cest-à-dire de KitKat à Android 13) avec des fonctionnalités telles que les <b>widgets</b>, les <b>raccourcis dapplication</b>, <b>économiseur décran</b> et la <b>recherche globale</b>. Il est également possible de lutiliser comme <b>visionneuse et sélecteur de médias</b>.

View file

@ -0,0 +1 @@
Galerie et explorateur de métadonnées

View file

@ -0,0 +1,5 @@
<i>Aves</i> pode xestionar todo tipo de imaxes e vídeos, incluídos os teus JPEG e MP4 típicos, pero tamén cousas máis exóticas como <b>TIFF de varias páxinas, SVG, AVI antigos e moito máis</b>. Analiza a túa colección multimedia para identificar <b>fotos en movemento</b>, <b>panoramas</b> (tamén coñecidos como fotografías esféricas), <b>vídeos de 360°</b>, así como <b>GeoTIFF</b> ficheiros.
A <b>navegación e busca</b> é unha parte importante de <i>Aves</i>. O obxectivo é que os usuarios poidan fluír facilmente de álbums a fotos, a etiquetas a mapas, etc.
<i>Aves</i> intégrase con Android (de <b>API 19 a 33</b>, é dicir, de KitKat a Android 13) con funcións como <b>widgets</b>, <b>atallos de aplicacións </b>, <b>salvapantallas</b> e manexo da <b>busca global</b>. Tamén funciona como <b>visor e selector de medios</b>.

View file

@ -0,0 +1 @@
Galería e explorador de metadatos

View file

@ -1,5 +1,5 @@
<i>Aves</i> può gestire tutti i tipi di immagini e video, compresi i tipici JPEG e MP4, ma anche cose più esotiche come <b>TIFF multipagina, SVG, vecchi AVI e molto di più</b>! Scansiona la tua collezione di media per identificare <b>foto in movimento</b>, <b>panorami</b> (le foto sferiche), <b>video a 360°</b>, così come i file <b>GeoTIFF</b>.
<i>Aves</i> può gestire tutti i tipi di immagini e video, compresi i tipici JPEG e MP4, ma anche cose più esotiche come <b>TIFF multipagina, SVG, vecchi AVI e molto di più</b>! Scansiona la tua collezione di media per identificare <b>foto in movimento</b>, <b>panorami</b> (le foto sferiche), <b>video a 360°</b>, così come i file <b>GeoTIFF</b>.
<b>Navigazione e ricerca</b> sono una parte importante di <i>Aves</i>. L'obiettivo è che gli utenti passino facilmente dagli album alle foto, ai tag, alle mappe, ecc.
<i>Aves</i> si integra con Android (da <b>API 19 a 33</b>, cioè da KitKat ad Android 13) con caratteristiche come <b>collegamenti alle app</b> e la gestione della <b>ricerca globale</b>. Funziona anche come <b>visualizzazione e raccolta di media</b>.
<i>Aves</i> si integra con Android (da <b>API 19 a 33</b>, cioè da KitKat ad Android 13) con caratteristiche come <b>collegamenti alle app</b> e la gestione della <b>ricerca globale</b>. Funziona anche come <b>visualizzazione e raccolta di media</b>.

View file

@ -1 +1 @@
Galleria e esploratore di metadati
Galleria e esploratore di metadati

View file

@ -1,7 +1,5 @@
<i>Aves</i>はあらゆる画像や動画を扱うことができ、一般的なJPEGやMP4はもちろん、 <b>マルチページTIFF、SVG、古いAVIなどの珍しい形式にも対応</b>しています!
<i>Aves</i>はあらゆる画像や動画を扱うことができ、一般的なJPEGやMP4はもちろん、 <b>マルチページTIFF、SVG、古いAVIなどの珍しい形式にも対応</b>しています! メディアコレクションをスキャンして、<b>モーションフォト</b>、<b>パノラマ</b>Photo Sphere、<b>360°動画</b>、<b>GeoTIFF</b>ファイルなどを識別します。
メディアコレクションをスキャンして、<b>モーションフォト</b>、<b>パノラマ</b>Photo Sphere、<b>360°動画</b>、<b>GeoTIFF</b>ファイルなどを識別します。
<b>ナビゲーションと検索</b>は、Avesの重要な部分です。アルバムから写真、タグ、地図などへ簡単に移動できます。
<b>ナビゲーションと検索</b>は、<i>Aves</i>の重要な部分です。アルバムから写真、タグ、地図などへ簡単に移動できます。
<i>Aves</i>は、<b>アプリショートカット</b>や<b>グローバル検索</b>などの機能を、Android<b>API 19から33まで</b>、つまりAndroid 4.4から13 Lまでと統合しています。また、<b>メディアビューワー</b>や<b>メディアピッカー</b>としても機能します。

View file

@ -0,0 +1,5 @@
<i>Aves</i> can handle all sorts of images and videos, including your typical JPEGs and MP4s, but also more exotic things like <b>multi-page TIFFs, SVGs, old AVIs and more</b>! It scans your media collection to identify <b>motion photos</b>, <b>panoramas</b> (aka photo spheres), <b>360° videos</b>, as well as <b>GeoTIFF</b> files.
<b>Navigation and search</b> is an important part of <i>Aves</i>. The goal is for users to easily flow from albums to photos to tags to maps, etc.
<i>Aves</i> integrates with Android (from <b>API 19 to 33</b>, i.e. from KitKat to Android 13) with features such as <b>widgets</b>, <b>app shortcuts</b>, <b>screen saver</b> and <b>global search</b> handling. It also works as a <b>media viewer and picker</b>.

View file

@ -0,0 +1 @@
Gallery and metadata explorer

View file

@ -0,0 +1,5 @@
<i>Aves</i> kan håndtere alle slags bilder og videoer, inkludert JPEG og MP4, men også mer eksotiske formater som <b>TIFF-filer over flere sider, SVG-er, gamle AVI-er, med mer</b>! Det skanner mediasamlingen din for å identifisere <b>bevegelige bilder</b>, <b>panoramaer</b> (også kjent som bildesirkler), <b>360°-videoer</b>, og også <b>GeoTIFF</b>-filer.
<b>Navigasjon og søk</b> er en viktig del av <i>Aves</i>. Målet er at brukere enkelt skal kunne ta seg fra album, til bilder, til etiketter, til kart, osv.
<i>Aves</i> integrerer seg med Android (fra <b>API 19 til 33</b>, altså fra KitKat til Android 13) med funksjoner som f.eks. <b>miniprogrammer</b>, <b>programsnarveier</b>, <b>skjermsparer</b> og <b>søk i hele programmet</b>. Det fungerer også som <b>mediaviser og utvelger</b>.

View file

@ -0,0 +1 @@
Galleri- og metadatautforsker

View file

@ -0,0 +1,5 @@
<i>Aves</i> can handle all sorts of images and videos, including your typical JPEGs and MP4s, but also more exotic things like <b>multi-page TIFFs, SVGs, old AVIs and more</b>! It scans your media collection to identify <b>motion photos</b>, <b>panoramas</b> (aka photo spheres), <b>360° videos</b>, as well as <b>GeoTIFF</b> files.
<b>Navigation and search</b> is an important part of <i>Aves</i>. The goal is for users to easily flow from albums to photos to tags to maps, etc.
<i>Aves</i> integrates with Android (from <b>API 19 to 33</b>, i.e. from KitKat to Android 13) with features such as <b>widgets</b>, <b>app shortcuts</b>, <b>screen saver</b> and <b>global search</b> handling. It also works as a <b>media viewer and picker</b>.

View file

@ -0,0 +1 @@
Galerij en metagegevensverkenner

View file

@ -0,0 +1,5 @@
<i>Aves</i> obsługuje wszelkiego rodzaju obrazy i filmy, w tym typowe pliki JPEG i MP4 ale także bardziej egzotyczne formaty takie jak <b>wielostronnicowe pliki TIFF, SVG, stare pliki AVI i wiele więcej</b>! Skanuje twoją kolekcję multimediów aby zidentyfikować <b>ruchome zdjęcia</b>, <b>panoramy</b> (inaczej zdjęcia sferyczne), <b>filmy 360°</b>, a także pliki <b>GeoTIFF</b>.
<b>Nawigacja i wyszukiwanie</b> jest ważną częścią <i>Aves</i>. Celem jest aby użytkownicy mogli łatwo przechodzić od albumów do zdjęć, tagów, map itd.
<i>Aves</i> integruje się z Androidem (od <b>API 19 do 33</b>, czyli od KitKata do Androida 13) z funkcjami takimi jak <b>widżety</b>, <b>skróty do aplikacji</b>, <b>wygaszacz ekranu</b> i <b>obsługa globalnego wyszukiwania</b>. Działa również jako <b>przeglądarka i selektor mediów</b>.

View file

@ -0,0 +1 @@
Galeria i przeglądarka metadanych

View file

@ -2,4 +2,4 @@
<b>Navegação e pesquisa</b> é uma parte importante do <i>Aves</i>. O objetivo é que os usuários fluam facilmente de álbuns para fotos, etiquetas, mapas, etc.
<i>Aves</i> integra com Android (de <b>API 19 para 33</b>, i.e. de KitKat para Android 13) com recursos como <b>atalhos de apps</b> e <b>pesquisa global</b> manipulação. Também funciona como um <b>visualizador e selecionador de mídia</b>.
<i>Aves</i> integra com Android (de <b>API 19 para 33</b>, i.e. de KitKat para Android 13) com recursos como <b>atalhos de apps</b> e <b>pesquisa global</b> manipulação. Também funciona como um <b>visualizador e selecionador de mídia</b>.

View file

@ -1 +1 @@
Galeria e explorador de metadados
Galeria e explorador de metadados

View file

@ -0,0 +1,5 @@
<i>Aves</i> умеет работать со всеми типами изображений и видео, начиная от обычных JPEG и MP4, заканчивая такими экзотическими штуками как <b>многостраничные TIFF, SVG, старые AVI и многими другими</b>! Он сканирует вашу медиа коллекцию, определяя <b>моушн фото</b>, <b>панорамы</b> (aka фото сферы), <b>360° видео</b>, <b>GeoTIFF</b> файлы.
<b>Навигация и поиск</b> важные части <i>Aves</i>. Пользователи могут легко переходить от альбомов к фотографиям, тэгам, картам и т.д.
<i>Aves</i> интегрируется с Android (начиная с версии <b>API 19 до 33</b>, т.е. от KitKat до Android 13) предлагая такие возможности как <b>виджеты</b>, <b>пользовательские ярлыки</b>, <b>скринсейвер</b> и поддержку <b>глобального поиска</b>. Он так же работает как диалоговое окно для <b>просмотра и выбора медиа</b>.

View file

@ -0,0 +1 @@
Медиа галерея с навигацией по метаданным

View file

@ -2,4 +2,4 @@
<b>Gezinme ve arama</b> <i>Aves'in</i> önemli bir parçasıdır. Amaç, kullanıcıların albümlerden fotoğraflara, etiketlerden haritalara vb. kolayca geçmesini sağlamaktır.
<i>Aves</i>, <b>uygulama kısayolları</b> ve <b>global arama<b> işleme gibi özelliklerle Android (<b>API 19'dan 33'ye</b>, yani KitKat'tan Android 13'ye kadar) ile entegre olur. Ayrıca bir <b>medya görüntüleyici ve alıcı</b> olarak da çalışır.
<i>Aves</i>, <b>uygulama kısayolları</b> ve <b>global arama</b> işleme gibi özelliklerle Android (<b>API 19'dan 33'ye</b>, yani KitKat'tan Android 13'ye kadar) ile entegre olur. Ayrıca bir <b>medya görüntüleyici ve alıcı</b> olarak da çalışır.

View file

@ -2,4 +2,4 @@
<b>导航与搜索</b>是 <i>Aves</i> 的核心功能之一,旨在帮助用户在相册、照片、标签、地图等之间轻松切换。
<i> Aves</i> 与 Android<b>API 19-33</b>,即从 KitKat 到 Android 13集成具有<b>快捷方式</b>和<b>全局搜索</b>等功能。它还可用作<b>媒体查看器和选择器<b>。
<i>Aves</i> 与 Android<b>API 19-33</b>,即从 KitKat 到 Android 13集成具有<b>快捷方式</b>和<b>全局搜索</b>等功能。它还可用作<b>媒体查看器和选择器</b>。

View file

@ -1 +1 @@
相册和元数据浏览器
相册和元数据浏览器

View file

@ -1,4 +1,6 @@
# cf guide: http://flutter.dev/go/i18n-user-guide
# cf guides:
# http://flutter.dev/go/i18n-user-guide
# https://docs.flutter.dev/development/accessibility-and-localization/internationalization
# use defaults to:
# - parse ARB files from `lib/l10n`

View file

@ -42,6 +42,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
key.region,
key.imageSize,
pageId: pageId,
sizeBytes: key.sizeBytes,
taskKey: key,
);
if (bytes.isEmpty) {
@ -70,7 +71,7 @@ class RegionProviderKey extends Equatable {
// do not store the entry as it is, because the key should be constant
// but the entry attributes may change over time
final String uri, mimeType;
final int? pageId;
final int? pageId, sizeBytes;
final int rotationDegrees, sampleSize;
final bool isFlipped;
final Rectangle<int> region;
@ -83,13 +84,11 @@ class RegionProviderKey extends Equatable {
required this.uri,
required this.mimeType,
required this.pageId,
required this.sizeBytes,
required this.rotationDegrees,
required this.isFlipped,
required this.sampleSize,
required this.region,
required this.imageSize,
});
@override
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, pageId=$pageId, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, region=$region, imageSize=$imageSize}';
}

View file

@ -52,8 +52,8 @@ class UriImage extends ImageProvider<UriImage> with EquatableMixin {
final bytes = await mediaFetchService.getImage(
uri,
mimeType,
rotationDegrees,
isFlipped,
rotationDegrees: rotationDegrees,
isFlipped: isFlipped,
pageId: pageId,
sizeBytes: sizeBytes,
onBytesReceived: (cumulative, total) {
@ -76,7 +76,4 @@ class UriImage extends ImageProvider<UriImage> with EquatableMixin {
unawaited(chunkEvents.close());
}
}
@override
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, pageId=$pageId, scale=$scale}';
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -216,6 +216,7 @@
"wallpaperTargetHomeLock": "Home and lock screens",
"widgetOpenPageHome": "Open home",
"widgetOpenPageCollection": "Open collection",
"widgetOpenPageViewer": "Open viewer",
"albumTierNew": "New",
@ -373,20 +374,23 @@
"renameEntryDialogLabel": "New name",
"editEntryDialogCopyFromItem": "Copy from other item",
"editEntryDialogTargetFieldsHeader": "Fields to modify",
"editEntryDateDialogTitle": "Date & Time",
"editEntryDateDialogSetCustom": "Set custom date",
"editEntryDateDialogCopyField": "Copy from other date",
"editEntryDateDialogCopyItem": "Copy from other item",
"editEntryDateDialogExtractFromTitle": "Extract from title",
"editEntryDateDialogShift": "Shift",
"editEntryDateDialogSourceFileModifiedDate": "File modified date",
"editEntryDateDialogHours": "Hours",
"editEntryDateDialogMinutes": "Minutes",
"durationDialogHours": "Hours",
"durationDialogMinutes": "Minutes",
"durationDialogSeconds": "Seconds",
"editEntryLocationDialogTitle": "Location",
"editEntryLocationDialogChooseOnMapTooltip": "Choose on map",
"editEntryLocationDialogSetCustom": "Set custom location",
"editEntryLocationDialogChooseOnMap": "Choose on map",
"editEntryLocationDialogLatitude": "Latitude",
"editEntryLocationDialogLongitude": "Longitude",
@ -437,7 +441,6 @@
"appPickDialogNone": "None",
"aboutPageTitle": "About",
"aboutLinkSources": "Sources",
"aboutLinkLicense": "License",
"aboutLinkPolicy": "Privacy Policy",
@ -757,6 +760,7 @@
"settingsSaveSearchHistory": "Save search history",
"settingsEnableBin": "Use recycle bin",
"settingsEnableBinSubtitle": "Keep deleted items for 30 days",
"settingsAllowMediaManagement": "Allow media management",
"settingsHiddenItemsTile": "Hidden items",
"settingsHiddenItemsPageTitle": "Hidden Items",
@ -865,6 +869,10 @@
"tagEditorPageNewTagFieldLabel": "New tag",
"tagEditorPageAddTagTooltip": "Add tag",
"tagEditorSectionRecent": "Recent",
"tagEditorSectionPlaceholders": "Placeholders",
"tagPlaceholderCountry": "Country",
"tagPlaceholderPlace": "Place",
"panoramaEnableSensorControl": "Enable sensor control",
"panoramaDisableSensorControl": "Disable sensor control",

File diff suppressed because it is too large Load diff

1
lib/l10n/app_fa.arb Normal file
View file

@ -0,0 +1 @@
{}

File diff suppressed because it is too large Load diff

302
lib/l10n/app_gl.arb Normal file
View file

@ -0,0 +1,302 @@
{
"entryActionConvert": "Converter",
"@entryActionConvert": {},
"entryActionExport": "Exportar",
"@entryActionExport": {},
"entryActionRotateCCW": "Xire no sentido antihorario",
"@entryActionRotateCCW": {},
"entryActionInfo": "Información",
"@entryActionInfo": {},
"entryActionRename": "Cambialo nome",
"@entryActionRename": {},
"chipActionCreateAlbum": "Crear álbum",
"@chipActionCreateAlbum": {},
"entryActionDelete": "Eliminar",
"@entryActionDelete": {},
"entryActionCopyToClipboard": "Copiar ao portapapeis",
"@entryActionCopyToClipboard": {},
"entryActionRestore": "Restaurar",
"@entryActionRestore": {},
"entryActionRotateCW": "Xira no sentido das agullas do reloxo",
"@entryActionRotateCW": {},
"entryActionFlip": "Xire horizontalmente",
"@entryActionFlip": {},
"entryActionPrint": "Imprimir",
"@entryActionPrint": {},
"entryActionViewSource": "Ver fonte",
"@entryActionViewSource": {},
"entryActionShare": "Compartir",
"@entryActionShare": {},
"entryActionShowGeoTiffOnMap": "Mostrar como superposición de mapa",
"@entryActionShowGeoTiffOnMap": {},
"filterNoTitleLabel": "Sen título",
"@filterNoTitleLabel": {},
"entryActionConvertMotionPhotoToStillImage": "Converter a imaxe fixa",
"@entryActionConvertMotionPhotoToStillImage": {},
"entryActionViewMotionPhotoVideo": "Abrir vídeo",
"@entryActionViewMotionPhotoVideo": {},
"entryActionEdit": "Editar",
"@entryActionEdit": {},
"entryInfoActionEditLocation": "Editar localización",
"@entryInfoActionEditLocation": {},
"entryInfoActionEditTags": "Editar etiquetas",
"@entryInfoActionEditTags": {},
"filterRecentlyAddedLabel": "Engadida recentemente",
"@filterRecentlyAddedLabel": {},
"filterTypeSphericalVideoLabel": "Vídeo 360°",
"@filterTypeSphericalVideoLabel": {},
"filterTypeMotionPhotoLabel": "Foto en movemento",
"@filterTypeMotionPhotoLabel": {},
"filterMimeImageLabel": "Imaxe",
"@filterMimeImageLabel": {},
"welcomeOptional": "Opcional",
"@welcomeOptional": {},
"welcomeTermsToggle": "Acepto os termos e condicións",
"@welcomeTermsToggle": {},
"nextButtonLabel": "SEGUINTE",
"@nextButtonLabel": {},
"showButtonLabel": "AMOSAR",
"@showButtonLabel": {},
"hideButtonLabel": "OCULTAR",
"@hideButtonLabel": {},
"continueButtonLabel": "PROSEGUIR",
"@continueButtonLabel": {},
"cancelTooltip": "Cancelar",
"@cancelTooltip": {},
"sourceStateLocatingCountries": "Localización dos países",
"@sourceStateLocatingCountries": {},
"sourceStateLocatingPlaces": "Localización dos lugares",
"@sourceStateLocatingPlaces": {},
"chipActionDelete": "Eliminar",
"@chipActionDelete": {},
"chipActionGoToTagPage": "Amosalas etiquetas",
"@chipActionGoToTagPage": {},
"chipActionGoToCountryPage": "Amosar en países",
"@chipActionGoToCountryPage": {},
"chipActionGoToAlbumPage": "Amosar en álbums",
"@chipActionGoToAlbumPage": {},
"chipActionFilterOut": "Filtrar",
"@chipActionFilterOut": {},
"chipActionFilterIn": "Filtrar",
"@chipActionFilterIn": {},
"chipActionHide": "Agochar",
"@chipActionHide": {},
"chipActionPin": "Fixar na parte superior",
"@chipActionPin": {},
"chipActionUnpin": "Desbloquear dende arriba",
"@chipActionUnpin": {},
"chipActionRename": "Cambialo nome",
"@chipActionRename": {},
"chipActionSetCover": "Fixala cubierta",
"@chipActionSetCover": {},
"entryActionOpen": "Abrir con",
"@entryActionOpen": {},
"entryActionSetAs": "Definido como",
"@entryActionSetAs": {},
"entryActionOpenMap": "Mostrar na aplicación de mapas",
"@entryActionOpenMap": {},
"entryActionRotateScreen": "Xire a pantalla",
"@entryActionRotateScreen": {},
"entryActionAddFavourite": "Engadir a favoritos",
"@entryActionAddFavourite": {},
"entryActionRemoveFavourite": "Eliminar dos favoritos",
"@entryActionRemoveFavourite": {},
"videoActionCaptureFrame": "Cadro de captura",
"@videoActionCaptureFrame": {},
"videoActionMute": "Silenciar",
"@videoActionMute": {},
"videoActionUnmute": "Activalo silencio",
"@videoActionUnmute": {},
"videoActionPause": "Pausa",
"@videoActionPause": {},
"videoActionPlay": "Reproducir",
"@videoActionPlay": {},
"videoActionReplay10": "Busca atrás 10 segundos",
"@videoActionReplay10": {},
"videoActionSkip10": "Busca adiante 10 segundos",
"@videoActionSkip10": {},
"videoActionSelectStreams": "Seleccionalas pistas",
"@videoActionSelectStreams": {},
"videoActionSetSpeed": "Velocidade de reprodución",
"@videoActionSetSpeed": {},
"videoActionSettings": "Configuración",
"@videoActionSettings": {},
"slideshowActionResume": "Resumo",
"@slideshowActionResume": {},
"slideshowActionShowInCollection": "Mostrar na colección",
"@slideshowActionShowInCollection": {},
"entryInfoActionEditDate": "Editar data e hora",
"@entryInfoActionEditDate": {},
"entryInfoActionEditTitleDescription": "Editar título e descrición",
"@entryInfoActionEditTitleDescription": {},
"entryInfoActionEditRating": "Editar valoración",
"@entryInfoActionEditRating": {},
"entryInfoActionRemoveMetadata": "Eliminar metadatos",
"@entryInfoActionRemoveMetadata": {},
"filterBinLabel": "Papeleira de reciclaxe",
"@filterBinLabel": {},
"filterFavouriteLabel": "Favorito",
"@filterFavouriteLabel": {},
"filterNoDateLabel": "Sen data",
"@filterNoDateLabel": {},
"filterNoLocationLabel": "Sen localizar",
"@filterNoLocationLabel": {},
"filterNoRatingLabel": "Sen clasificar",
"@filterNoRatingLabel": {},
"filterNoTagLabel": "Sen etiquetar",
"@filterNoTagLabel": {},
"filterOnThisDayLabel": "Neste día",
"@filterOnThisDayLabel": {},
"filterRatingRejectedLabel": "Rexeitado",
"@filterRatingRejectedLabel": {},
"filterTypeAnimatedLabel": "Animado",
"@filterTypeAnimatedLabel": {},
"filterTypePanoramaLabel": "Panorámica",
"@filterTypePanoramaLabel": {},
"filterTypeRawLabel": "Raw (Formato do ficheiro)",
"@filterTypeRawLabel": {},
"filterTypeGeotiffLabel": "GeoTIFF (Formato de ficheiro)",
"@filterTypeGeotiffLabel": {},
"filterMimeVideoLabel": "Vídeo",
"@filterMimeVideoLabel": {},
"mapStyleStamenToner": "Stamen Maps - Stamen Design",
"@mapStyleStamenToner": {},
"mapStyleStamenWatercolor": "Stamen Watercolor (con sombreamento e cores)",
"@mapStyleStamenWatercolor": {},
"nameConflictStrategyRename": "Renomear",
"@nameConflictStrategyRename": {},
"nameConflictStrategyReplace": "Trocar",
"@nameConflictStrategyReplace": {},
"nameConflictStrategySkip": "Saltar",
"@nameConflictStrategySkip": {},
"keepScreenOnNever": "Nunca",
"@keepScreenOnNever": {},
"keepScreenOnViewerOnly": "Páxina da visualización só",
"@keepScreenOnViewerOnly": {},
"keepScreenOnAlways": "Sempre",
"@keepScreenOnAlways": {},
"appName": "Aves",
"@appName": {},
"welcomeMessage": "Benvido a Aves",
"@welcomeMessage": {},
"itemCount": "{count, plural, =1{1 elementos} other{{count} elementos}}",
"@itemCount": {
"placeholders": {
"count": {}
}
},
"timeSeconds": "{seconds, plural, =1{1 segundo} other{{seconds} segundos}}",
"@timeSeconds": {
"placeholders": {
"seconds": {}
}
},
"timeMinutes": "{minutes, plural, =1{1 minuto} other{{minutes} minutos}}",
"@timeMinutes": {
"placeholders": {
"minutes": {}
}
},
"timeDays": "{days, plural, =1{1 día} other{{days} días}}",
"@timeDays": {
"placeholders": {
"days": {}
}
},
"focalLength": "{length} milímetro",
"@focalLength": {
"placeholders": {
"length": {
"type": "String",
"example": "5.4"
}
}
},
"applyButtonLabel": "APLICAR",
"@applyButtonLabel": {},
"deleteButtonLabel": "ELIMINAR",
"@deleteButtonLabel": {},
"changeTooltip": "Trocar",
"@changeTooltip": {},
"clearTooltip": "Limpar",
"@clearTooltip": {},
"previousTooltip": "Anterior",
"@previousTooltip": {},
"saveTooltip": "Gardar",
"@saveTooltip": {},
"nextTooltip": "Seguinte",
"@nextTooltip": {},
"showTooltip": "Amosar",
"@showTooltip": {},
"hideTooltip": "Agochar",
"@hideTooltip": {},
"resetTooltip": "Restituír",
"@resetTooltip": {},
"doNotAskAgain": "Non volvas preguntar",
"@doNotAskAgain": {},
"actionRemove": "Remover",
"@actionRemove": {},
"pickTooltip": "Escolla",
"@pickTooltip": {},
"sourceStateCataloguing": "Catalogación",
"@sourceStateCataloguing": {},
"doubleBackExitMessage": "Prema \"atrás\" de novo para saír.",
"@doubleBackExitMessage": {},
"sourceStateLoading": "Cargando",
"@sourceStateLoading": {},
"coordinateFormatDms": "DMS (Sistema de xestión documental)",
"@coordinateFormatDms": {},
"coordinateFormatDecimal": "Graos decimais",
"@coordinateFormatDecimal": {},
"coordinateDms": "{coordinate} {direction}",
"@coordinateDms": {
"placeholders": {
"coordinate": {
"type": "String",
"example": "38° 41 47.72″"
},
"direction": {
"type": "String",
"example": "S"
}
}
},
"mapStyleGoogleTerrain": "Google Maps (Terreo)",
"@mapStyleGoogleTerrain": {},
"coordinateDmsNorth": "Norte",
"@coordinateDmsNorth": {},
"coordinateDmsSouth": "Sur",
"@coordinateDmsSouth": {},
"coordinateDmsEast": "Leste",
"@coordinateDmsEast": {},
"coordinateDmsWest": "Oeste",
"@coordinateDmsWest": {},
"unitSystemMetric": "Métrico",
"@unitSystemMetric": {},
"unitSystemImperial": "Imperial",
"@unitSystemImperial": {},
"videoControlsPlay": "Reproducir",
"@videoControlsPlay": {},
"mapStyleHuaweiNormal": "Petal Maps (Huawei)",
"@mapStyleHuaweiNormal": {},
"videoLoopModeNever": "Nunca",
"@videoLoopModeNever": {},
"videoLoopModeAlways": "Sempre",
"@videoLoopModeAlways": {},
"videoControlsPlaySeek": "Reprroduce e busca cara atrás/adelante",
"@videoControlsPlaySeek": {},
"videoControlsPlayOutside": "Abrir con outro xogador",
"@videoControlsPlayOutside": {},
"videoControlsNone": "Ningún",
"@videoControlsNone": {},
"mapStyleGoogleNormal": "Google Maps",
"@mapStyleGoogleNormal": {},
"videoLoopModeShortOnly": "Só vídeos curtos",
"@videoLoopModeShortOnly": {},
"mapStyleGoogleHybrid": "Google Maps (híbrido)",
"@mapStyleGoogleHybrid": {},
"mapStyleHuaweiTerrain": "Petal Maps (Terreo)",
"@mapStyleHuaweiTerrain": {},
"mapStyleOsmHot": "Humanitarian OpenStreetMap Team",
"@mapStyleOsmHot": {}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

989
lib/l10n/app_nb.arb Normal file
View file

@ -0,0 +1,989 @@
{
"welcomeOptional": "Valgfritt",
"@welcomeOptional": {},
"deleteButtonLabel": "Slett",
"@deleteButtonLabel": {},
"focalLength": "{length} mm",
"@focalLength": {
"placeholders": {
"length": {
"type": "String",
"example": "5.4"
}
}
},
"changeTooltip": "Endre",
"@changeTooltip": {},
"clearTooltip": "Tøm",
"@clearTooltip": {},
"previousTooltip": "Forrige",
"@previousTooltip": {},
"nextTooltip": "Neste",
"@nextTooltip": {},
"doNotAskAgain": "Ikke spør igjen",
"@doNotAskAgain": {},
"chipActionGoToAlbumPage": "Vis i album",
"@chipActionGoToAlbumPage": {},
"chipActionGoToCountryPage": "Vis i land",
"@chipActionGoToCountryPage": {},
"chipActionGoToTagPage": "Vis i etiketter",
"@chipActionGoToTagPage": {},
"doubleBackExitMessage": "Trykk «Tilbake» igjen for å avslutte.",
"@doubleBackExitMessage": {},
"chipActionUnpin": "Løsne fra toppen",
"@chipActionUnpin": {},
"chipActionRename": "Gi nytt navn",
"@chipActionRename": {},
"chipActionFilterOut": "Ekskluder",
"@chipActionFilterOut": {},
"chipActionFilterIn": "Inkluder",
"@chipActionFilterIn": {},
"entryActionCopyToClipboard": "Kopier til utklippstavle",
"@entryActionCopyToClipboard": {},
"entryActionRename": "Gi nytt navn",
"@entryActionRename": {},
"entryActionOpen": "Åpne med",
"@entryActionOpen": {},
"entryActionSetAs": "Sett som",
"@entryActionSetAs": {},
"entryActionRotateCCW": "Roter mot klokken",
"@entryActionRotateCCW": {},
"entryActionRotateCW": "Roter med klokken",
"@entryActionRotateCW": {},
"entryActionShare": "Del",
"@entryActionShare": {},
"entryActionViewSource": "Vis kilde",
"@entryActionViewSource": {},
"entryActionShowGeoTiffOnMap": "Vis som overlagskart",
"@entryActionShowGeoTiffOnMap": {},
"entryActionConvertMotionPhotoToStillImage": "Konverter til stillbilde",
"@entryActionConvertMotionPhotoToStillImage": {},
"entryActionViewMotionPhotoVideo": "Åpne video",
"@entryActionViewMotionPhotoVideo": {},
"entryActionEdit": "Rediger",
"@entryActionEdit": {},
"entryActionAddFavourite": "Legg til i favoritter",
"@entryActionAddFavourite": {},
"entryActionRemoveFavourite": "Fjern fra favoritter",
"@entryActionRemoveFavourite": {},
"videoActionMute": "Forstum",
"@videoActionMute": {},
"videoActionUnmute": "Opphev forstummelse",
"@videoActionUnmute": {},
"videoActionReplay10": "Blafre bakover 10 sekunder",
"@videoActionReplay10": {},
"videoActionSkip10": "Blafre forover 10 sekunder",
"@videoActionSkip10": {},
"videoActionSetSpeed": "Avspillingshastighet",
"@videoActionSetSpeed": {},
"videoActionSettings": "Innstillinger",
"@videoActionSettings": {},
"entryInfoActionEditTitleDescription": "Rediger navn og beskrivelse",
"@entryInfoActionEditTitleDescription": {},
"filterNoDateLabel": "Udatert",
"@filterNoDateLabel": {},
"filterNoLocationLabel": "Uten posisjon",
"@filterNoLocationLabel": {},
"filterNoRatingLabel": "Uvurdert",
"@filterNoRatingLabel": {},
"filterTypeRawLabel": "Rådata",
"@filterTypeRawLabel": {},
"filterNoTagLabel": "Uten etiketter",
"@filterNoTagLabel": {},
"filterNoTitleLabel": "Uten navn",
"@filterNoTitleLabel": {},
"filterOnThisDayLabel": "På denne dagen",
"@filterOnThisDayLabel": {},
"filterRatingRejectedLabel": "Avslått",
"@filterRatingRejectedLabel": {},
"coordinateDmsNorth": "N",
"@coordinateDmsNorth": {},
"mapStyleGoogleHybrid": "Google Maps Hybrid (fiendekartet)",
"@mapStyleGoogleHybrid": {},
"mapStyleGoogleTerrain": "Google Maps Terreng (fiendekartet)",
"@mapStyleGoogleTerrain": {},
"coordinateDms": "{coordinate} {direction}",
"@coordinateDms": {
"placeholders": {
"coordinate": {
"type": "String",
"example": "38° 41 47.72″"
},
"direction": {
"type": "String",
"example": "S"
}
}
},
"mapStyleOsmHot": "Humanitært OSM",
"@mapStyleOsmHot": {},
"accessibilityAnimationsRemove": "Forhindre skjermeffekter",
"@accessibilityAnimationsRemove": {},
"accessibilityAnimationsKeep": "Behold skjermeffekter",
"@accessibilityAnimationsKeep": {},
"themeBrightnessDark": "Mørk",
"@themeBrightnessDark": {},
"themeBrightnessLight": "Lys",
"@themeBrightnessLight": {},
"viewerTransitionZoomIn": "Forstørr",
"@viewerTransitionZoomIn": {},
"storageVolumeDescriptionFallbackPrimary": "Internlagring",
"@storageVolumeDescriptionFallbackPrimary": {},
"storageVolumeDescriptionFallbackNonPrimary": "SD-kort",
"@storageVolumeDescriptionFallbackNonPrimary": {},
"rootDirectoryDescription": "rotmappe",
"@rootDirectoryDescription": {},
"widgetOpenPageCollection": "Åpne samling",
"@widgetOpenPageCollection": {},
"nameConflictDialogMultipleSourceMessage": "Noen filer har samme navn.",
"@nameConflictDialogMultipleSourceMessage": {},
"moveUndatedConfirmationDialogSetDate": "Lagre datoer",
"@moveUndatedConfirmationDialogSetDate": {},
"videoResumeButtonLabel": "Fortsett",
"@videoResumeButtonLabel": {},
"renameEntrySetPageTitle": "Gi nytt navn",
"@renameEntrySetPageTitle": {},
"exportEntryDialogFormat": "Format:",
"@exportEntryDialogFormat": {},
"editEntryDateDialogTitle": "Dato og tid",
"@editEntryDateDialogTitle": {},
"editEntryDateDialogSetCustom": "Sett egendefinert dato",
"@editEntryDateDialogSetCustom": {},
"editEntryDateDialogCopyField": "Kopier fra annen dato",
"@editEntryDateDialogCopyField": {},
"editEntryLocationDialogLatitude": "Breddegrad",
"@editEntryLocationDialogLatitude": {},
"videoStreamSelectionDialogVideo": "Video",
"@videoStreamSelectionDialogVideo": {},
"videoStreamSelectionDialogAudio": "Lyd",
"@videoStreamSelectionDialogAudio": {},
"videoStreamSelectionDialogOff": "Av",
"@videoStreamSelectionDialogOff": {},
"genericSuccessFeedback": "Ferdig",
"@genericSuccessFeedback": {},
"genericFailureFeedback": "Mislykket",
"@genericFailureFeedback": {},
"menuActionSelect": "Velg",
"@menuActionSelect": {},
"menuActionSlideshow": "Lysbildevisning",
"@menuActionSlideshow": {},
"menuActionStats": "Statistikk",
"@menuActionStats": {},
"appPickDialogNone": "Ingen",
"@appPickDialogNone": {},
"aboutPageTitle": "Om",
"@aboutPageTitle": {},
"aboutLinkLicense": "Lisens",
"@aboutLinkLicense": {},
"aboutLinkPolicy": "Personvernspraksis",
"@aboutLinkPolicy": {},
"aboutCreditsWorldAtlas2": "under ISC-lisens.",
"@aboutCreditsWorldAtlas2": {},
"aboutTranslatorsSectionTitle": "Oversettere",
"@aboutTranslatorsSectionTitle": {},
"aboutLicensesSectionTitle": "Frie lisenser",
"@aboutLicensesSectionTitle": {},
"policyPageTitle": "Personvernspraksis",
"@policyPageTitle": {},
"aboutLicensesFlutterPackagesSectionTitle": "Flutter-pakker",
"@aboutLicensesFlutterPackagesSectionTitle": {},
"aboutLicensesDartPackagesSectionTitle": "Dart-pakker",
"@aboutLicensesDartPackagesSectionTitle": {},
"collectionActionShowTitleSearch": "Vis navnefilter",
"@collectionActionShowTitleSearch": {},
"collectionActionHideTitleSearch": "Skjul navnefilter",
"@collectionActionHideTitleSearch": {},
"collectionSearchTitlesHintText": "Søk etter navn",
"@collectionSearchTitlesHintText": {},
"collectionGroupMonth": "Etter måned",
"@collectionGroupMonth": {},
"collectionEmptyImages": "Ingen bilder",
"@collectionEmptyImages": {},
"drawerAboutButton": "Om",
"@drawerAboutButton": {},
"drawerSettingsButton": "Innstillinger",
"@drawerSettingsButton": {},
"drawerCollectionFavourites": "Favoritter",
"@drawerCollectionFavourites": {},
"drawerCollectionAll": "Alle samlinger",
"@drawerCollectionAll": {},
"sortOrderAtoZ": "A-Å",
"@sortOrderAtoZ": {},
"sortOrderZtoA": "Å-A",
"@sortOrderZtoA": {},
"newFilterBanner": "ny",
"@newFilterBanner": {},
"settingsNavigationSectionTitle": "Navigasjon",
"@settingsNavigationSectionTitle": {},
"settingsHomeTile": "Hjem",
"@settingsHomeTile": {},
"settingsHomeDialogTitle": "Hjem",
"@settingsHomeDialogTitle": {},
"settingsDisabled": "Avskrudd",
"@settingsDisabled": {},
"settingsSearchFieldLabel": "Søkeinnstillinger",
"@settingsSearchFieldLabel": {},
"settingsSearchEmpty": "Ingen samsvarende innstilling",
"@settingsSearchEmpty": {},
"settingsShowBottomNavigationBar": "Vis navigasjonsfelt på bunnen",
"@settingsShowBottomNavigationBar": {},
"settingsKeepScreenOnTile": "Behold skjermen påslått",
"@settingsKeepScreenOnTile": {},
"settingsKeepScreenOnDialogTitle": "Behold skjermen påslått",
"@settingsKeepScreenOnDialogTitle": {},
"settingsDoubleBackExit": "Trykk «Tilbake» to ganger for å avslutte",
"@settingsDoubleBackExit": {},
"settingsNavigationDrawerTile": "Navigasjonsmeny",
"@settingsNavigationDrawerTile": {},
"settingsNavigationDrawerEditorPageTitle": "Navigasjonsmeny",
"@settingsNavigationDrawerEditorPageTitle": {},
"settingsNavigationDrawerTabAlbums": "Album",
"@settingsNavigationDrawerTabAlbums": {},
"settingsNavigationDrawerTabPages": "Sider",
"@settingsNavigationDrawerTabPages": {},
"settingsViewerQuickActionEditorPageTitle": "Hurtighandlinger",
"@settingsViewerQuickActionEditorPageTitle": {},
"settingsViewerShowInformationSubtitle": "Vis navn, dato, posisjon, osv.",
"@settingsViewerShowInformationSubtitle": {},
"settingsViewerSlideshowPageTitle": "Lysbildevisning",
"@settingsViewerSlideshowPageTitle": {},
"settingsSlideshowAnimatedZoomEffect": "Animert forstørrelseseffekt",
"@settingsSlideshowAnimatedZoomEffect": {},
"settingsSlideshowTransitionTile": "Overgang",
"@settingsSlideshowTransitionTile": {},
"settingsSlideshowVideoPlaybackTile": "Videoavspilling",
"@settingsSlideshowVideoPlaybackTile": {},
"settingsVideoPageTitle": "Videoinnstillinger",
"@settingsVideoPageTitle": {},
"settingsVideoSectionTitle": "Video",
"@settingsVideoSectionTitle": {},
"settingsVideoEnableHardwareAcceleration": "Maskinvareakselerasjon",
"@settingsVideoEnableHardwareAcceleration": {},
"settingsVideoLoopModeTile": "Gjentagelsesmodus",
"@settingsVideoLoopModeTile": {},
"settingsVideoLoopModeDialogTitle": "Gjentagelsesmodus",
"@settingsVideoLoopModeDialogTitle": {},
"settingsSubtitleThemeTextAlignmentDialogTitle": "Tekstjustering",
"@settingsSubtitleThemeTextAlignmentDialogTitle": {},
"settingsSubtitleThemeTextColor": "Tekstfarge",
"@settingsSubtitleThemeTextColor": {},
"settingsSubtitleThemeTextOpacity": "Tekst-dekkevne",
"@settingsSubtitleThemeTextOpacity": {},
"settingsSubtitleThemeBackgroundColor": "Bakgrunnsfarge",
"@settingsSubtitleThemeBackgroundColor": {},
"settingsSubtitleThemeBackgroundOpacity": "Bakgrunnsdekkevne",
"@settingsSubtitleThemeBackgroundOpacity": {},
"settingsSubtitleThemeTextAlignmentRight": "Høyre",
"@settingsSubtitleThemeTextAlignmentRight": {},
"settingsVideoControlsTile": "Kontroller",
"@settingsVideoControlsTile": {},
"settingsVideoGestureDoubleTapTogglePlay": "Dobbelttrykk for å spille/pause",
"@settingsVideoGestureDoubleTapTogglePlay": {},
"settingsPrivacySectionTitle": "Personvern",
"@settingsPrivacySectionTitle": {},
"settingsEnableBin": "Bruk papirkurv",
"@settingsEnableBin": {},
"settingsHiddenItemsTile": "Skjulte elementer",
"@settingsHiddenItemsTile": {},
"settingsHiddenItemsPageTitle": "Skjulte elementer",
"@settingsHiddenItemsPageTitle": {},
"settingsRemoveAnimationsTile": "Fjern animasjoner",
"@settingsRemoveAnimationsTile": {},
"settingsDisplaySectionTitle": "Visning",
"@settingsDisplaySectionTitle": {},
"settingsThemeBrightnessTile": "Drakt",
"@settingsThemeBrightnessTile": {},
"settingsCoordinateFormatDialogTitle": "Koordinatformat",
"@settingsCoordinateFormatDialogTitle": {},
"settingsUnitSystemTile": "Enheter",
"@settingsUnitSystemTile": {},
"settingsScreenSaverPageTitle": "Skjermsparer",
"@settingsScreenSaverPageTitle": {},
"statsTopCountriesSectionTitle": "Toppland",
"@statsTopCountriesSectionTitle": {},
"statsTopPlacesSectionTitle": "Toppsteder",
"@statsTopPlacesSectionTitle": {},
"statsTopTagsSectionTitle": "Topp-etiketter",
"@statsTopTagsSectionTitle": {},
"statsTopAlbumsSectionTitle": "Topp-album",
"@statsTopAlbumsSectionTitle": {},
"viewerInfoLabelDescription": "Beskrivelse",
"@viewerInfoLabelDescription": {},
"viewerInfoLabelTitle": "Navn",
"@viewerInfoLabelTitle": {},
"viewerInfoLabelResolution": "Oppløsning",
"@viewerInfoLabelResolution": {},
"viewerInfoLabelSize": "Størrelse",
"@viewerInfoLabelSize": {},
"viewerInfoLabelUri": "URI",
"@viewerInfoLabelUri": {},
"viewerInfoLabelPath": "Sti",
"@viewerInfoLabelPath": {},
"viewerInfoLabelDuration": "Varighet",
"@viewerInfoLabelDuration": {},
"viewerInfoLabelOwner": "Eier",
"@viewerInfoLabelOwner": {},
"viewerInfoLabelCoordinates": "Koordinater",
"@viewerInfoLabelCoordinates": {},
"viewerInfoLabelAddress": "Adresse",
"@viewerInfoLabelAddress": {},
"openMapPageTooltip": "Vis på kartsiden",
"@openMapPageTooltip": {},
"viewerInfoOpenLinkText": "Åpne",
"@viewerInfoOpenLinkText": {},
"viewerInfoViewXmlLinkText": "Vis XML",
"@viewerInfoViewXmlLinkText": {},
"appName": "Aves",
"@appName": {},
"welcomeMessage": "Velkommen",
"@welcomeMessage": {},
"welcomeTermsToggle": "Jeg samtykker til vilkår og betingelser",
"@welcomeTermsToggle": {},
"filterRecentlyAddedLabel": "Nylig tillagt",
"@filterRecentlyAddedLabel": {},
"filterTypeAnimatedLabel": "Animert",
"@filterTypeAnimatedLabel": {},
"nextButtonLabel": "Neste",
"@nextButtonLabel": {},
"continueButtonLabel": "Fortsett",
"@continueButtonLabel": {},
"sourceStateLoading": "Laster inn …",
"@sourceStateLoading": {},
"chipActionCreateAlbum": "Opprett album",
"@chipActionCreateAlbum": {},
"collectionGroupAlbum": "Etter album",
"@collectionGroupAlbum": {},
"showButtonLabel": "Vis",
"@showButtonLabel": {},
"hideButtonLabel": "Skjul",
"@hideButtonLabel": {},
"cancelTooltip": "Avbryt",
"@cancelTooltip": {},
"showTooltip": "Vis",
"@showTooltip": {},
"actionRemove": "Fjern",
"@actionRemove": {},
"pickTooltip": "Velg",
"@pickTooltip": {},
"chipActionSetCover": "Sett omslag",
"@chipActionSetCover": {},
"hideTooltip": "Skjul",
"@hideTooltip": {},
"saveTooltip": "Lagre",
"@saveTooltip": {},
"resetTooltip": "Tilbakestill",
"@resetTooltip": {},
"chipActionDelete": "Slett",
"@chipActionDelete": {},
"entryActionRotateScreen": "Roter skjerm",
"@entryActionRotateScreen": {},
"videoActionPlay": "Spill",
"@videoActionPlay": {},
"entryInfoActionEditTags": "Rediger etiketter",
"@entryInfoActionEditTags": {},
"entryInfoActionRemoveMetadata": "Fjern metadata",
"@entryInfoActionRemoveMetadata": {},
"chipActionHide": "Skjul",
"@chipActionHide": {},
"chipActionPin": "Fest til toppen",
"@chipActionPin": {},
"entryActionDelete": "Slett",
"@entryActionDelete": {},
"entryActionExport": "Eksporter",
"@entryActionExport": {},
"videoActionPause": "Pause",
"@videoActionPause": {},
"entryActionConvert": "Konverter",
"@entryActionConvert": {},
"entryActionOpenMap": "Vis i kartprogram",
"@entryActionOpenMap": {},
"entryActionInfo": "Info",
"@entryActionInfo": {},
"entryActionRestore": "Gjenopprett",
"@entryActionRestore": {},
"aboutBugSaveLogInstruction": "Lagre programlogger til fil",
"@aboutBugSaveLogInstruction": {},
"aboutBugCopyInfoButton": "Kopier",
"@aboutBugCopyInfoButton": {},
"slideshowActionShowInCollection": "Vis i samling",
"@slideshowActionShowInCollection": {},
"sortOrderLargestFirst": "Største først",
"@sortOrderLargestFirst": {},
"entryInfoActionEditDate": "Rediger dato og tid",
"@entryInfoActionEditDate": {},
"entryInfoActionEditRating": "Rediger vurdering",
"@entryInfoActionEditRating": {},
"filterTypePanoramaLabel": "Panorama",
"@filterTypePanoramaLabel": {},
"filterTypeMotionPhotoLabel": "Bevegelig bilde",
"@filterTypeMotionPhotoLabel": {},
"filterMimeImageLabel": "Bilde",
"@filterMimeImageLabel": {},
"filterMimeVideoLabel": "Video",
"@filterMimeVideoLabel": {},
"coordinateDmsWest": "V",
"@coordinateDmsWest": {},
"filterTypeSphericalVideoLabel": "360°-video",
"@filterTypeSphericalVideoLabel": {},
"filterTypeGeotiffLabel": "GeoTIFF",
"@filterTypeGeotiffLabel": {},
"coordinateDmsSouth": "S",
"@coordinateDmsSouth": {},
"coordinateFormatDecimal": "Desimalgrader",
"@coordinateFormatDecimal": {},
"coordinateDmsEast": "Ø",
"@coordinateDmsEast": {},
"videoLoopModeNever": "Aldri",
"@videoLoopModeNever": {},
"videoLoopModeAlways": "Alltid",
"@videoLoopModeAlways": {},
"keepScreenOnNever": "Aldri",
"@keepScreenOnNever": {},
"albumTierRegular": "Andre",
"@albumTierRegular": {},
"setCoverDialogAuto": "Auto",
"@setCoverDialogAuto": {},
"renameEntrySetPagePatternFieldLabel": "Navngivningsmønster",
"@renameEntrySetPagePatternFieldLabel": {},
"unitSystemMetric": "Metrisk",
"@unitSystemMetric": {},
"unitSystemImperial": "Engelske måleenheter",
"@unitSystemImperial": {},
"keepScreenOnAlways": "Alltid",
"@keepScreenOnAlways": {},
"wallpaperTargetLock": "Lås skjerm",
"@wallpaperTargetLock": {},
"albumTierApps": "Programmer",
"@albumTierApps": {},
"videoLoopModeShortOnly": "Kun korte videoer",
"@videoLoopModeShortOnly": {},
"videoControlsPlay": "Spill",
"@videoControlsPlay": {},
"videoControlsPlaySeek": "Spill og blafre forover/bakover",
"@videoControlsPlaySeek": {},
"videoControlsPlayOutside": "Åpne med annen avspiller",
"@videoControlsPlayOutside": {},
"videoControlsNone": "Ingen",
"@videoControlsNone": {},
"mapStyleGoogleNormal": "Google Maps (fiendekartet)",
"@mapStyleGoogleNormal": {},
"videoPlaybackSkip": "Hopp over",
"@videoPlaybackSkip": {},
"newAlbumDialogNameLabelAlreadyExistsHelper": "Mappen finnes allerede",
"@newAlbumDialogNameLabelAlreadyExistsHelper": {},
"filePickerShowHiddenFiles": "Vis skjulte filer",
"@filePickerShowHiddenFiles": {},
"themeBrightnessBlack": "Svart",
"@themeBrightnessBlack": {},
"albumTierNew": "Ny",
"@albumTierNew": {},
"albumTierPinned": "Festet",
"@albumTierPinned": {},
"renameEntrySetPagePreviewSectionTitle": "Forhåndsvis",
"@renameEntrySetPagePreviewSectionTitle": {},
"renameProcessorCounter": "Teller",
"@renameProcessorCounter": {},
"viewerTransitionNone": "Ingen",
"@viewerTransitionNone": {},
"wallpaperTargetHome": "Hjemmeskjerm",
"@wallpaperTargetHome": {},
"wallpaperTargetHomeLock": "Hjem- og låseskjermer",
"@wallpaperTargetHomeLock": {},
"otherDirectoryDescription": "«{name}»-mappen",
"@otherDirectoryDescription": {
"placeholders": {
"name": {
"type": "String",
"example": "Pictures",
"description": "the name of a specific directory"
}
}
},
"addShortcutDialogLabel": "Snarveisetikett",
"@addShortcutDialogLabel": {},
"setCoverDialogCustom": "Egendefinert",
"@setCoverDialogCustom": {},
"newAlbumDialogTitle": "Nytt album",
"@newAlbumDialogTitle": {},
"addShortcutButtonLabel": "Legg til",
"@addShortcutButtonLabel": {},
"newAlbumDialogNameLabel": "Albumsnavn",
"@newAlbumDialogNameLabel": {},
"newAlbumDialogStorageLabel": "Lagring:",
"@newAlbumDialogStorageLabel": {},
"durationDialogSeconds": "Sekunder",
"@durationDialogSeconds": {},
"removeEntryMetadataDialogMore": "Mer",
"@removeEntryMetadataDialogMore": {},
"menuActionSelectAll": "Velg alle",
"@menuActionSelectAll": {},
"appPickDialogTitle": "Velg program",
"@appPickDialogTitle": {},
"renameEntrySetPageInsertTooltip": "Sett inn felt",
"@renameEntrySetPageInsertTooltip": {},
"exportEntryDialogWidth": "Bredde",
"@exportEntryDialogWidth": {},
"renameEntryDialogLabel": "Nytt navn",
"@renameEntryDialogLabel": {},
"collectionActionMove": "Flytt til album",
"@collectionActionMove": {},
"renameAlbumDialogLabel": "Nytt navn",
"@renameAlbumDialogLabel": {},
"renameAlbumDialogLabelAlreadyExistsHelper": "Mappen finnes allerede",
"@renameAlbumDialogLabelAlreadyExistsHelper": {},
"renameProcessorName": "Navn",
"@renameProcessorName": {},
"editEntryLocationDialogLongitude": "Lengdegrad",
"@editEntryLocationDialogLongitude": {},
"exportEntryDialogHeight": "Høyde",
"@exportEntryDialogHeight": {},
"collectionActionRescan": "Skann igjen",
"@collectionActionRescan": {},
"sortOrderSmallestFirst": "Minste først",
"@sortOrderSmallestFirst": {},
"albumGroupNone": "Ikke grupper",
"@albumGroupNone": {},
"editEntryDialogTargetFieldsHeader": "Felter å endre",
"@editEntryDialogTargetFieldsHeader": {},
"editEntryDialogCopyFromItem": "Kopier fra annet element",
"@editEntryDialogCopyFromItem": {},
"durationDialogMinutes": "Minutter",
"@durationDialogMinutes": {},
"editEntryLocationDialogChooseOnMap": "Velg på kartet",
"@editEntryLocationDialogChooseOnMap": {},
"editEntryRatingDialogTitle": "Vurdering",
"@editEntryRatingDialogTitle": {},
"removeEntryMetadataDialogTitle": "Metadatafjerning",
"@removeEntryMetadataDialogTitle": {},
"convertMotionPhotoToStillImageWarningDialogMessage": "Er du sikker?",
"@convertMotionPhotoToStillImageWarningDialogMessage": {},
"videoSpeedDialogLabel": "Avspillingshastighet",
"@videoSpeedDialogLabel": {},
"menuActionConfigureView": "Vis",
"@menuActionConfigureView": {},
"menuActionMap": "Kart",
"@menuActionMap": {},
"viewDialogGroupSectionTitle": "Gruppe",
"@viewDialogGroupSectionTitle": {},
"tileLayoutList": "Liste",
"@tileLayoutList": {},
"collectionPickPageTitle": "Velg",
"@collectionPickPageTitle": {},
"menuActionSelectNone": "Fravelg alt",
"@menuActionSelectNone": {},
"coverDialogTabCover": "Omslag",
"@coverDialogTabCover": {},
"coverDialogTabApp": "Program",
"@coverDialogTabApp": {},
"collectionEmptyFavourites": "Ingen favoritter",
"@collectionEmptyFavourites": {},
"viewDialogSortSectionTitle": "Sortering",
"@viewDialogSortSectionTitle": {},
"tileLayoutGrid": "Rutenett",
"@tileLayoutGrid": {},
"coverDialogTabColor": "Farge",
"@coverDialogTabColor": {},
"aboutBugSectionTitle": "Feilrapport",
"@aboutBugSectionTitle": {},
"aboutBugReportButton": "Rapporter",
"@aboutBugReportButton": {},
"aboutCreditsWorldAtlas1": "Dette programmet bruker en TopoJSON-fil fra",
"@aboutCreditsWorldAtlas1": {},
"collectionPageTitle": "Samling",
"@collectionPageTitle": {},
"aboutBugCopyInfoInstruction": "Kopier systeminfo",
"@aboutBugCopyInfoInstruction": {},
"aboutCreditsSectionTitle": "Bidragsytere",
"@aboutCreditsSectionTitle": {},
"aboutLicensesShowAllButtonLabel": "Vis alle lisenser",
"@aboutLicensesShowAllButtonLabel": {},
"collectionActionAddShortcut": "Legg til snarvei",
"@collectionActionAddShortcut": {},
"dateToday": "I dag",
"@dateToday": {},
"searchDateSectionTitle": "Dato",
"@searchDateSectionTitle": {},
"collectionSelectPageTitle": "Velg elementer",
"@collectionSelectPageTitle": {},
"collectionActionEmptyBin": "Tøm papirkurv",
"@collectionActionEmptyBin": {},
"collectionActionCopy": "Kopier til album",
"@collectionActionCopy": {},
"dateYesterday": "I går",
"@dateYesterday": {},
"drawerCollectionImages": "Bilder",
"@drawerCollectionImages": {},
"collectionActionEdit": "Rediger",
"@collectionActionEdit": {},
"collectionGroupDay": "Etter dag",
"@collectionGroupDay": {},
"dateThisMonth": "Denne måneden",
"@dateThisMonth": {},
"collectionGroupNone": "Ikke grupper",
"@collectionGroupNone": {},
"drawerCollectionAnimated": "Animert",
"@drawerCollectionAnimated": {},
"drawerCollectionSphericalVideos": "360°-videoer",
"@drawerCollectionSphericalVideos": {},
"sortByDate": "Etter dato",
"@sortByDate": {},
"sectionUnknown": "Ukjent",
"@sectionUnknown": {},
"collectionEmptyVideos": "Ingen videoer",
"@collectionEmptyVideos": {},
"drawerCountryPage": "Land",
"@drawerCountryPage": {},
"collectionEmptyGrantAccessButtonLabel": "Innvilg tilgang",
"@collectionEmptyGrantAccessButtonLabel": {},
"drawerCollectionVideos": "Videoer",
"@drawerCollectionVideos": {},
"drawerTagPage": "Etiketter",
"@drawerTagPage": {},
"drawerCollectionMotionPhotos": "Bevegelige bilder",
"@drawerCollectionMotionPhotos": {},
"drawerCollectionRaws": "Rådatabilder",
"@drawerCollectionRaws": {},
"drawerAlbumPage": "Album",
"@drawerAlbumPage": {},
"sortOrderHighestFirst": "Høyeste først",
"@sortOrderHighestFirst": {},
"sortOrderLowestFirst": "Laveste først",
"@sortOrderLowestFirst": {},
"countryEmpty": "Ingen land",
"@countryEmpty": {},
"drawerCollectionPanoramas": "Panoramaer",
"@drawerCollectionPanoramas": {},
"searchRecentSectionTitle": "Nylige",
"@searchRecentSectionTitle": {},
"sourceViewerPageTitle": "Kilde",
"@sourceViewerPageTitle": {},
"sortByName": "Etter navn",
"@sortByName": {},
"sortByItemCount": "Etter antall elementer",
"@sortByItemCount": {},
"sortBySize": "Etter størrelse",
"@sortBySize": {},
"sortByAlbumFileName": "Etter album og filnavn",
"@sortByAlbumFileName": {},
"sortByRating": "Etter vurdering",
"@sortByRating": {},
"sortOrderNewestFirst": "Nyeste først",
"@sortOrderNewestFirst": {},
"sortOrderOldestFirst": "Eldste først",
"@sortOrderOldestFirst": {},
"albumCamera": "Kamera",
"@albumCamera": {},
"albumScreenshots": "Skjermavbildninger",
"@albumScreenshots": {},
"albumPageTitle": "Album",
"@albumPageTitle": {},
"tagPageTitle": "Etiketter",
"@tagPageTitle": {},
"settingsViewerShowInformation": "Vis info",
"@settingsViewerShowInformation": {},
"albumGroupType": "Etter type",
"@albumGroupType": {},
"albumGroupVolume": "Etter lagringsdataområde",
"@albumGroupVolume": {},
"albumMimeTypeMixed": "Blandet",
"@albumMimeTypeMixed": {},
"albumPickPageTitleCopy": "Kopier til album",
"@albumPickPageTitleCopy": {},
"albumPickPageTitlePick": "Velg album",
"@albumPickPageTitlePick": {},
"albumDownload": "Last ned",
"@albumDownload": {},
"createAlbumButtonLabel": "Opprett",
"@createAlbumButtonLabel": {},
"searchRatingSectionTitle": "Vurderinger",
"@searchRatingSectionTitle": {},
"settingsSystemDefault": "Systemforvalg",
"@settingsSystemDefault": {},
"albumPickPageTitleExport": "Eksporter til album",
"@albumPickPageTitleExport": {},
"albumPickPageTitleMove": "Flytt til album",
"@albumPickPageTitleMove": {},
"albumScreenRecordings": "Skjermopptak",
"@albumScreenRecordings": {},
"albumVideoCaptures": "Videoopptak",
"@albumVideoCaptures": {},
"albumEmpty": "Ingen album",
"@albumEmpty": {},
"createAlbumTooltip": "Opprett album",
"@createAlbumTooltip": {},
"binPageTitle": "Papirkurv",
"@binPageTitle": {},
"countryPageTitle": "Land",
"@countryPageTitle": {},
"tagEmpty": "Ingen etiketter",
"@tagEmpty": {},
"searchAlbumsSectionTitle": "Album",
"@searchAlbumsSectionTitle": {},
"searchMetadataSectionTitle": "Metadata",
"@searchMetadataSectionTitle": {},
"settingsPageTitle": "Innstillinger",
"@settingsPageTitle": {},
"settingsActionExportDialogTitle": "Eksporter",
"@settingsActionExportDialogTitle": {},
"searchCollectionFieldHint": "Søk i samling",
"@searchCollectionFieldHint": {},
"searchCountriesSectionTitle": "Land",
"@searchCountriesSectionTitle": {},
"settingsDefault": "Forvalg",
"@settingsDefault": {},
"settingsActionExport": "Eksporter",
"@settingsActionExport": {},
"settingsActionImport": "Importer",
"@settingsActionImport": {},
"settingsActionImportDialogTitle": "Importer",
"@settingsActionImportDialogTitle": {},
"searchPlacesSectionTitle": "Steder",
"@searchPlacesSectionTitle": {},
"searchTagsSectionTitle": "Etiketter",
"@searchTagsSectionTitle": {},
"appExportCovers": "Omslag",
"@appExportCovers": {},
"appExportFavourites": "Favoritter",
"@appExportFavourites": {},
"appExportSettings": "Innstillinger",
"@appExportSettings": {},
"settingsConfirmationDialogTitle": "Bekreftelsesdialoger",
"@settingsConfirmationDialogTitle": {},
"settingsConfirmationTile": "Bekreftelsesdialoger",
"@settingsConfirmationTile": {},
"settingsThumbnailSectionTitle": "Miniatyrbilder",
"@settingsThumbnailSectionTitle": {},
"timeMinutes": "{minutes, plural, =1{1 minutt} other{{minutes} minutter}}",
"@timeMinutes": {
"placeholders": {
"minutes": {}
}
},
"timeDays": "{days, plural, =1{1 dag} other{{days} dager}}",
"@timeDays": {
"placeholders": {
"days": {}
}
},
"itemCount": "{count, plural, =1{1 element} other{{count} elementer}}",
"@itemCount": {
"placeholders": {
"count": {}
}
},
"timeSeconds": "{seconds, plural, =1{1 sekund} other{{seconds} sekunder}}",
"@timeSeconds": {
"placeholders": {
"seconds": {}
}
},
"applyButtonLabel": "Bruk",
"@applyButtonLabel": {},
"sourceStateCataloguing": "Katalogisering",
"@sourceStateCataloguing": {},
"entryActionPrint": "Skriv ut",
"@entryActionPrint": {},
"filterFavouriteLabel": "Favorittmerk",
"@filterFavouriteLabel": {},
"filterBinLabel": "Papirkurv",
"@filterBinLabel": {},
"nameConflictStrategyRename": "Gi nytt navn",
"@nameConflictStrategyRename": {},
"nameConflictStrategyReplace": "Erstatt",
"@nameConflictStrategyReplace": {},
"nameConflictStrategySkip": "Hopp over",
"@nameConflictStrategySkip": {},
"videoPlaybackWithSound": "Spill med lyd",
"@videoPlaybackWithSound": {},
"videoPlaybackMuted": "Spill forstummet",
"@videoPlaybackMuted": {},
"videoStartOverButtonLabel": "Start om igjen",
"@videoStartOverButtonLabel": {},
"noMatchingAppDialogMessage": "Ingen programmer kan håndtere dette.",
"@noMatchingAppDialogMessage": {},
"videoResumeDialogMessage": "Fortsett avspilling fra {time}?",
"@videoResumeDialogMessage": {
"placeholders": {
"time": {
"type": "String",
"example": "13:37"
}
}
},
"settingsNavigationDrawerTabTypes": "Typer",
"@settingsNavigationDrawerTabTypes": {},
"settingsNavigationDrawerAddAlbum": "Legg til album",
"@settingsNavigationDrawerAddAlbum": {},
"settingsViewerQuickActionsTile": "Hurtighandlinger",
"@settingsViewerQuickActionsTile": {},
"tagEditorPageTitle": "Rediger etiketter",
"@tagEditorPageTitle": {},
"settingsViewerShowMinimap": "Vis minikart",
"@settingsViewerShowMinimap": {},
"settingsSubtitleThemePageTitle": "Undertekster",
"@settingsSubtitleThemePageTitle": {},
"settingsUnitSystemDialogTitle": "Enheter",
"@settingsUnitSystemDialogTitle": {},
"settingsViewerShowOverlayThumbnails": "Vis miniatyrbilder",
"@settingsViewerShowOverlayThumbnails": {},
"settingsSlideshowFillScreen": "Fyll skjermen",
"@settingsSlideshowFillScreen": {},
"settingsVideoShowVideos": "Vis videoer",
"@settingsVideoShowVideos": {},
"settingsViewerSlideshowTile": "Lysbildevisning",
"@settingsViewerSlideshowTile": {},
"settingsSlideshowVideoPlaybackDialogTitle": "Videoavspilling",
"@settingsSlideshowVideoPlaybackDialogTitle": {},
"settingsVideoAutoPlay": "Automatisk avspilling",
"@settingsVideoAutoPlay": {},
"settingsSubtitleThemeTextSize": "Tekststørrelse",
"@settingsSubtitleThemeTextSize": {},
"settingsVideoButtonsTile": "Knapper",
"@settingsVideoButtonsTile": {},
"settingsSlideshowRepeat": "Gjenta",
"@settingsSlideshowRepeat": {},
"settingsSlideshowIntervalTile": "Intervall",
"@settingsSlideshowIntervalTile": {},
"settingsSubtitleThemeTile": "Undertekster",
"@settingsSubtitleThemeTile": {},
"settingsSubtitleThemeTextAlignmentTile": "Tekstjustering",
"@settingsSubtitleThemeTextAlignmentTile": {},
"settingsSubtitleThemeTextAlignmentLeft": "Venstre",
"@settingsSubtitleThemeTextAlignmentLeft": {},
"settingsSubtitleThemeTextAlignmentCenter": "Midten",
"@settingsSubtitleThemeTextAlignmentCenter": {},
"settingsVideoControlsPageTitle": "Kontroller",
"@settingsVideoControlsPageTitle": {},
"settingsHiddenItemsTabFilters": "Skjulte filtre",
"@settingsHiddenItemsTabFilters": {},
"settingsThemeEnableDynamicColor": "Dynamisk farge",
"@settingsThemeEnableDynamicColor": {},
"mapZoomOutTooltip": "Forminsk",
"@mapZoomOutTooltip": {},
"viewerInfoPageTitle": "Info",
"@viewerInfoPageTitle": {},
"settingsSaveSearchHistory": "Lagre søkehistorikk",
"@settingsSaveSearchHistory": {},
"settingsEnableBinSubtitle": "Behold slettede elementer i 30 dager",
"@settingsEnableBinSubtitle": {},
"settingsAccessibilitySectionTitle": "Tilgjengelighet",
"@settingsAccessibilitySectionTitle": {},
"tagEditorPageNewTagFieldLabel": "Ny etikett",
"@tagEditorPageNewTagFieldLabel": {},
"settingsRemoveAnimationsDialogTitle": "Fjern animasjoner",
"@settingsRemoveAnimationsDialogTitle": {},
"settingsLanguageTile": "Språk",
"@settingsLanguageTile": {},
"settingsLanguagePageTitle": "Språk",
"@settingsLanguagePageTitle": {},
"viewerInfoUnknown": "ukjent",
"@viewerInfoUnknown": {},
"settingsThemeBrightnessDialogTitle": "Drakt",
"@settingsThemeBrightnessDialogTitle": {},
"settingsCoordinateFormatTile": "Koordinatformat",
"@settingsCoordinateFormatTile": {},
"settingsCollectionTile": "Samling",
"@settingsCollectionTile": {},
"statsPageTitle": "Statistikk",
"@statsPageTitle": {},
"viewerInfoLabelDate": "Dato",
"@viewerInfoLabelDate": {},
"mapStyleDialogTitle": "Kartstil",
"@mapStyleDialogTitle": {},
"mapStyleTooltip": "Velg kartstil",
"@mapStyleTooltip": {},
"mapZoomInTooltip": "Forstørr",
"@mapZoomInTooltip": {},
"mapPointNorthUpTooltip": "Nord oppover",
"@mapPointNorthUpTooltip": {},
"viewerInfoSearchSuggestionDescription": "Beskrivelse",
"@viewerInfoSearchSuggestionDescription": {},
"viewerInfoSearchFieldLabel": "Søk metadata",
"@viewerInfoSearchFieldLabel": {},
"viewerInfoSearchSuggestionDate": "Dato og tid",
"@viewerInfoSearchSuggestionDate": {},
"viewerInfoSearchSuggestionDimensions": "Dimensjoner",
"@viewerInfoSearchSuggestionDimensions": {},
"tagEditorSectionRecent": "Nylig",
"@tagEditorSectionRecent": {},
"viewerInfoSearchSuggestionResolution": "Oppløsning",
"@viewerInfoSearchSuggestionResolution": {},
"viewerInfoSearchSuggestionRights": "Rettigheter",
"@viewerInfoSearchSuggestionRights": {},
"tagEditorPageAddTagTooltip": "Legg til etikett",
"@tagEditorPageAddTagTooltip": {},
"filePickerDoNotShowHiddenFiles": "Ikke vis skjulte filer",
"@filePickerDoNotShowHiddenFiles": {},
"filePickerOpenFrom": "Åpne fra",
"@filePickerOpenFrom": {},
"filePickerNoItems": "Ingen elementer",
"@filePickerNoItems": {},
"filePickerUseThisFolder": "Bruk denne mappen",
"@filePickerUseThisFolder": {},
"sourceStateLocatingCountries": "Lokalisering av land",
"@sourceStateLocatingCountries": {},
"sourceStateLocatingPlaces": "Lokalisering av steder",
"@sourceStateLocatingPlaces": {},
"entryActionFlip": "Vend vannrett",
"@entryActionFlip": {},
"slideshowActionResume": "Fortsett",
"@slideshowActionResume": {},
"displayRefreshRatePreferHighest": "Høyeste takt",
"@displayRefreshRatePreferHighest": {},
"displayRefreshRatePreferLowest": "Laveste takt",
"@displayRefreshRatePreferLowest": {},
"viewerTransitionSlide": "Glidende",
"@viewerTransitionSlide": {},
"viewerTransitionParallax": "Parallakse",
"@viewerTransitionParallax": {},
"widgetOpenPageHome": "Åpne startside",
"@widgetOpenPageHome": {},
"widgetOpenPageViewer": "Åpne visning",
"@widgetOpenPageViewer": {},
"durationDialogHours": "Timer",
"@durationDialogHours": {},
"aboutLicensesBanner": "Programmet bruker følgende frie pakker og bibliotek.",
"@aboutLicensesBanner": {},
"aboutLicensesAndroidLibrariesSectionTitle": "Android-bibliotek",
"@aboutLicensesAndroidLibrariesSectionTitle": {},
"aboutLicensesFlutterPluginsSectionTitle": "Flutter-programtillegg",
"@aboutLicensesFlutterPluginsSectionTitle": {},
"aboutBugReportInstruction": "Innrapporter på GitHub med loggføring og systeminfo",
"@aboutBugReportInstruction": {},
"settingsSubtitleThemeShowOutline": "Vis omriss og skygge",
"@settingsSubtitleThemeShowOutline": {},
"settingsVideoGestureSideDoubleTapSeek": "Dobbelttrykk på skjermkantene for å blafre forover/bakover",
"@settingsVideoGestureSideDoubleTapSeek": {},
"settingsAllowInstalledAppAccessSubtitle": "Brukt til forbedring av albumsvisning",
"@settingsAllowInstalledAppAccessSubtitle": {},
"settingsAllowErrorReporting": "Tillat anonym feilrapportering",
"@settingsAllowErrorReporting": {},
"settingsStorageAccessPageTitle": "Lagringstilgang",
"@settingsStorageAccessPageTitle": {},
"settingsStorageAccessRevokeTooltip": "Tilbakekall",
"@settingsStorageAccessRevokeTooltip": {},
"settingsLanguageSectionTitle": "Språk og formater",
"@settingsLanguageSectionTitle": {},
"settingsWidgetPageTitle": "Bilderamme",
"@settingsWidgetPageTitle": {},
"settingsWidgetShowOutline": "Omriss",
"@settingsWidgetShowOutline": {},
"settingsWidgetOpenPage": "Når miniprogrammet trykkes",
"@settingsWidgetOpenPage": {},
"viewerOpenPanoramaButtonLabel": "Åpne panorama",
"@viewerOpenPanoramaButtonLabel": {},
"viewerSetWallpaperButtonLabel": "Sett som bakgrunnsbilde",
"@viewerSetWallpaperButtonLabel": {},
"viewerErrorUnknown": "Oida.",
"@viewerErrorUnknown": {},
"viewerInfoBackToViewerTooltip": "Tilbake til visning",
"@viewerInfoBackToViewerTooltip": {},
"mapAttributionOsmHot": "Kartdata © [OpenStreetMap](https://www.openstreetmap.org/copyright)-bidragsyterne • Flis av [HOT](https://www.hotosm.org) • Vertstjent av [OSM France](https://openstreetmap.fr)",
"@mapAttributionOsmHot": {},
"panoramaEnableSensorControl": "Skru på sensorstyring",
"@panoramaEnableSensorControl": {},
"panoramaDisableSensorControl": "Skru av sensorstyring",
"@panoramaDisableSensorControl": {},
"nameConflictDialogSingleSourceMessage": "Noen filer i målmappen har samme navn.",
"@nameConflictDialogSingleSourceMessage": {}
}

File diff suppressed because it is too large Load diff

188
lib/l10n/app_pl.arb Normal file
View file

@ -0,0 +1,188 @@
{
"actionRemove": "Usuń",
"@actionRemove": {},
"applyButtonLabel": "ZASTOSUJ",
"@applyButtonLabel": {},
"changeTooltip": "Zmień",
"@changeTooltip": {},
"nextTooltip": "Następny",
"@nextTooltip": {},
"hideTooltip": "Ukryj",
"@hideTooltip": {},
"resetTooltip": "Zresetuj",
"@resetTooltip": {},
"pickTooltip": "Wybierz",
"@pickTooltip": {},
"doubleBackExitMessage": "Tapnij ponownie \"wstecz\" aby wyjść.",
"@doubleBackExitMessage": {},
"saveTooltip": "Zapisz",
"@saveTooltip": {},
"doNotAskAgain": "Nie pytaj ponownie",
"@doNotAskAgain": {},
"sourceStateLoading": "Ładowanie",
"@sourceStateLoading": {},
"sourceStateLocatingCountries": "Lokowanie krajów",
"@sourceStateLocatingCountries": {},
"chipActionGoToCountryPage": "Pokaż w Krajach",
"@chipActionGoToCountryPage": {},
"appName": "Aves",
"@appName": {},
"welcomeMessage": "Witaj w Aves",
"@welcomeMessage": {},
"welcomeOptional": "Opcjonalny",
"@welcomeOptional": {},
"welcomeTermsToggle": "Akceptuję warunki i zasady",
"@welcomeTermsToggle": {},
"deleteButtonLabel": "USUŃ",
"@deleteButtonLabel": {},
"nextButtonLabel": "NASTĘPNY",
"@nextButtonLabel": {},
"showButtonLabel": "POKAŻ",
"@showButtonLabel": {},
"hideButtonLabel": "UKRYJ",
"@hideButtonLabel": {},
"continueButtonLabel": "KONTYNUUJ",
"@continueButtonLabel": {},
"cancelTooltip": "Anuluj",
"@cancelTooltip": {},
"clearTooltip": "Wyczyść",
"@clearTooltip": {},
"previousTooltip": "Poprzedni",
"@previousTooltip": {},
"showTooltip": "Pokaż",
"@showTooltip": {},
"chipActionFilterIn": "Filtrować",
"@chipActionFilterIn": {},
"chipActionPin": "Przypnij na górze",
"@chipActionPin": {},
"chipActionUnpin": "Odepnij z góry",
"@chipActionUnpin": {},
"chipActionRename": "Zmień nazwę",
"@chipActionRename": {},
"chipActionSetCover": "Ustaw okładkę",
"@chipActionSetCover": {},
"entryActionDelete": "Usuń",
"@entryActionDelete": {},
"entryActionConvert": "Konwertuj",
"@entryActionConvert": {},
"entryActionExport": "Eksport",
"@entryActionExport": {},
"videoActionCaptureFrame": "Ramka do przechwytywania",
"@videoActionCaptureFrame": {},
"videoActionMute": "Wycisz",
"@videoActionMute": {},
"videoActionUnmute": "Wyłącz wyciszenie",
"@videoActionUnmute": {},
"videoActionPause": "Pauza",
"@videoActionPause": {},
"videoActionPlay": "Graj",
"@videoActionPlay": {},
"videoActionReplay10": "Przewiń do tyłu 10 sekund",
"@videoActionReplay10": {},
"videoActionSkip10": "Przewiń do przodu 10 sekund",
"@videoActionSkip10": {},
"videoActionSelectStreams": "Wybierz ścieżki",
"@videoActionSelectStreams": {},
"videoActionSetSpeed": "Prędkość odtwarzania",
"@videoActionSetSpeed": {},
"videoActionSettings": "Ustawienia",
"@videoActionSettings": {},
"slideshowActionResume": "Wznów",
"@slideshowActionResume": {},
"slideshowActionShowInCollection": "Pokaż w Kolekcji",
"@slideshowActionShowInCollection": {},
"entryInfoActionEditDate": "Edytuj datę & godzinę",
"@entryInfoActionEditDate": {},
"entryInfoActionEditLocation": "Edytuj lokalizację",
"@entryInfoActionEditLocation": {},
"entryInfoActionEditTitleDescription": "Edytuj tytuł & opis",
"@entryInfoActionEditTitleDescription": {},
"entryInfoActionEditRating": "Edytuj ocenę",
"@entryInfoActionEditRating": {},
"entryInfoActionEditTags": "Edytuj tagi",
"@entryInfoActionEditTags": {},
"entryInfoActionRemoveMetadata": "Usuń metadatę",
"@entryInfoActionRemoveMetadata": {},
"filterBinLabel": "Kosz",
"@filterBinLabel": {},
"filterFavouriteLabel": "Ulubione",
"@filterFavouriteLabel": {},
"filterNoDateLabel": "Niedatowany",
"@filterNoDateLabel": {},
"filterNoRatingLabel": "Nieoceniony",
"@filterNoRatingLabel": {},
"filterNoTagLabel": "Nieoznakowany",
"@filterNoTagLabel": {},
"filterNoTitleLabel": "Bez tytułu",
"@filterNoTitleLabel": {},
"filterOnThisDayLabel": "Tego dnia",
"@filterOnThisDayLabel": {},
"filterRecentlyAddedLabel": "Ostatnio dodany",
"@filterRecentlyAddedLabel": {},
"filterTypeMotionPhotoLabel": "Ruchome Zdjęcie",
"@filterTypeMotionPhotoLabel": {},
"filterTypePanoramaLabel": "Zdjęcie sferyczne",
"@filterTypePanoramaLabel": {},
"entryActionFlip": "Obróć w poziomie",
"@entryActionFlip": {},
"entryActionShare": "Udostępnij",
"@entryActionShare": {},
"entryActionViewSource": "Pokaż źródło",
"@entryActionViewSource": {},
"entryActionOpenMap": "Pokaż w aplikacji mapy",
"@entryActionOpenMap": {},
"sourceStateCataloguing": "Katalogowanie",
"@sourceStateCataloguing": {},
"sourceStateLocatingPlaces": "Lokowanie miejsc",
"@sourceStateLocatingPlaces": {},
"chipActionFilterOut": "Odfiltruj",
"@chipActionFilterOut": {},
"chipActionCreateAlbum": "Utwórz album",
"@chipActionCreateAlbum": {},
"entryActionRotateCCW": "Obróć w lewo",
"@entryActionRotateCCW": {},
"chipActionDelete": "Usuń",
"@chipActionDelete": {},
"chipActionGoToAlbumPage": "Pokaż w Albumach",
"@chipActionGoToAlbumPage": {},
"chipActionGoToTagPage": "Pokaż w Tagach",
"@chipActionGoToTagPage": {},
"entryActionCopyToClipboard": "Skopiuj do schowka",
"@entryActionCopyToClipboard": {},
"chipActionHide": "Schowaj",
"@chipActionHide": {},
"entryActionPrint": "Drukuj",
"@entryActionPrint": {},
"entryActionShowGeoTiffOnMap": "Pokaż jako nakładkę mapy",
"@entryActionShowGeoTiffOnMap": {},
"entryActionConvertMotionPhotoToStillImage": "Konwertuj na nieruchomy obraz",
"@entryActionConvertMotionPhotoToStillImage": {},
"entryActionEdit": "Edycja",
"@entryActionEdit": {},
"entryActionOpen": "Otwórz z",
"@entryActionOpen": {},
"entryActionViewMotionPhotoVideo": "Otwórz film",
"@entryActionViewMotionPhotoVideo": {},
"entryActionRotateScreen": "Obróć ekran",
"@entryActionRotateScreen": {},
"entryActionRemoveFavourite": "Usuń z ulubionych",
"@entryActionRemoveFavourite": {},
"entryActionSetAs": "Ustaw jako",
"@entryActionSetAs": {},
"entryActionAddFavourite": "Dodaj do ulubionych",
"@entryActionAddFavourite": {},
"filterNoLocationLabel": "Nieumiejscowiony",
"@filterNoLocationLabel": {},
"filterRatingRejectedLabel": "Odrzucony",
"@filterRatingRejectedLabel": {},
"filterTypeAnimatedLabel": "Animowany",
"@filterTypeAnimatedLabel": {},
"entryActionRename": "Zmień nazwę",
"@entryActionRename": {},
"entryActionRotateCW": "Obróć w prawo",
"@entryActionRotateCW": {},
"entryActionInfo": "Informacje",
"@entryActionInfo": {},
"entryActionRestore": "Przywróć",
"@entryActionRestore": {}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,4 @@
import 'package:aves/model/device.dart';
import 'package:aves/model/settings/enums/map_style.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves_map/aves_map.dart';
@ -40,10 +41,8 @@ class LiveAvesAvailability implements AvesAvailability {
}
}
// local geocoding with `geocoder` seems to require Google Play Services
// what about devices with Huawei Mobile Services?
@override
Future<bool> get canLocatePlaces async => mobileServices.isServiceAvailable && await isConnected;
Future<bool> get canLocatePlaces async => device.hasGeocoder && await isConnected;
@override
List<EntryMapStyle> get mapStyles => [

View file

@ -55,14 +55,14 @@ class Covers {
final oldRows = _rows.where((row) => row.filter == filter).toSet();
_rows.removeAll(oldRows);
await metadataDb.removeCovers({filter});
final oldRow = oldRows.firstOrNull;
final oldEntry = oldRow?.entryId;
final oldPackage = oldRow?.packageName;
final oldColor = oldRow?.color;
if (entryId == null && packageName == null && color == null) {
await metadataDb.removeCovers({filter});
} else {
if (entryId != null || packageName != null || color != null) {
final row = CoverRow(
filter: filter,
entryId: entryId,

View file

@ -100,7 +100,7 @@ class SqfliteMetadataDb implements MetadataDb {
')');
},
onUpgrade: MetadataDbUpgrader.upgradeDb,
version: 9,
version: 10,
);
final maxIdRows = await _db.rawQuery('SELECT max(id) AS maxId FROM $entryTable');
@ -470,9 +470,23 @@ class SqfliteMetadataDb implements MetadataDb {
Future<void> removeCovers(Set<CollectionFilter> filters) async {
if (filters.isEmpty) return;
// for backward compatibility, remove stored JSON instead of removing de/reserialized filters
final obsoleteFilterJson = <String>{};
final rows = await _db.query(coverTable);
rows.forEach((row) {
final filterJson = row['filter'] as String?;
if (filterJson != null) {
final filter = CollectionFilter.fromJson(filterJson);
if (filters.any((v) => filter == v)) {
obsoleteFilterJson.add(filterJson);
}
}
});
// using array in `whereArgs` and using it with `where filter IN ?` is a pain, so we prefer `batch` instead
final batch = _db.batch();
filters.forEach((filter) => batch.delete(coverTable, where: 'filter = ?', whereArgs: [filter.toJson()]));
obsoleteFilterJson.forEach((filterJson) => batch.delete(coverTable, where: 'filter = ?', whereArgs: [filterJson]));
await batch.commit(noResult: true);
}

View file

@ -1,4 +1,5 @@
import 'package:aves/model/db/db_metadata_sqflite.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart';
@ -41,6 +42,9 @@ class MetadataDbUpgrader {
case 8:
await _upgradeFrom8(db);
break;
case 9:
await _upgradeFrom9(db);
break;
}
oldVersion++;
}
@ -334,4 +338,36 @@ class MetadataDbUpgrader {
await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;');
});
}
static Future<void> _upgradeFrom9(Database db) async {
debugPrint('upgrading DB from v9');
// clean duplicates introduced before Aves v1.7.1
final duplicatedContentIdRows = await db.query(entryTable, columns: ['contentId'], groupBy: 'contentId', having: 'COUNT(id) > 1 AND contentId IS NOT NULL');
final duplicatedContentIds = duplicatedContentIdRows.map((row) => row['contentId'] as int?).whereNotNull().toSet();
final duplicateIds = <int>{};
await Future.forEach(duplicatedContentIds, (contentId) async {
final rows = await db.query(entryTable, columns: ['id'], where: 'contentId = ?', whereArgs: [contentId]);
final ids = rows.map((row) => row['id'] as int?).whereNotNull().toList()..sort();
if (ids.length > 1) {
ids.removeAt(0);
duplicateIds.addAll(ids);
}
});
final batch = db.batch();
const where = 'id = ?';
const coverWhere = 'entryId = ?';
duplicateIds.forEach((id) {
final whereArgs = [id];
batch.delete(entryTable, where: where, whereArgs: whereArgs);
batch.delete(dateTakenTable, where: where, whereArgs: whereArgs);
batch.delete(metadataTable, where: where, whereArgs: whereArgs);
batch.delete(addressTable, where: where, whereArgs: whereArgs);
batch.delete(favouriteTable, where: where, whereArgs: whereArgs);
batch.delete(coverTable, where: coverWhere, whereArgs: whereArgs);
batch.delete(trashTable, where: where, whereArgs: whereArgs);
batch.delete(videoPlaybackTable, where: where, whereArgs: whereArgs);
});
await batch.commit(noResult: true);
}
}

View file

@ -5,8 +5,8 @@ final Device device = Device._private();
class Device {
late final String _userAgent;
late final bool _canGrantDirectoryAccess, _canPinShortcut, _canPrint, _canRenderFlagEmojis, _canSetLockScreenWallpaper;
late final bool _isDynamicColorAvailable, _showPinShortcutFeedback, _supportEdgeToEdgeUIMode;
late final bool _canGrantDirectoryAccess, _canPinShortcut, _canPrint, _canRenderFlagEmojis, _canRequestManageMedia, _canSetLockScreenWallpaper;
late final bool _hasGeocoder, _isDynamicColorAvailable, _showPinShortcutFeedback, _supportEdgeToEdgeUIMode;
String get userAgent => _userAgent;
@ -18,8 +18,12 @@ class Device {
bool get canRenderFlagEmojis => _canRenderFlagEmojis;
bool get canRequestManageMedia => _canRequestManageMedia;
bool get canSetLockScreenWallpaper => _canSetLockScreenWallpaper;
bool get hasGeocoder => _hasGeocoder;
bool get isDynamicColorAvailable => _isDynamicColorAvailable;
bool get showPinShortcutFeedback => _showPinShortcutFeedback;
@ -37,7 +41,9 @@ class Device {
_canPinShortcut = capabilities['canPinShortcut'] ?? false;
_canPrint = capabilities['canPrint'] ?? false;
_canRenderFlagEmojis = capabilities['canRenderFlagEmojis'] ?? false;
_canRequestManageMedia = capabilities['canRequestManageMedia'] ?? false;
_canSetLockScreenWallpaper = capabilities['canSetLockScreenWallpaper'] ?? false;
_hasGeocoder = capabilities['hasGeocoder'] ?? false;
_isDynamicColorAvailable = capabilities['isDynamicColorAvailable'] ?? false;
_showPinShortcutFeedback = capabilities['showPinShortcutFeedback'] ?? false;
_supportEdgeToEdgeUIMode = capabilities['supportEdgeToEdgeUIMode'] ?? false;

View file

@ -47,7 +47,7 @@ class AvesEntry {
List<AvesEntry>? burstEntries;
final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier();
final AChangeNotifier visualChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier();
AvesEntry({
required int? id,
@ -176,7 +176,7 @@ class AvesEntry {
}
void dispose() {
imageChangeNotifier.dispose();
visualChangeNotifier.dispose();
metadataChangeNotifier.dispose();
addressChangeNotifier.dispose();
}
@ -284,7 +284,7 @@ class AvesEntry {
bool get canEditDate => canEdit && (canEditExif || canEditXmp);
bool get canEditLocation => canEdit && canEditExif;
bool get canEditLocation => canEdit && (canEditExif || mimeType == MimeTypes.mp4);
bool get canEditTitleDescription => canEdit && canEditXmp;
@ -292,56 +292,17 @@ class AvesEntry {
bool get canEditTags => canEdit && canEditXmp;
bool get canRotateAndFlip => canEdit && canEditExif;
bool get canRotate => canEdit && (canEditExif || mimeType == MimeTypes.mp4);
// `exifinterface` v1.3.3 declared support for DNG, but it strips non-standard Exif tags when saving attributes,
// and DNG requires DNG-specific tags saved along standard Exif. So it was actually breaking DNG files.
// as of androidx.exifinterface:exifinterface:1.3.4
bool get canEditExif {
switch (mimeType.toLowerCase()) {
case MimeTypes.jpeg:
case MimeTypes.png:
case MimeTypes.webp:
return true;
default:
return false;
}
}
bool get canFlip => canEdit && canEditExif;
// as of latest PixyMeta
bool get canEditIptc {
switch (mimeType.toLowerCase()) {
case MimeTypes.jpeg:
case MimeTypes.tiff:
return true;
default:
return false;
}
}
bool get canEditExif => MimeTypes.canEditExif(mimeType);
// as of latest PixyMeta
bool get canEditXmp {
switch (mimeType.toLowerCase()) {
case MimeTypes.gif:
case MimeTypes.jpeg:
case MimeTypes.png:
case MimeTypes.tiff:
return true;
default:
return false;
}
}
bool get canEditIptc => MimeTypes.canEditIptc(mimeType);
// as of latest PixyMeta
bool get canRemoveMetadata {
switch (mimeType.toLowerCase()) {
case MimeTypes.jpeg:
case MimeTypes.tiff:
return true;
default:
return false;
}
}
bool get canEditXmp => MimeTypes.canEditXmp(mimeType);
bool get canRemoveMetadata => MimeTypes.canRemoveMetadata(mimeType);
// Media Store size/rotation is inaccurate, e.g. a portrait FHD video is rotated according to its metadata,
// so it should be registered as width=1920, height=1080, orientation=90,
@ -753,7 +714,7 @@ class AvesEntry {
) async {
if ((!MimeTypes.refersToSameType(oldMimeType, mimeType) && !MimeTypes.isVideo(oldMimeType)) || oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) {
await EntryCache.evict(uri, oldMimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
imageChangeNotifier.notify();
visualChangeNotifier.notify();
}
}

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