Merge branch 'develop'
This commit is contained in:
commit
f14e83ed8e
193 changed files with 15199 additions and 4262 deletions
2
.github/workflows/check.yml
vendored
2
.github/workflows/check.yml
vendored
|
@ -17,7 +17,7 @@ jobs:
|
|||
# Available versions may lag behind https://github.com/flutter/flutter.git
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.3.4'
|
||||
flutter-version: '3.3.8'
|
||||
channel: 'stable'
|
||||
|
||||
- name: Clone the repository.
|
||||
|
|
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
|
@ -19,7 +19,7 @@ jobs:
|
|||
# Available versions may lag behind https://github.com/flutter/flutter.git
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.3.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:
|
||||
|
|
24
CHANGELOG.md
24
CHANGELOG.md
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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) }
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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} = ?"
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 & videos</string>
|
||||
<string name="analysis_notification_default_title">Explorando medios</string>
|
||||
|
|
12
android/app/src/main/res/values-fa/strings.xml
Normal file
12
android/app/src/main/res/values-fa/strings.xml
Normal 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>
|
|
@ -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>
|
||||
|
|
12
android/app/src/main/res/values-gl/strings.xml
Normal file
12
android/app/src/main/res/values-gl/strings.xml
Normal file
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Aves</string>
|
||||
<string name="app_widget_label">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>
|
|
@ -9,4 +9,4 @@
|
|||
<string name="analysis_service_description">Scansione immagini & videos</string>
|
||||
<string name="analysis_notification_default_title">Scansione in corso</string>
|
||||
<string name="analysis_notification_action_stop">Annulla</string>
|
||||
</resources>
|
||||
</resources>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
12
android/app/src/main/res/values-nb-rNO/strings.xml
Normal file
12
android/app/src/main/res/values-nb-rNO/strings.xml
Normal 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>
|
12
android/app/src/main/res/values-pl/strings.xml
Normal file
12
android/app/src/main/res/values-pl/strings.xml
Normal 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 & 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>
|
|
@ -9,4 +9,4 @@
|
|||
<string name="analysis_service_description">Digitalizar imagens & vídeos</string>
|
||||
<string name="analysis_notification_default_title">Digitalizando mídia</string>
|
||||
<string name="analysis_notification_action_stop">Pare</string>
|
||||
</resources>
|
||||
</resources>
|
|
@ -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>
|
||||
|
|
|
@ -9,4 +9,4 @@
|
|||
<string name="analysis_service_description">扫描图像 & 视频</string>
|
||||
<string name="analysis_notification_default_title">正在扫描媒体库</string>
|
||||
<string name="analysis_notification_action_stop">停止</string>
|
||||
</resources>
|
||||
</resources>
|
|
@ -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'
|
||||
|
|
|
@ -1 +1 @@
|
|||
Συλλογή φωτογραφιών και εξερεύνηση των μεταδεδομένων.
|
||||
Συλλογή φωτογραφιών και εξερεύνηση των μεταδεδομένων
|
5
fastlane/metadata/android/en-US/changelogs/1082.txt
Normal file
5
fastlane/metadata/android/en-US/changelogs/1082.txt
Normal 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
|
|
@ -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>.
|
5
fastlane/metadata/android/fa/full_description.txt
Normal file
5
fastlane/metadata/android/fa/full_description.txt
Normal 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>.
|
1
fastlane/metadata/android/fa/short_description.txt
Normal file
1
fastlane/metadata/android/fa/short_description.txt
Normal file
|
@ -0,0 +1 @@
|
|||
پویشگر گالری و فراداده
|
5
fastlane/metadata/android/fr/full_description.txt
Normal file
5
fastlane/metadata/android/fr/full_description.txt
Normal file
|
@ -0,0 +1,5 @@
|
|||
<i>Aves</i> supporte toutes sortes d’images 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> s’intègre avec Android (de l’<b>API 19 à 33</b>, c’est-à-dire de KitKat à Android 13) avec des fonctionnalités telles que les <b>widgets</b>, les <b>raccourcis d’application</b>, <b>économiseur d’écran</b> et la <b>recherche globale</b>. Il est également possible de l’utiliser comme <b>visionneuse et sélecteur de médias</b>.
|
1
fastlane/metadata/android/fr/short_description.txt
Normal file
1
fastlane/metadata/android/fr/short_description.txt
Normal file
|
@ -0,0 +1 @@
|
|||
Galerie et explorateur de métadonnées
|
5
fastlane/metadata/android/gl/full_description.txt
Normal file
5
fastlane/metadata/android/gl/full_description.txt
Normal 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>.
|
1
fastlane/metadata/android/gl/short_description.txt
Normal file
1
fastlane/metadata/android/gl/short_description.txt
Normal file
|
@ -0,0 +1 @@
|
|||
Galería e explorador de metadatos
|
|
@ -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>.
|
|
@ -1 +1 @@
|
|||
Galleria e esploratore di metadati
|
||||
Galleria e esploratore di metadati
|
|
@ -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>としても機能します。
|
5
fastlane/metadata/android/ko/full_description.txt
Normal file
5
fastlane/metadata/android/ko/full_description.txt
Normal 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>.
|
1
fastlane/metadata/android/ko/short_description.txt
Normal file
1
fastlane/metadata/android/ko/short_description.txt
Normal file
|
@ -0,0 +1 @@
|
|||
Gallery and metadata explorer
|
5
fastlane/metadata/android/nb-NO/full_description.txt
Normal file
5
fastlane/metadata/android/nb-NO/full_description.txt
Normal 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>.
|
1
fastlane/metadata/android/nb-NO/short_description.txt
Normal file
1
fastlane/metadata/android/nb-NO/short_description.txt
Normal file
|
@ -0,0 +1 @@
|
|||
Galleri- og metadatautforsker
|
5
fastlane/metadata/android/nl/full_description.txt
Normal file
5
fastlane/metadata/android/nl/full_description.txt
Normal 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>.
|
1
fastlane/metadata/android/nl/short_description.txt
Normal file
1
fastlane/metadata/android/nl/short_description.txt
Normal file
|
@ -0,0 +1 @@
|
|||
Galerij en metagegevensverkenner
|
5
fastlane/metadata/android/pl/full_description.txt
Normal file
5
fastlane/metadata/android/pl/full_description.txt
Normal 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>.
|
1
fastlane/metadata/android/pl/short_description.txt
Normal file
1
fastlane/metadata/android/pl/short_description.txt
Normal file
|
@ -0,0 +1 @@
|
|||
Galeria i przeglądarka metadanych
|
|
@ -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>.
|
|
@ -1 +1 @@
|
|||
Galeria e explorador de metadados
|
||||
Galeria e explorador de metadados
|
5
fastlane/metadata/android/ru/full_description.txt
Normal file
5
fastlane/metadata/android/ru/full_description.txt
Normal 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>.
|
1
fastlane/metadata/android/ru/short_description.txt
Normal file
1
fastlane/metadata/android/ru/short_description.txt
Normal file
|
@ -0,0 +1 @@
|
|||
Медиа галерея с навигацией по метаданным
|
|
@ -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.
|
|
@ -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>。
|
|
@ -1 +1 @@
|
|||
相册和元数据浏览器
|
||||
相册和元数据浏览器
|
|
@ -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`
|
||||
|
|
|
@ -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}';
|
||||
}
|
||||
|
|
|
@ -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}';
|
||||
}
|
||||
|
|
1854
lib/l10n/app_de.arb
1854
lib/l10n/app_de.arb
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
@ -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
1
lib/l10n/app_fa.arb
Normal file
|
@ -0,0 +1 @@
|
|||
{}
|
File diff suppressed because it is too large
Load diff
302
lib/l10n/app_gl.arb
Normal file
302
lib/l10n/app_gl.arb
Normal 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
1818
lib/l10n/app_ja.arb
1818
lib/l10n/app_ja.arb
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
989
lib/l10n/app_nb.arb
Normal 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
188
lib/l10n/app_pl.arb
Normal 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
|
@ -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 => [
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
Loading…
Reference in a new issue