Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2022-04-19 10:16:03 +09:00
commit 6915ada4ab
162 changed files with 8466 additions and 907 deletions

View file

@ -12,10 +12,13 @@ jobs:
name: Check code quality.
runs-on: ubuntu-latest
steps:
- uses: subosito/flutter-action@v1
# Flutter SDK is pulled from https://storage.googleapis.com/flutter_infra_release/releases/releases_linux.json
# or, as displayed at https://docs.flutter.dev/development/tools/sdk/releases?tab=linux
# Available versions may lag behind https://github.com/flutter/flutter.git
- uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: '2.10.3'
flutter-version: '2.10.4'
channel: 'stable'
- name: Clone the repository.
uses: actions/checkout@v2

View file

@ -14,10 +14,13 @@ jobs:
with:
java-version: '11.x'
- uses: subosito/flutter-action@v1
# Flutter SDK is pulled from https://storage.googleapis.com/flutter_infra_release/releases/releases_linux.json
# or, as displayed at https://docs.flutter.dev/development/tools/sdk/releases?tab=linux
# Available versions may lag behind https://github.com/flutter/flutter.git
- uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: '2.10.3'
flutter-version: '2.10.4'
channel: 'stable'
# Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1):
# https://issuetracker.google.com/issues/144111441
@ -52,12 +55,12 @@ 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_2.10.3.sksl.json
flutter build appbundle -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_2.10.4.sksl.json
cp build/app/outputs/bundle/playRelease/*.aab outputs
flutter build apk -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_2.10.3.sksl.json
flutter build apk -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_2.10.4.sksl.json
cp build/app/outputs/apk/play/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_2.10.3.sksl.json
flutter build apk -t lib/main_izzy.dart --flavor izzy --split-per-abi --bundle-sksl-path shaders_2.10.4.sksl.json
cp build/app/outputs/apk/izzy/release/*.apk outputs
rm $AVES_STORE_FILE
env:

View file

@ -4,6 +4,29 @@ All notable changes to this project will be documented in this file.
## <a id="unreleased"></a>[Unreleased]
## <a id="v1.6.4"></a>[v1.6.4] - 2022-04-19
### Added
- Albums / Countries / Tags: allow custom app / color along cover item
- Info: improved GeoTIFF section
- Cataloguing: locating from GeoTIFF metadata (requires rescan, limited to some projections)
- Info: action to overlay GeoTIFF on map (limited to some projections)
- Info: action to convert motion photo to still image
- Italian translation (thanks glemco)
- Chinese (Simplified) translation (thanks 小默 & Aerowolf)
### Changed
- upgraded Flutter to stable v2.10.4
- snack bars are dismissible with an horizontal swipe instead of a down swipe
- Viewer: snack bars avoid quick actions and thumbnails at the bottom
### Fixed
- black screen launch when Firebase fails to initialize (Play version only)
- crash when cataloguing JPEG with large extended XMP
## <a id="v1.6.3"></a>[v1.6.3] - 2022-03-28
### Added

View file

@ -85,7 +85,7 @@ At this stage this project does *not* accept PRs, except for translations.
### Translations
If you want to translate this app in your language and share the result, [there is a guide](https://github.com/deckerst/aves/wiki/Contributing-to-Translations). English, Korean and French are already handled by me. Russian, German, Spanish, Portuguese, Indonesian & Japanese are handled by generous volunteers.
If you want to translate this app in your language and share the result, [there is a guide](https://github.com/deckerst/aves/wiki/Contributing-to-Translations). English, Korean and French are already handled by me. Russian, German, Spanish, Portuguese, Indonesian, Japanese, Italian & Chinese are handled by generous volunteers.
### Donations

View file

@ -8,7 +8,9 @@
So we request `WRITE_EXTERNAL_STORAGE` until Q (29), and enable `requestLegacyExternalStorage`
-->
<!-- TODO TLAD [tiramisu] need notification permission? -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- TODO TLAD [tiramisu] READ_MEDIA_IMAGE, READ_MEDIA_VIDEO instead of READ_EXTERNAL_STORAGE? -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
@ -37,12 +39,13 @@
<application
android:allowBackup="true"
android:appCategory="image"
android:fullBackupOnly="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"
tools:targetApi="lollipop">
tools:targetApi="o">
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"

View file

@ -16,6 +16,7 @@ import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.channel.calls.*
import deckers.thibault.aves.channel.streams.*
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.PermissionManager
import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
@ -324,7 +325,7 @@ class MainActivity : FlutterActivity() {
var pendingScopedStoragePermissionCompleter: CompletableFuture<Boolean>? = null
private fun onStorageAccessResult(requestCode: Int, uri: Uri?) {
Log.d(LOG_TAG, "onStorageAccessResult with requestCode=$requestCode, uri=$uri")
Log.i(LOG_TAG, "onStorageAccessResult with requestCode=$requestCode, uri=$uri")
val handler = pendingStorageAccessResultHandlers.remove(requestCode) ?: return
if (uri != null) {
handler.onGranted(uri)

View file

@ -33,10 +33,12 @@ import deckers.thibault.aves.utils.LogUtils
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.io.File
import java.util.*
import kotlin.collections.ArrayList
import kotlin.math.roundToInt
class AppAdapterHandler(private val context: Context) : MethodCallHandler {
@ -46,6 +48,7 @@ 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)
@ -107,8 +110,29 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
}
}
addPackageDetails(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER))
addPackageDetails(Intent(Intent.ACTION_MAIN))
// identify launcher category packages, which typically include user apps
// they should be fetched before the other packages, to be marked as launcher packages
try {
addPackageDetails(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER))
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to list launcher packages", e)
}
try {
// complete with all the other packages
addPackageDetails(Intent(Intent.ACTION_MAIN))
} catch (e: Exception) {
// `PackageManager.queryIntentActivities()` may kill the package manager if the response is too large
Log.w(LOG_TAG, "failed to list all packages", e)
// fallback to the default category packages, which typically include system and OEM tools
try {
addPackageDetails(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_DEFAULT))
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to list default packages", e)
}
}
result.success(ArrayList(packages.values))
}
@ -161,6 +185,23 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
}
}
private fun getAppInstaller(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
val packageName = context.packageName
val pm = context.packageManager
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val info = pm.getInstallSourceInfo(packageName)
result.success(info.initiatingPackageName ?: info.installingPackageName)
} else {
@Suppress("deprecation")
result.success(pm.getInstallerPackageName(packageName))
}
} catch (e: Exception) {
result.error("getAppInstaller-exception", "failed to get installer for packageName=$packageName", e.message)
return
}
}
private fun copyToClipboard(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val label = call.argument<String>("label")

View file

@ -13,13 +13,9 @@ import android.os.Looper
import android.provider.MediaStore
import android.util.Log
import androidx.exifinterface.media.ExifInterface
import com.drew.imaging.ImageMetadataReader
import com.drew.metadata.file.FileTypeDirectory
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.PixyMetaHelper
import deckers.thibault.aves.metadata.*
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface
@ -288,7 +284,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
val metadata = MetadataExtractorHelper.safeRead(input, sizeBytes)
metadataMap["mimeType"] = metadata.getDirectoriesOfType(FileTypeDirectory::class.java).joinToString { dir ->
if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) {
dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)

View file

@ -8,13 +8,11 @@ import androidx.exifinterface.media.ExifInterface
import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.XMPUtils
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
import com.drew.imaging.ImageMetadataReader
import com.drew.metadata.file.FileTypeDirectory
import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
import deckers.thibault.aves.metadata.MetadataExtractorHelper
import deckers.thibault.aves.metadata.MultiPage
import deckers.thibault.aves.metadata.XMP
import deckers.thibault.aves.metadata.XMP.getSafeStructField
@ -25,19 +23,21 @@ import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.extensionFor
import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface
import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor
import deckers.thibault.aves.utils.MimeTypes.extensionFor
import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.io.File
import java.io.InputStream
import java.util.*
class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@ -118,10 +118,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
retriever.embeddedPicture?.let { bytes ->
var embedMimeType: String? = null
bytes.inputStream().use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
metadata.getFirstDirectoryOfType(FileTypeDirectory::class.java)?.let { dir ->
dir.getSafeString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) { embedMimeType = it }
}
MetadataExtractorHelper.readMimeType(input)?.let { embedMimeType = it }
}
embedMimeType?.let { mime ->
copyEmbeddedBytes(result, mime, displayName, bytes.inputStream())
@ -153,7 +150,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
val metadata = MetadataExtractorHelper.safeRead(input, sizeBytes)
// data can be large and stored in "Extended XMP",
// which is returned as a second XMP directory
val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java)

View file

@ -10,7 +10,10 @@ import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@ -21,6 +24,7 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
"flip" -> ioScope.launch { safe(call, result, ::flip) }
"editDate" -> ioScope.launch { safe(call, result, ::editDate) }
"editMetadata" -> ioScope.launch { safe(call, result, ::editMetadata) }
"removeTrailerVideo" -> ioScope.launch { safe(call, result, ::removeTrailerVideo) }
"removeTypes" -> ioScope.launch { safe(call, result, ::removeTypes) }
else -> result.notImplemented()
}
@ -101,7 +105,8 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
private fun editMetadata(call: MethodCall, result: MethodChannel.Result) {
val metadata = call.argument<FieldMap>("metadata")
val entryMap = call.argument<FieldMap>("entry")
if (entryMap == null || metadata == null) {
val autoCorrectTrailerOffset = call.argument<Boolean>("autoCorrectTrailerOffset")
if (entryMap == null || metadata == null || autoCorrectTrailerOffset == null) {
result.error("editMetadata-args", "failed because of missing arguments", null)
return
}
@ -120,12 +125,39 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
return
}
provider.editMetadata(activity, path, uri, mimeType, metadata, callback = object : ImageOpCallback {
provider.editMetadata(activity, 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)
})
}
private fun removeTrailerVideo(call: MethodCall, result: MethodChannel.Result) {
val entryMap = call.argument<FieldMap>("entry")
if (entryMap == null) {
result.error("removeTrailerVideo-args", "failed because of missing arguments", null)
return
}
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
val path = entryMap["path"] as String?
val mimeType = entryMap["mimeType"] as String?
if (uri == null || path == null || mimeType == null) {
result.error("removeTrailerVideo-args", "failed because entry fields are missing", null)
return
}
val provider = getProvider(uri)
if (provider == null) {
result.error("removeTrailerVideo-provider", "failed to find provider for uri=$uri", null)
return
}
provider.removeTrailerVideo(activity, 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)
})
}
private fun removeTypes(call: MethodCall, result: MethodChannel.Result) {
val types = call.argument<List<String>>("types")
val entryMap = call.argument<FieldMap>("entry")

View file

@ -14,7 +14,6 @@ import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.XMPMetaFactory
import com.adobe.internal.xmp.options.SerializeOptions
import com.adobe.internal.xmp.properties.XMPPropertyInfo
import com.drew.imaging.ImageMetadataReader
import com.drew.lang.KeyValuePair
import com.drew.lang.Rational
import com.drew.metadata.Tag
@ -40,12 +39,16 @@ import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeRational
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDescription
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt
import deckers.thibault.aves.metadata.Metadata.DIR_DNG
import deckers.thibault.aves.metadata.Metadata.DIR_EXIF_GEOTIFF
import deckers.thibault.aves.metadata.Metadata.DIR_PNG_TEXTUAL_DATA
import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
import deckers.thibault.aves.metadata.Metadata.isFlippedForExifCode
import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_ITXT_DIR_NAME
import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_LAST_MODIFICATION_TIME_FORMAT
import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_TIME_DIR_NAME
import deckers.thibault.aves.metadata.MetadataExtractorHelper.containsGeoTiffTags
import deckers.thibault.aves.metadata.MetadataExtractorHelper.extractGeoKeys
import deckers.thibault.aves.metadata.MetadataExtractorHelper.extractPngProfile
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getDateDigitizedMillis
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getDateModifiedMillis
@ -55,7 +58,6 @@ import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeRational
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
import deckers.thibault.aves.metadata.MetadataExtractorHelper.isGeoTiff
import deckers.thibault.aves.metadata.MetadataExtractorHelper.isPngTextDir
import deckers.thibault.aves.metadata.XMP.getSafeDateMillis
import deckers.thibault.aves.metadata.XMP.getSafeInt
@ -83,6 +85,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets
import java.text.DecimalFormat
import java.text.ParseException
import kotlin.math.roundToInt
import kotlin.math.roundToLong
@ -95,6 +98,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
"getAllMetadata" -> ioScope.launch { safe(call, result, ::getAllMetadata) }
"getCatalogMetadata" -> ioScope.launch { safe(call, result, ::getCatalogMetadata) }
"getOverlayMetadata" -> ioScope.launch { safe(call, result, ::getOverlayMetadata) }
"getGeoTiffInfo" -> ioScope.launch { safe(call, result, ::getGeoTiffInfo) }
"getMultiPageInfo" -> ioScope.launch { safe(call, result, ::getMultiPageInfo) }
"getPanoramaInfo" -> ioScope.launch { safe(call, result, ::getPanoramaInfo) }
"getIptc" -> ioScope.launch { safe(call, result, ::getIptc) }
@ -122,12 +126,12 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
val metadata = MetadataExtractorHelper.safeRead(input, sizeBytes)
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.tagCount > 0 || it.errorCount > 0)
&& it !is FileTypeDirectory
&& it !is AviDirectory
}.groupBy { dir -> dir.name }
@ -168,76 +172,95 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
// tags
val tags = dir.tags
if (dir is ExifDirectoryBase) {
when {
dir.isGeoTiff() -> {
// split GeoTIFF tags in their own directory
val geoTiffDirMap = metadataMap["GeoTIFF"] ?: HashMap()
metadataMap["GeoTIFF"] = geoTiffDirMap
val byGeoTiff = tags.groupBy { ExifTags.isGeoTiffTag(it.tagType) }
byGeoTiff[true]?.map { exifTagMapper(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["DNG"] ?: HashMap()
metadataMap["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) })
}
} else if (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) })
} else {
profileDirMap.putAll(profileTags.map { Pair(it.tagName, it.description) })
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))
}
null
} else {
Pair(key, valueString)
}
}?.let { geoTiffDirMap.putAll(it) }
byGeoTiff[false]?.map { exifTagMapper(it) }?.let { dirMap.putAll(it) }
}
dirMap.putAll(textPairs.filterNotNull())
} else {
dirMap[tag.tagName] = tag.description
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) })
}
}
} else {
dirMap.putAll(tags.map { Pair(it.tagName, it.description) })
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) })
} else {
profileDirMap.putAll(profileTags.map { Pair(it.tagName, it.description) })
}
}
null
} else {
Pair(key, valueString)
}
}
dirMap.putAll(textPairs.filterNotNull())
} else {
dirMap[tag.tagName] = tag.description
}
}
}
else -> dirMap.putAll(tags.map { Pair(it.tagName, it.description) })
}
if (dir is XmpDirectory) {
try {
for (prop in dir.xmpMeta) {
@ -296,13 +319,18 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
}
// include errors, if any
dir.errors.forEachIndexed { i, error ->
dirMap["Error[$i]"] = error
}
}
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e)
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e)
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
}
}
@ -421,7 +449,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
val metadata = MetadataExtractorHelper.safeRead(input, sizeBytes)
foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 }
// File type
@ -557,7 +585,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
MimeTypes.TIFF -> {
// identification of GeoTIFF
for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
if (dir.isGeoTiff()) flags = flags or MASK_IS_GEOTIFF
if (dir.containsGeoTiffTags()) flags = flags or MASK_IS_GEOTIFF
}
}
}
@ -570,9 +598,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get catalog metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to get catalog metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
}
}
@ -686,7 +714,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
val metadata = MetadataExtractorHelper.safeRead(input, sizeBytes)
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
foundExif = true
dir.getSafeRational(ExifDirectoryBase.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it.numerator.toDouble() / it.denominator }
@ -696,9 +724,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
}
}
@ -722,6 +750,45 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
result.success(metadataMap)
}
private fun getGeoTiffInfo(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getGeoTiffInfo-args", "failed because of missing arguments", null)
return
}
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = MetadataExtractorHelper.safeRead(input, sizeBytes)
val fields = HashMap<Int, Any?>()
for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
if (dir.containsGeoTiffTags()) {
fields.putAll(dir.tags.map { it.tagType }.filter { ExifTags.isGeoTiffTag(it) }.map {
val value = when (it) {
ExifGeoTiffTags.TAG_GEO_ASCII_PARAMS -> dir.getString(it)
else -> dir.getObject(it)
}
Pair(it, value)
})
val geoKeyDirectory = dir.getIntArray(ExifGeoTiffTags.TAG_GEO_KEY_DIRECTORY)
fields.putAll((dir as ExifIFD0Directory).extractGeoKeys(geoKeyDirectory))
}
}
result.success(fields)
return
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
}
}
result.error("getGeoTiffInfo-empty", "failed to get info for mimeType=$mimeType uri=$uri", null)
}
private fun getMultiPageInfo(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
@ -756,7 +823,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
val metadata = MetadataExtractorHelper.safeRead(input, sizeBytes)
val fields: FieldMap = hashMapOf(
"projectionType" to XMP.GPANO_PROJECTION_TYPE_DEFAULT,
)
@ -774,12 +841,12 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
return
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to read XMP", e)
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to read XMP", e)
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
}
}
result.error("getPanoramaInfo-empty", "failed to read XMP for mimeType=$mimeType uri=$uri", null)
result.error("getPanoramaInfo-empty", "failed to get info for mimeType=$mimeType uri=$uri", null)
}
private fun getIptc(call: MethodCall, result: MethodChannel.Result) {
@ -818,7 +885,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
val metadata = MetadataExtractorHelper.safeRead(input, sizeBytes)
val xmpStrings = metadata.getDirectoriesOfType(XmpDirectory::class.java).mapNotNull { XMPMetaFactory.serializeToString(it.xmpMeta, xmpSerializeOptions) }
result.success(xmpStrings.toMutableList())
return
@ -920,7 +987,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
val metadata = MetadataExtractorHelper.safeRead(input, sizeBytes)
val tag = when (field) {
ExifInterface.TAG_DATETIME -> ExifIFD0Directory.TAG_DATETIME
ExifInterface.TAG_DATETIME_DIGITIZED -> ExifSubIFDDirectory.TAG_DATETIME_DIGITIZED
@ -961,9 +1028,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
}
}
@ -974,6 +1041,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
private val LOG_TAG = LogUtils.createTag<MetadataFetchHandler>()
const val CHANNEL = "deckers.thibault/aves/metadata_fetch"
private val doubleFormat = DecimalFormat("0.###")
private val allMetadataRedundantDirNames = setOf(
"MP4",
"MP4 Metadata",

View file

@ -14,6 +14,7 @@ import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.CoroutineScope
@ -154,7 +155,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
return
}
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
destinationDir = ensureTrailingSeparator(destinationDir)
val entries = entryMapList.map(::AvesEntry)
provider.exportMultiple(activity, mimeType, destinationDir, entries, width, height, nameConflictStrategy, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = success(fields)
@ -181,7 +182,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
rawEntryMap.forEach {
var destinationDir = it.key as String
if (destinationDir != StorageUtils.TRASH_PATH_PLACEHOLDER) {
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
destinationDir = ensureTrailingSeparator(destinationDir)
}
@Suppress("unchecked_cast")
val rawEntries = it.value as List<FieldMap>

View file

@ -14,13 +14,13 @@ import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.PermissionManager
import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.io.FileOutputStream
// starting activity to give access with the native dialog
// breaks the regular `MethodChannel` so we use a stream channel instead
@ -64,7 +64,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
return
}
PermissionManager.requestDirectoryAccess(activity, path, {
PermissionManager.requestDirectoryAccess(activity, ensureTrailingSeparator(path), {
success(true)
endOfStream()
}, {
@ -123,10 +123,8 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
MainActivity.pendingStorageAccessResultHandlers[MainActivity.CREATE_FILE_REQUEST] = PendingStorageAccessResultHandler(null, { uri ->
ioScope.launch {
try {
activity.contentResolver.openOutputStream(uri)?.use { output ->
output as FileOutputStream
// truncate is necessary when overwriting a longer file
output.channel.truncate(0)
// truncate is necessary when overwriting a longer file
activity.contentResolver.openOutputStream(uri, "wt")?.use { output ->
output.write(bytes)
}
success(true)

View file

@ -8,48 +8,54 @@ https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/
https://www.adobe.io/content/dam/udp/en/open/standards/tiff/TIFFphotoshop.pdf
*/
object ExifTags {
private const val TAG_X_POSITION = 0x011e
private const val TAG_Y_POSITION = 0x011f
private const val TAG_T4_OPTIONS = 0x0124
private const val TAG_T6_OPTIONS = 0x0125
private const val TAG_COLOR_MAP = 0x0140
private const val TAG_EXTRA_SAMPLES = 0x0152
private const val TAG_SAMPLE_FORMAT = 0x0153
private const val TAG_RATING_PERCENT = 0x4749
private const val PROCESSING_SOFTWARE = 0x000b
private const val X_POSITION = 0x011e
private const val Y_POSITION = 0x011f
private const val T4_OPTIONS = 0x0124
private const val T6_OPTIONS = 0x0125
private const val COLOR_MAP = 0x0140
private const val EXTRA_SAMPLES = 0x0152
private const val SAMPLE_FORMAT = 0x0153
private const val SMIN_SAMPLE_VALUE = 0x0154
private const val SMAX_SAMPLE_VALUE = 0x0155
private const val RATING_PERCENT = 0x4749
private const val SONY_RAW_FILE_TYPE = 0x7000
private const val SONY_TONE_CURVE = 0x7010
private const val TAG_MATTEING = 0x80e3
private const val MATTEING = 0x80e3
// sensing method (0x9217) redundant with sensing method (0xA217)
private const val TAG_SENSING_METHOD = 0x9217
private const val TAG_IMAGE_SOURCE_DATA = 0x935c
private const val TAG_GDAL_METADATA = 0xa480
private const val TAG_GDAL_NO_DATA = 0xa481
private const val SENSING_METHOD = 0x9217
private const val IMAGE_SOURCE_DATA = 0x935c
private const val GDAL_METADATA = 0xa480
private const val GDAL_NO_DATA = 0xa481
private val tagNameMap = hashMapOf(
TAG_X_POSITION to "X Position",
TAG_Y_POSITION to "Y Position",
TAG_T4_OPTIONS to "T4 Options",
TAG_T6_OPTIONS to "T6 Options",
TAG_COLOR_MAP to "Color Map",
TAG_EXTRA_SAMPLES to "Extra Samples",
TAG_SAMPLE_FORMAT to "Sample Format",
TAG_RATING_PERCENT to "Rating Percent",
PROCESSING_SOFTWARE to "Processing Software",
X_POSITION to "X Position",
Y_POSITION to "Y Position",
T4_OPTIONS to "T4 Options",
T6_OPTIONS to "T6 Options",
COLOR_MAP to "Color Map",
EXTRA_SAMPLES to "Extra Samples",
SAMPLE_FORMAT to "Sample Format",
SMIN_SAMPLE_VALUE to "S Min Sample Value",
SMAX_SAMPLE_VALUE to "S Max Sample Value",
RATING_PERCENT to "Rating Percent",
SONY_RAW_FILE_TYPE to "Sony Raw File Type",
SONY_TONE_CURVE to "Sony Tone Curve",
TAG_MATTEING to "Matteing",
TAG_SENSING_METHOD to "Sensing Method (0x9217)",
TAG_IMAGE_SOURCE_DATA to "Image Source Data",
TAG_GDAL_METADATA to "GDAL Metadata",
TAG_GDAL_NO_DATA to "GDAL No Data",
MATTEING to "Matteing",
SENSING_METHOD to "Sensing Method (0x9217)",
IMAGE_SOURCE_DATA to "Image Source Data",
GDAL_METADATA to "GDAL Metadata",
GDAL_NO_DATA to "GDAL No Data",
).apply {
putAll(DngTags.tagNameMap)
putAll(GeoTiffTags.tagNameMap)
putAll(ExifGeoTiffTags.tagNameMap)
}
fun isDngTag(tag: Int) = DngTags.tags.contains(tag)
fun isGeoTiffTag(tag: Int) = GeoTiffTags.tags.contains(tag)
fun isGeoTiffTag(tag: Int) = ExifGeoTiffTags.tags.contains(tag)
fun getTagName(tag: Int): String? {
return tagNameMap[tag]

View file

@ -1,17 +1,83 @@
package deckers.thibault.aves.metadata
object GeoTiffTags {
object GeoTiffKeys {
// not a standard tag
const val GEOTIFF_VERSION = 0
private const val MODEL_TYPE = 0x0400
private const val RASTER_TYPE = 0x0401
private const val CITATION = 0x0402
private const val GEOG_TYPE = 0x0800
private const val GEOG_CITATION = 0x0801
private const val GEOG_GEODETIC_DATUM = 0x0802
private const val GEOG_LINEAR_UNITS = 0x0804
private const val GEOG_ANGULAR_UNITS = 0x0806
private const val GEOG_ELLIPSOID = 0x0808
private const val GEOG_SEMI_MAJOR_AXIS = 0x0809
private const val GEOG_SEMI_MINOR_AXIS = 0x080a
private const val GEOG_INV_FLATTENING = 0x080b
private const val PROJ_CS_TYPE = 0x0c00
private const val PROJ_CS_CITATION = 0x0c01
private const val PROJECTION = 0x0c02
private const val PROJ_COORD_TRANS = 0x0c03
private const val PROJ_LINEAR_UNITS = 0x0c04
private const val PROJ_STD_PARALLEL_1 = 0x0c06
private const val PROJ_STD_PARALLEL_2 = 0x0c07
private const val PROJ_NAT_ORIGIN_LONG = 0x0c08
private const val PROJ_NAT_ORIGIN_LAT = 0x0c09
private const val PROJ_FALSE_EASTING = 0x0c0a
private const val PROJ_FALSE_NORTHING = 0x0c0b
private const val PROJ_SCALE_AT_NAT_ORIGIN = 0x0c14
private const val PROJ_AZIMUTH_ANGLE = 0x0c16
private const val VERTICAL_UNITS = 0x1003
private val tagNameMap = hashMapOf(
GEOTIFF_VERSION to "GeoTIFF Version",
MODEL_TYPE to "Model Type",
RASTER_TYPE to "Raster Type",
CITATION to "Citation",
GEOG_TYPE to "Geographic Type",
GEOG_CITATION to "Geographic Citation",
GEOG_GEODETIC_DATUM to "Geographic Geodetic Datum",
GEOG_LINEAR_UNITS to "Geographic Linear Units",
GEOG_ANGULAR_UNITS to "Geographic Angular Units",
GEOG_ELLIPSOID to "Geographic Ellipsoid",
GEOG_SEMI_MAJOR_AXIS to "Semi-major axis",
GEOG_SEMI_MINOR_AXIS to "Semi-minor axis",
GEOG_INV_FLATTENING to "Inv. Flattening",
PROJ_CS_TYPE to "Projected Coordinate System Type",
PROJ_CS_CITATION to "Projected Coordinate System Citation",
PROJECTION to "Projection",
PROJ_COORD_TRANS to "Projected Coordinate Transform",
PROJ_LINEAR_UNITS to "Projection Linear Units",
PROJ_STD_PARALLEL_1 to "Projection Standard Parallel 1",
PROJ_STD_PARALLEL_2 to "Projection Standard Parallel 2",
PROJ_NAT_ORIGIN_LONG to "Projection Natural Origin Longitude",
PROJ_NAT_ORIGIN_LAT to "Projection Natural Origin Latitude",
PROJ_FALSE_EASTING to "Projection False Easting",
PROJ_FALSE_NORTHING to "Projection False Northing",
PROJ_SCALE_AT_NAT_ORIGIN to "Projection Scale at Natural Origin",
PROJ_AZIMUTH_ANGLE to "Projection Azimuth Angle",
VERTICAL_UNITS to "Vertical Units",
)
fun getTagName(tag: Int): String? {
return tagNameMap[tag]
}
}
object ExifGeoTiffTags {
// ModelPixelScaleTag (optional)
// Tag = 33550 (830E.H)
// Type = DOUBLE
// Count = 3
const val TAG_MODEL_PIXEL_SCALE = 0x830e
// ModelTiepointTag (conditional)
// ModelTiePointTag (conditional)
// Tag = 33922 (8482.H)
// Type = DOUBLE
// Count = 6*K, K = number of tiepoints
const val TAG_MODEL_TIEPOINT = 0x8482
// Count = 6*K, K = number of tie points
const val TAG_MODEL_TIE_POINT = 0x8482
// ModelTransformationTag (conditional)
// Tag = 34264 (85D8.H)
@ -29,22 +95,22 @@ object GeoTiffTags {
// Tag = 34736 (87BO.H)
// Type = DOUBLE
// Count = variable
private const val TAG_GEO_DOUBLE_PARAMS = 0x87b0
const val TAG_GEO_DOUBLE_PARAMS = 0x87b0
// GeoAsciiParamsTag (optional)
// Tag = 34737 (87B1.H)
// Type = ASCII
// Count = variable
private const val TAG_GEO_ASCII_PARAMS = 0x87b1
const val TAG_GEO_ASCII_PARAMS = 0x87b1
val tagNameMap = hashMapOf(
TAG_GEO_ASCII_PARAMS to "Geo Ascii Params",
TAG_GEO_DOUBLE_PARAMS to "Geo Double Params",
TAG_GEO_KEY_DIRECTORY to "Geo Key Directory",
TAG_MODEL_PIXEL_SCALE to "Model Pixel Scale",
TAG_MODEL_TIEPOINT to "Model Tiepoint",
TAG_MODEL_TIE_POINT to "Model Tie Points",
TAG_MODEL_TRANSFORMATION to "Model Transformation",
)
val tags = tagNameMap.keys
}
}

View file

@ -29,6 +29,8 @@ object Metadata {
const val DIR_XMP = "XMP" // from metadata-extractor
const val DIR_MEDIA = "Media" // custom
const val DIR_COVER_ART = "Cover" // custom
const val DIR_DNG = "DNG" // custom
const val DIR_EXIF_GEOTIFF = "GeoTIFF" // custom
const val DIR_PNG_TEXTUAL_DATA = "PNG Textual Data" // custom
// types of metadata

View file

@ -1,17 +1,27 @@
package deckers.thibault.aves.metadata
import android.util.Log
import com.drew.imaging.FileType
import com.drew.imaging.FileTypeDetector
import com.drew.imaging.ImageMetadataReader
import com.drew.imaging.jpeg.JpegMetadataReader
import com.drew.imaging.jpeg.JpegSegmentMetadataReader
import com.drew.lang.ByteArrayReader
import com.drew.lang.Rational
import com.drew.lang.SequentialByteArrayReader
import com.drew.metadata.Directory
import com.drew.metadata.StringValue
import com.drew.metadata.exif.ExifDirectoryBase
import com.drew.metadata.exif.ExifIFD0Directory
import com.drew.metadata.exif.ExifReader
import com.drew.metadata.exif.ExifSubIFDDirectory
import com.drew.metadata.file.FileTypeDirectory
import com.drew.metadata.iptc.IptcReader
import com.drew.metadata.png.PngDirectory
import com.drew.metadata.xmp.XmpReader
import deckers.thibault.aves.utils.LogUtils
import java.io.BufferedInputStream
import java.io.InputStream
import java.text.SimpleDateFormat
import java.util.*
@ -33,6 +43,40 @@ object MetadataExtractorHelper {
// e.g. "exif [...] 134 [...] 4578696600004949[...]"
private val PNG_RAW_PROFILE_PATTERN = Regex("^\\n(.*?)\\n\\s*(\\d+)\\n(.*)", RegexOption.DOT_MATCHES_ALL)
fun readMimeType(input: InputStream): String? {
val bufferedInputStream = if (input is BufferedInputStream) input else BufferedInputStream(input)
return FileTypeDetector.detectFileType(bufferedInputStream).mimeType
}
fun safeRead(input: InputStream, sizeBytes: Long?): com.drew.metadata.Metadata {
val streamLength = sizeBytes ?: -1
val bufferedInputStream = if (input is BufferedInputStream) input else BufferedInputStream(input)
val fileType = FileTypeDetector.detectFileType(bufferedInputStream)
val metadata = if (fileType == FileType.Jpeg) {
safeReadJpeg(bufferedInputStream)
} else {
ImageMetadataReader.readMetadata(bufferedInputStream, streamLength, fileType)
}
metadata.addDirectory(FileTypeDirectory(fileType))
return metadata
}
// Some JPEG (and other types?) contain XMP with a preposterous number of `DocumentAncestors`.
// This bloated XMP is unsafely loaded in memory by Adobe's `XMPMetaParser.parseInputSource`
// which easily yields OOM on Android, so we try to detect and strip extended XMP with a modified XMP reader.
private fun safeReadJpeg(input: InputStream): com.drew.metadata.Metadata {
val readers = ArrayList<JpegSegmentMetadataReader>().apply {
addAll(JpegMetadataReader.ALL_READERS.filter { it !is XmpReader })
add(MetadataExtractorSafeXmpReader())
}
val metadata = com.drew.metadata.Metadata()
JpegMetadataReader.process(metadata, input, readers)
return metadata
}
// extensions
fun Directory.getSafeString(tag: Int, save: (value: String) -> Unit) {
@ -93,19 +137,69 @@ object MetadataExtractorHelper {
- If the ModelTransformationTag is included in an IFD, then a ModelPixelScaleTag SHALL NOT be included
- If the ModelPixelScaleTag is included in an IFD, then a ModelTiepointTag SHALL also be included.
*/
fun ExifDirectoryBase.isGeoTiff(): Boolean {
if (!this.containsTag(GeoTiffTags.TAG_GEO_KEY_DIRECTORY)) return false
fun ExifDirectoryBase.containsGeoTiffTags(): Boolean {
if (!this.containsTag(ExifGeoTiffTags.TAG_GEO_KEY_DIRECTORY)) return false
val modelTiepoint = this.containsTag(GeoTiffTags.TAG_MODEL_TIEPOINT)
val modelTransformation = this.containsTag(GeoTiffTags.TAG_MODEL_TRANSFORMATION)
if (!modelTiepoint && !modelTransformation) return false
val modelTiePoints = this.containsTag(ExifGeoTiffTags.TAG_MODEL_TIE_POINT)
val modelTransformation = this.containsTag(ExifGeoTiffTags.TAG_MODEL_TRANSFORMATION)
if (!modelTiePoints && !modelTransformation) return false
val modelPixelScale = this.containsTag(GeoTiffTags.TAG_MODEL_PIXEL_SCALE)
if ((modelTransformation && modelPixelScale) || (modelPixelScale && !modelTiepoint)) return false
val modelPixelScale = this.containsTag(ExifGeoTiffTags.TAG_MODEL_PIXEL_SCALE)
if ((modelTransformation && modelPixelScale) || (modelPixelScale && !modelTiePoints)) return false
return true
}
// TODO TLAD use `GeoTiffDirectory` from the Java version of `metadata-extractor` when available
// adapted from https://github.com/drewnoakes/metadata-extractor-dotnet/blob/master/MetadataExtractor/Formats/Exif/ExifTiffHandler.cs
fun ExifIFD0Directory.extractGeoKeys(geoKeys: IntArray): HashMap<Int, Any?> {
val fields = HashMap<Int, Any?>()
if (geoKeys.size < 4) return fields
var i = 0
val directoryVersion = geoKeys[i++]
val revision = geoKeys[i++]
val minorRevision = geoKeys[i++]
val numberOfKeys = geoKeys[i++]
fields[GeoTiffKeys.GEOTIFF_VERSION] = "$directoryVersion.$revision.$minorRevision"
for (j in 0 until numberOfKeys) {
val keyId = geoKeys[i++]
val tiffTagLocation = geoKeys[i++]
val valueCount = geoKeys[i++]
val valueOffset = geoKeys[i++]
try {
if (tiffTagLocation == 0) {
fields[keyId] = valueOffset
} else {
val sourceValue = getObject(tiffTagLocation)
if (sourceValue is StringValue) {
if (valueOffset + valueCount <= sourceValue.bytes.size) {
fields[keyId] = String(sourceValue.bytes, valueOffset, valueCount).trimEnd('|')
} else {
Log.w(LOG_TAG, "GeoTIFF key $keyId with offset $valueOffset and count $valueCount extends beyond length of source value (${sourceValue.bytes.size})")
}
} else if (sourceValue.javaClass.isArray) {
val sourceArray = sourceValue as DoubleArray
if (valueOffset + valueCount <= sourceArray.size) {
fields[keyId] = sourceArray.copyOfRange(valueOffset, valueOffset + valueCount)
} else {
Log.w(LOG_TAG, "GeoTIFF key $keyId with offset $valueOffset and count $valueCount extends beyond length of source value (${sourceArray.size})")
}
} else {
Log.w(LOG_TAG, "GeoTIFF key $keyId references tag $tiffTagLocation which has unsupported type of ${sourceValue?.javaClass}")
}
}
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to extract GeoTiff fields from keys", e)
}
}
return fields
}
// PNG
fun Directory.isPngTextDir(): Boolean = this is PngDirectory && setOf(PNG_ITXT_DIR_NAME, PNG_TEXT_DIR_NAME, PNG_ZTXT_DIR_NAME).contains(this.name)

View file

@ -0,0 +1,155 @@
package deckers.thibault.aves.metadata
import android.util.Log
import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.XMPMeta
import com.adobe.internal.xmp.XMPMetaFactory
import com.adobe.internal.xmp.impl.ByteBuffer
import com.adobe.internal.xmp.options.ParseOptions
import com.adobe.internal.xmp.properties.XMPPropertyInfo
import com.drew.imaging.jpeg.JpegSegmentType
import com.drew.lang.SequentialByteArrayReader
import com.drew.lang.SequentialReader
import com.drew.lang.annotations.NotNull
import com.drew.lang.annotations.Nullable
import com.drew.metadata.Directory
import com.drew.metadata.Metadata
import com.drew.metadata.xmp.XmpDirectory
import com.drew.metadata.xmp.XmpReader
import deckers.thibault.aves.utils.LogUtils
import java.io.IOException
class MetadataExtractorSafeXmpReader : XmpReader() {
// adapted from `XmpReader` to detect and skip large extended XMP
override fun readJpegSegments(segments: Iterable<ByteArray>, metadata: Metadata, segmentType: JpegSegmentType) {
val preambleLength = XMP_JPEG_PREAMBLE.length
val extensionPreambleLength = XMP_EXTENSION_JPEG_PREAMBLE.length
var extendedXMPGUID: String? = null
var extendedXMPBuffer: ByteArray? = null
for (segmentBytes in segments) {
if (segmentBytes.size >= preambleLength) {
if (XMP_JPEG_PREAMBLE.equals(String(segmentBytes, 0, preambleLength), ignoreCase = true) ||
"XMP".equals(String(segmentBytes, 0, 3), ignoreCase = true)
) {
val xmlBytes = ByteArray(segmentBytes.size - preambleLength)
System.arraycopy(segmentBytes, preambleLength, xmlBytes, 0, xmlBytes.size)
extract(xmlBytes, metadata)
extendedXMPGUID = getExtendedXMPGUID(metadata)
continue
}
}
if (extendedXMPGUID != null && segmentBytes.size >= extensionPreambleLength &&
XMP_EXTENSION_JPEG_PREAMBLE.equals(String(segmentBytes, 0, extensionPreambleLength), ignoreCase = true)
) {
extendedXMPBuffer = processExtendedXMPChunk(metadata, segmentBytes, extendedXMPGUID, extendedXMPBuffer)
}
}
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)
})
} else {
extract(xmpBytes, metadata)
}
}
}
// adapted from `XmpReader` to provide different parsing options
override fun extract(@NotNull xmpBytes: ByteArray, offset: Int, length: Int, @NotNull metadata: Metadata, @Nullable parentDirectory: Directory?) {
val directory = XmpDirectory()
if (parentDirectory != null) directory.parent = parentDirectory
try {
val xmpMeta: XMPMeta = if (offset == 0 && length == xmpBytes.size) {
XMPMetaFactory.parseFromBuffer(xmpBytes, PARSE_OPTIONS)
} else {
val buffer = ByteBuffer(xmpBytes, offset, length)
XMPMetaFactory.parse(buffer.byteStream, PARSE_OPTIONS)
}
directory.xmpMeta = xmpMeta
} catch (e: XMPException) {
directory.addError("Error processing XMP data: " + e.message)
}
if (!directory.isEmpty) metadata.addDirectory(directory)
}
// adapted from `XmpReader` because original is private
private fun getExtendedXMPGUID(metadata: Metadata): String? {
val xmpDirectories = metadata.getDirectoriesOfType(XmpDirectory::class.java)
for (directory in xmpDirectories) {
val xmpMeta = directory.xmpMeta
try {
val itr = xmpMeta.iterator(SCHEMA_XMP_NOTES, null, null) ?: continue
while (itr.hasNext()) {
val pi = itr.next() as XMPPropertyInfo?
if (ATTRIBUTE_EXTENDED_XMP == pi!!.path) {
return pi.value
}
}
} catch (e: XMPException) {
// Fail silently here: we had a reading issue, not a decoding issue.
}
}
return null
}
// adapted from `XmpReader` because original is private
private fun processExtendedXMPChunk(metadata: Metadata, segmentBytes: ByteArray, extendedXMPGUID: String, extendedXMPBufferIn: ByteArray?): ByteArray? {
var extendedXMPBuffer: ByteArray? = extendedXMPBufferIn
val extensionPreambleLength = XMP_EXTENSION_JPEG_PREAMBLE.length
val segmentLength = segmentBytes.size
val totalOffset = extensionPreambleLength + EXTENDED_XMP_GUID_LENGTH + EXTENDED_XMP_INT_LENGTH + EXTENDED_XMP_INT_LENGTH
if (segmentLength >= totalOffset) {
try {
val reader: SequentialReader = SequentialByteArrayReader(segmentBytes)
reader.skip(extensionPreambleLength.toLong())
val segmentGUID = reader.getString(EXTENDED_XMP_GUID_LENGTH)
if (extendedXMPGUID == segmentGUID) {
val fullLength = reader.uInt32.toInt()
val chunkOffset = reader.uInt32.toInt()
if (extendedXMPBuffer == null) extendedXMPBuffer = ByteArray(fullLength)
if (extendedXMPBuffer.size == fullLength) {
System.arraycopy(segmentBytes, totalOffset, extendedXMPBuffer, chunkOffset, segmentLength - totalOffset)
} else {
val directory = XmpDirectory()
directory.addError(String.format("Inconsistent length for the Extended XMP buffer: %d instead of %d", fullLength, extendedXMPBuffer.size))
metadata.addDirectory(directory)
}
}
} catch (ex: IOException) {
val directory = XmpDirectory()
directory.addError(ex.message)
metadata.addDirectory(directory)
}
}
return extendedXMPBuffer
}
companion object {
private val LOG_TAG = LogUtils.createTag<MetadataExtractorSafeXmpReader>()
// arbitrary size to detect extended XMP that may yield an OOM
private const val segmentTypeSizeDangerThreshold = 3 * (1 shl 20) // MB
// tighter node limits for faster loading
private val PARSE_OPTIONS = ParseOptions().setXMPNodesToLimit(
mapOf(
"photoshop:DocumentAncestors" to 200,
"xmpMM:History" to 200,
)
)
private const val XMP_JPEG_PREAMBLE = "http://ns.adobe.com/xap/1.0/\u0000"
private const val XMP_EXTENSION_JPEG_PREAMBLE = "http://ns.adobe.com/xmp/extension/\u0000"
private const val SCHEMA_XMP_NOTES = "http://ns.adobe.com/xmp/note/"
private const val ATTRIBUTE_EXTENDED_XMP = "xmpNote:HasExtendedXMP"
private const val EXTENDED_XMP_GUID_LENGTH = 32
private const val EXTENDED_XMP_INT_LENGTH = 4
}
}

View file

@ -8,7 +8,6 @@ import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
import android.util.Log
import com.drew.imaging.ImageMetadataReader
import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.metadata.XMP.getSafeLong
import deckers.thibault.aves.metadata.XMP.getSafeStructField
@ -16,7 +15,6 @@ import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import java.util.*
object MultiPage {
private val LOG_TAG = LogUtils.createTag<MultiPage>()
@ -142,7 +140,7 @@ object MultiPage {
fun getMotionPhotoOffset(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Long? {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
val metadata = MetadataExtractorHelper.safeRead(input, sizeBytes)
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
var offsetFromEnd: Long? = null
val xmpMeta = dir.xmpMeta

View file

@ -8,7 +8,6 @@ import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import androidx.exifinterface.media.ExifInterface
import com.drew.imaging.ImageMetadataReader
import com.drew.metadata.avi.AviDirectory
import com.drew.metadata.exif.ExifIFD0Directory
import com.drew.metadata.jpeg.JpegDirectory
@ -23,6 +22,7 @@ import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeLong
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeString
import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
import deckers.thibault.aves.metadata.MetadataExtractorHelper
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeLong
@ -161,7 +161,7 @@ class SourceEntry {
try {
Metadata.openSafeInputStream(context, uri, sourceMimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
val metadata = MetadataExtractorHelper.safeRead(input, sizeBytes)
// do not switch on specific MIME types, as the reported MIME type could be wrong
// (e.g. PNG registered as JPG)

View file

@ -5,10 +5,8 @@ import android.net.Uri
import android.provider.MediaStore
import android.provider.OpenableColumns
import android.util.Log
import com.drew.imaging.ImageMetadataReader
import com.drew.metadata.file.FileTypeDirectory
import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
import deckers.thibault.aves.metadata.MetadataExtractorHelper
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.SourceEntry
import deckers.thibault.aves.utils.LogUtils
@ -22,17 +20,12 @@ internal class ContentImageProvider : ImageProvider() {
try {
val safeUri = Uri.fromFile(Metadata.createPreviewFile(context, uri))
StorageUtils.openInputStream(context, safeUri)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) {
// `metadata-extractor` is the most reliable, except for `tiff` (false positives, false negatives)
// cf https://github.com/drewnoakes/metadata-extractor/issues/296
dir.getSafeString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) {
if (it != MimeTypes.TIFF) {
extractorMimeType = it
if (extractorMimeType != sourceMimeType) {
Log.d(LOG_TAG, "source MIME type is $sourceMimeType but extracted MIME type is $extractorMimeType for uri=$uri")
}
}
// `metadata-extractor` is the most reliable, except for `tiff` (false positives, false negatives)
// cf https://github.com/drewnoakes/metadata-extractor/issues/296
MetadataExtractorHelper.readMimeType(input)?.takeIf { it != MimeTypes.TIFF }?.let {
extractorMimeType = it
if (extractorMimeType != sourceMimeType) {
Log.d(LOG_TAG, "source MIME type is $sourceMimeType but extracted MIME type is $extractorMimeType for uri=$uri")
}
}
}
@ -53,7 +46,10 @@ internal class ContentImageProvider : ImageProvider() {
"sourceMimeType" to mimeType,
)
try {
val cursor = context.contentResolver.query(uri, projection, null, null, null)
// some providers do not provide the mandatory `OpenableColumns`
// and the query fails when compiling a projection specifying them
// e.g. `content://mms/part/[id]` on Android KitKat
val cursor = context.contentResolver.query(uri, null, null, null, null)
if (cursor != null && cursor.moveToFirst()) {
cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).let { if (it != -1) fields["title"] = cursor.getString(it) }
cursor.getColumnIndex(OpenableColumns.SIZE).let { if (it != -1) fields["sizeBytes"] = cursor.getLong(it) }
@ -78,13 +74,5 @@ internal class ContentImageProvider : ImageProvider() {
@Suppress("deprecation")
const val PATH = MediaStore.MediaColumns.DATA
private val projection = arrayOf(
// standard columns for openable URI
OpenableColumns.DISPLAY_NAME,
OpenableColumns.SIZE,
// optional path underlying media content
PATH,
)
}
}

View file

@ -409,6 +409,7 @@ abstract class ImageProvider {
uri: Uri,
mimeType: String,
callback: ImageOpCallback,
autoCorrectTrailerOffset: Boolean = true,
trailerDiff: Int = 0,
edit: (exif: ExifInterface) -> Unit,
): Boolean {
@ -464,7 +465,7 @@ abstract class ImageProvider {
// copy the edited temporary file back to the original
copyTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path)
if (!checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
return false
}
} catch (e: IOException) {
@ -481,6 +482,7 @@ abstract class ImageProvider {
uri: Uri,
mimeType: String,
callback: ImageOpCallback,
autoCorrectTrailerOffset: Boolean = true,
trailerDiff: Int = 0,
iptc: List<FieldMap>?,
): Boolean {
@ -550,7 +552,7 @@ abstract class ImageProvider {
// copy the edited temporary file back to the original
copyTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path)
if (!checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
return false
}
} catch (e: IOException) {
@ -569,6 +571,7 @@ abstract class ImageProvider {
uri: Uri,
mimeType: String,
callback: ImageOpCallback,
autoCorrectTrailerOffset: Boolean = true,
trailerDiff: Int = 0,
coreXmp: String? = null,
extendedXmp: String? = null,
@ -624,7 +627,7 @@ abstract class ImageProvider {
// copy the edited temporary file back to the original
copyTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path)
if (!checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
return false
}
} catch (e: IOException) {
@ -812,12 +815,20 @@ abstract class ImageProvider {
uri: Uri,
mimeType: String,
modifier: FieldMap,
autoCorrectTrailerOffset: Boolean,
callback: ImageOpCallback,
) {
if (modifier.containsKey("exif")) {
val fields = modifier["exif"] as Map<*, *>?
if (fields != null && fields.isNotEmpty()) {
if (!editExif(context, path, uri, mimeType, callback) { exif ->
if (!editExif(
context = context,
path = path,
uri = uri,
mimeType = mimeType,
callback = callback,
autoCorrectTrailerOffset = autoCorrectTrailerOffset,
) { exif ->
var setLocation = false
fields.forEach { kv ->
val tag = kv.key as String?
@ -859,7 +870,8 @@ abstract class ImageProvider {
}
}
exif.saveAttributes()
}) return
}
) return
}
}
@ -871,6 +883,7 @@ abstract class ImageProvider {
uri = uri,
mimeType = mimeType,
callback = callback,
autoCorrectTrailerOffset = autoCorrectTrailerOffset,
iptc = iptc,
)
) return
@ -887,6 +900,7 @@ abstract class ImageProvider {
uri = uri,
mimeType = mimeType,
callback = callback,
autoCorrectTrailerOffset = autoCorrectTrailerOffset,
coreXmp = coreXmp,
extendedXmp = extendedXmp,
)
@ -898,6 +912,58 @@ abstract class ImageProvider {
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
}
fun removeTrailerVideo(
context: Context,
path: String,
uri: Uri,
mimeType: String,
callback: ImageOpCallback,
) {
val originalFileSize = File(path).length()
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.toInt()
if (videoSize == null) {
callback.onFailure(Exception("failed to get trailer video size"))
return
}
val bytesToCopy = originalFileSize - videoSize
val editableFile = File.createTempFile("aves", null).apply {
deleteOnExit()
try {
outputStream().use { output ->
// reopen input to read from start
StorageUtils.openInputStream(context, uri)?.use { input ->
// partial copy
var bytesRemaining: Long = bytesToCopy
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytes = input.read(buffer)
while (bytes >= 0 && bytesRemaining > 0) {
val len = if (bytes > bytesRemaining) bytesRemaining.toInt() else bytes
output.write(buffer, 0, len)
bytesRemaining -= len
bytes = input.read(buffer)
}
}
}
} catch (e: Exception) {
Log.d(LOG_TAG, "failed to remove trailer video", e)
callback.onFailure(e)
return
}
}
try {
// copy the edited temporary file back to the original
copyTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path)
} catch (e: IOException) {
callback.onFailure(e)
return
}
val newFields = HashMap<String, Any?>()
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
}
fun removeMetadataTypes(
context: Context,
path: String,
@ -952,12 +1018,17 @@ abstract class ImageProvider {
targetUri: Uri,
targetPath: String
) {
if (isMediaUriPermissionGranted(context, targetUri, mimeType)) {
val targetStream = StorageUtils.openOutputStream(context, targetUri, mimeType) ?: throw Exception("failed to open output stream for uri=$targetUri")
DocumentFileCompat.fromFile(sourceFile).copyTo(targetStream)
} else {
val targetDocumentFile = StorageUtils.getDocumentFile(context, targetPath, targetUri) ?: throw Exception("failed to get document file for path=$targetPath, uri=$targetUri")
DocumentFileCompat.fromFile(sourceFile).copyTo(targetDocumentFile)
sourceFile.inputStream().use { input ->
// 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")
} 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")
}
targetStream.use { output ->
input.copyTo(output)
}
}
}

View file

@ -9,7 +9,7 @@ import android.net.Uri
import android.os.Binder
import android.os.Build
import android.os.Environment
import android.os.storage.StorageManager
import android.provider.DocumentsContract
import android.provider.MediaStore
import android.util.Log
import androidx.annotation.RequiresApi
@ -33,22 +33,16 @@ object PermissionManager {
suspend fun requestDirectoryAccess(activity: Activity, path: String, onGranted: (uri: Uri) -> Unit, onDenied: () -> Unit) {
Log.i(LOG_TAG, "request user to select and grant access permission to path=$path")
var intent: Intent? = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val sm = activity.getSystemService(Context.STORAGE_SERVICE) as? StorageManager
val storageVolume = sm?.getStorageVolume(File(path))
if (storageVolume != null) {
intent = storageVolume.createOpenDocumentTreeIntent()
} else {
MainActivity.notifyError("failed to get storage volume for path=$path on volumes=${sm?.storageVolumes?.joinToString(", ")}")
// `StorageVolume.createOpenDocumentTreeIntent` is an alternative,
// and it helps with initial volume, but not with initial directory
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// initial URI should not be a `tree document URI`, but a simple `document URI`
StorageUtils.convertDirPathToDocumentUri(activity, path)?.let {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, it)
}
}
// fallback to basic open document tree intent
if (intent == null) {
intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
}
if (intent.resolveActivity(activity.packageManager) != null) {
MainActivity.pendingStorageAccessResultHandlers[MainActivity.DOCUMENT_TREE_ACCESS_REQUEST] = PendingStorageAccessResultHandler(path, onGranted, onDenied)
activity.startActivityForResult(intent, MainActivity.DOCUMENT_TREE_ACCESS_REQUEST)
@ -109,7 +103,7 @@ object PermissionManager {
if (relativeDir != null) {
val dirSegments = relativeDir.split(File.separator).takeWhile { it.isNotEmpty() }
val primaryDir = dirSegments.firstOrNull()
if (primaryDir == Environment.DIRECTORY_DOWNLOADS && dirSegments.size > 1) {
if (getRestrictedPrimaryDirectories().contains(primaryDir) && dirSegments.size > 1) {
// request secondary directory (if any) for restricted primary directory
dirSet.add(dirSegments.take(2).joinToString(File.separator))
} else {
@ -156,7 +150,7 @@ object PermissionManager {
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
fun revokeDirectoryAccess(context: Context, path: String): Boolean {
return StorageUtils.convertDirPathToTreeUri(context, path)?.let {
return StorageUtils.convertDirPathToTreeDocumentUri(context, path)?.let {
releaseUriPermission(context, it)
true
} ?: false
@ -167,7 +161,7 @@ object PermissionManager {
val grantedDirs = HashSet<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
for (uriPermission in context.contentResolver.persistedUriPermissions) {
val dirPath = StorageUtils.convertTreeUriToDirPath(context, uriPermission.uri)
val dirPath = StorageUtils.convertTreeDocumentUriToDirPath(context, uriPermission.uri)
dirPath?.let { grantedDirs.add(it) }
}
}
@ -191,11 +185,25 @@ object PermissionManager {
return accessibleDirs
}
private fun getRestrictedPrimaryDirectories(): List<String> {
val dirs = ArrayList<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// cf https://developer.android.com/about/versions/11/privacy/storage#directory-access
dirs.add(Environment.DIRECTORY_DOWNLOADS)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// by observation, no documentation
dirs.add("Android")
}
}
return dirs
}
// cf https://developer.android.com/about/versions/11/privacy/storage#directory-access
fun getRestrictedDirectories(context: Context): List<Map<String, String>> {
val dirs = ArrayList<Map<String, String>>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// cf https://developer.android.com/about/versions/11/privacy/storage#directory-access
val volumePaths = StorageUtils.getVolumePaths(context)
dirs.addAll(volumePaths.map {
hashMapOf(
@ -203,12 +211,14 @@ object PermissionManager {
"relativeDir" to "",
)
})
dirs.addAll(volumePaths.map {
hashMapOf(
"volumePath" to it,
"relativeDir" to Environment.DIRECTORY_DOWNLOADS,
)
})
for (relativeDir in getRestrictedPrimaryDirectories()) {
dirs.addAll(volumePaths.map {
hashMapOf(
"volumePath" to it,
"relativeDir" to relativeDir,
)
})
}
} else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT
|| Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT_WATCH
) {
@ -234,7 +244,7 @@ object PermissionManager {
try {
for (uriPermission in context.contentResolver.persistedUriPermissions) {
val uri = uriPermission.uri
val path = StorageUtils.convertTreeUriToDirPath(context, uri)
val path = StorageUtils.convertTreeDocumentUriToDirPath(context, uri)
if (path != null && !File(path).exists()) {
Log.d(LOG_TAG, "revoke URI permission for obsolete path=$path")
releaseUriPermission(context, uri)

View file

@ -30,7 +30,12 @@ import java.util.regex.Pattern
object StorageUtils {
private val LOG_TAG = LogUtils.createTag<StorageUtils>()
private const val TREE_URI_ROOT = "content://com.android.externalstorage.documents/tree/"
// from `DocumentsContract`
private const val EXTERNAL_STORAGE_PROVIDER_AUTHORITY = "com.android.externalstorage.documents"
private const val EXTERNAL_STORAGE_PRIMARY_EMULATED_ROOT_ID = "primary"
private const val TREE_URI_ROOT = "content://$EXTERNAL_STORAGE_PROVIDER_AUTHORITY/tree/"
private val TREE_URI_PATH_PATTERN = Pattern.compile("(.*?):(.*)")
const val TRASH_PATH_PLACEHOLDER = "#trash"
@ -242,12 +247,12 @@ object StorageUtils {
// e.g.
// /storage/emulated/0/ -> primary
// /storage/10F9-3F13/Pictures/ -> 10F9-3F13
private fun getVolumeUuidForTreeUri(context: Context, anyPath: String): String? {
private fun getVolumeUuidForDocumentUri(context: Context, anyPath: String): String? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val sm = context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager
sm?.getStorageVolume(File(anyPath))?.let { volume ->
if (volume.isPrimary) {
return "primary"
return EXTERNAL_STORAGE_PRIMARY_EMULATED_ROOT_ID
}
volume.uuid?.let { uuid ->
return uuid.uppercase(Locale.ROOT)
@ -258,7 +263,7 @@ object StorageUtils {
// fallback for <N
getVolumePath(context, anyPath)?.let { volumePath ->
if (volumePath == getPrimaryVolumePath(context)) {
return "primary"
return EXTERNAL_STORAGE_PRIMARY_EMULATED_ROOT_ID
}
volumePath.split(File.separator).lastOrNull { it.isNotEmpty() }?.let { uuid ->
return uuid.uppercase(Locale.ROOT)
@ -272,8 +277,8 @@ object StorageUtils {
// e.g.
// primary -> /storage/emulated/0/
// 10F9-3F13 -> /storage/10F9-3F13/
private fun getVolumePathFromTreeUriUuid(context: Context, uuid: String): String? {
if (uuid == "primary") {
private fun getVolumePathFromTreeDocumentUriUuid(context: Context, uuid: String): String? {
if (uuid == EXTERNAL_STORAGE_PRIMARY_EMULATED_ROOT_ID) {
return getPrimaryVolumePath(context)
}
@ -309,37 +314,50 @@ object StorageUtils {
// /storage/emulated/0/ -> content://com.android.externalstorage.documents/tree/primary%3A
// /storage/10F9-3F13/Pictures/ -> content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
fun convertDirPathToTreeUri(context: Context, dirPath: String): Uri? {
val uuid = getVolumeUuidForTreeUri(context, dirPath)
fun convertDirPathToTreeDocumentUri(context: Context, dirPath: String): Uri? {
val uuid = getVolumeUuidForDocumentUri(context, dirPath)
if (uuid != null) {
val relativeDir = removeTrailingSeparator(PathSegments(context, dirPath).relativeDir ?: "")
return DocumentsContract.buildTreeDocumentUri("com.android.externalstorage.documents", "$uuid:$relativeDir")
return DocumentsContract.buildTreeDocumentUri(EXTERNAL_STORAGE_PROVIDER_AUTHORITY, "$uuid:$relativeDir")
}
Log.e(LOG_TAG, "failed to convert dirPath=$dirPath to tree URI")
Log.e(LOG_TAG, "failed to convert dirPath=$dirPath to tree document URI")
return null
}
// e.g.
// /storage/emulated/0/ -> content://com.android.externalstorage.documents/document/primary%3A
// /storage/10F9-3F13/Pictures/ -> content://com.android.externalstorage.documents/document/10F9-3F13%3APictures
fun convertDirPathToDocumentUri(context: Context, dirPath: String): Uri? {
val uuid = getVolumeUuidForDocumentUri(context, dirPath)
if (uuid != null) {
val relativeDir = removeTrailingSeparator(PathSegments(context, dirPath).relativeDir ?: "")
return DocumentsContract.buildDocumentUri(EXTERNAL_STORAGE_PROVIDER_AUTHORITY, "$uuid:$relativeDir")
}
Log.e(LOG_TAG, "failed to convert dirPath=$dirPath to document URI")
return null
}
// e.g.
// content://com.android.externalstorage.documents/tree/primary%3A -> /storage/emulated/0/
// content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures -> /storage/10F9-3F13/Pictures/
fun convertTreeUriToDirPath(context: Context, treeUri: Uri): String? {
val treeUriString = treeUri.toString()
if (treeUriString.length <= TREE_URI_ROOT.length) return null
val encoded = treeUriString.substring(TREE_URI_ROOT.length)
fun convertTreeDocumentUriToDirPath(context: Context, treeDocumentUri: Uri): String? {
val treeDocumentUriString = treeDocumentUri.toString()
if (treeDocumentUriString.length <= TREE_URI_ROOT.length) return null
val encoded = treeDocumentUriString.substring(TREE_URI_ROOT.length)
val matcher = TREE_URI_PATH_PATTERN.matcher(Uri.decode(encoded))
with(matcher) {
if (find()) {
val uuid = group(1)
val relativePath = group(2)
if (uuid != null && relativePath != null) {
val volumePath = getVolumePathFromTreeUriUuid(context, uuid)
val volumePath = getVolumePathFromTreeDocumentUriUuid(context, uuid)
if (volumePath != null) {
return ensureTrailingSeparator(volumePath + relativePath)
}
}
}
}
Log.e(LOG_TAG, "failed to convert treeUri=$treeUri to path")
Log.e(LOG_TAG, "failed to convert treeDocumentUri=$treeDocumentUri to path")
return null
}
@ -365,7 +383,7 @@ object StorageUtils {
}
// fallback for older APIs
val df = getVolumePath(context, anyPath)?.let { convertDirPathToTreeUri(context, it) }?.let { getDocumentFileFromVolumeTree(context, it, anyPath) }
val df = getVolumePath(context, anyPath)?.let { convertDirPathToTreeDocumentUri(context, it) }?.let { getDocumentFileFromVolumeTree(context, it, anyPath) }
if (df != null) return df
// try to strip user info, if any
@ -389,8 +407,8 @@ object StorageUtils {
val cleanDirPath = ensureTrailingSeparator(dirPath)
return if (requireAccessPermission(context, cleanDirPath) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
val grantedDir = getGrantedDirForPath(context, cleanDirPath) ?: return null
val rootTreeUri = convertDirPathToTreeUri(context, grantedDir) ?: return null
var parentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeUri) ?: return null
val rootTreeDocumentUri = convertDirPathToTreeDocumentUri(context, grantedDir) ?: return null
var parentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeDocumentUri) ?: return null
val pathIterator = getPathStepIterator(context, cleanDirPath, grantedDir)
while (pathIterator?.hasNext() == true) {
val dirName = pathIterator.next()
@ -420,8 +438,8 @@ object StorageUtils {
}
}
private fun getDocumentFileFromVolumeTree(context: Context, rootTreeUri: Uri, anyPath: String): DocumentFileCompat? {
var documentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeUri) ?: return null
private fun getDocumentFileFromVolumeTree(context: Context, rootTreeDocumentUri: Uri, anyPath: String): DocumentFileCompat? {
var documentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeDocumentUri) ?: return null
// follow the entry path down the document tree
val pathIterator = getPathStepIterator(context, anyPath, null)
@ -561,14 +579,14 @@ object StorageUtils {
}
}
fun openOutputStream(context: Context, uri: Uri, mimeType: String): OutputStream? {
fun openOutputStream(context: Context, uri: Uri, mimeType: String, mode: String): OutputStream? {
val effectiveUri = getMediaStoreScopedStorageSafeUri(uri, mimeType)
return try {
context.contentResolver.openOutputStream(effectiveUri)
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", e)
Log.w(LOG_TAG, "failed to open output stream for uri=$uri effectiveUri=$effectiveUri mode=$mode", e)
null
}
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="search_shortcut_short_label">Ricerca</string>
<string name="videos_shortcut_short_label">Video</string>
<string name="analysis_channel_name">Scansione media</string>
<string name="analysis_service_description">Scansione immagini &amp; videos</string>
<string name="analysis_notification_default_title">Scansione in corso</string>
<string name="analysis_notification_action_stop">Annulla</string>
</resources>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">アヴェス</string>
<string name="app_name">Aves</string>
<string name="search_shortcut_short_label">検索</string>
<string name="videos_shortcut_short_label">動画</string>
<string name="analysis_channel_name">メディアスキャン</string>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</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">扫描图像 &amp; 视频</string>
<string name="analysis_notification_default_title">正在扫描媒体库</string>
<string name="analysis_notification_action_stop">停止</string>
</resources>

View file

@ -1,12 +1,12 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '1.6.10'
ext.kotlin_version = '1.6.20'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.1.2'
classpath 'com.android.tools.build:gradle:7.1.3'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// GMS & Firebase Crashlytics are not actually used by all flavors
classpath 'com.google.gms:google-services:4.3.10'

View file

@ -0,0 +1,5 @@
In v1.6.4:
- customize album cover app & color
- explore improved GeoTIFF metadata
- enjoy the app in Italian & Chinese (Simplified)
Full changelog available on GitHub

View file

@ -0,0 +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>.
<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 32</b>, cioè da KitKat ad Android 12L) 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>.

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View file

@ -0,0 +1 @@
ギャラリーとメタデータエクスプローラー

View file

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

View file

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View file

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,5 @@
<i>Aves</i> 可以处理各种图像和视频,包括常见的 JPEG 和 MP4 格式,也包括不常见的格式,比如<b>多页 TIFF、SVG 和旧式 AVI 等等</b>!它会扫描你的媒体库以识别<b>动态照片</b>、<b>全景照片</b>(又称球体照片)、<b>360° 视频</b>以及 <b>GeoTIFF</b> 文件。
<b>导航与搜索</b>是 <i>Aves</i> 的核心功能之一,旨在帮助用户在相册、照片、标签、地图等之间轻松切换。
<i> Aves</i> 与 Android<b>API 19-32</b>,即从 KitKat 到 Android 12L集成具有<b>快捷方式</b>和<b>全局搜索</b>等功能。它还可用作<b>媒体查看器和选择器<b>。

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

View file

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

View file

@ -58,7 +58,9 @@
"entryActionPrint": "Drucken",
"entryActionShare": "Teilen",
"entryActionViewSource": "Quelle anzeigen",
"entryActionViewMotionPhotoVideo": "Bewegtes Foto öffnen",
"entryActionShowGeoTiffOnMap": "Als Karten-Overlay anzeigen",
"entryActionConvertMotionPhotoToStillImage": "In ein Standbild umwandeln",
"entryActionViewMotionPhotoVideo": "Video öffnen",
"entryActionEdit": "Bearbeiten",
"entryActionOpen": "Öffnen mit",
"entryActionSetAs": "Einstellen als",
@ -184,8 +186,8 @@
"videoStartOverButtonLabel": "NEU BEGINNEN",
"videoResumeButtonLabel": "FORTSETZTEN",
"setCoverDialogTitle": "Titelbild bestimmen",
"setCoverDialogLatest": "Letzter Artikel",
"setCoverDialogAuto": "Auto",
"setCoverDialogCustom": "Benutzerdefiniert",
"hideFilterConfirmationDialogMessage": "Passende Fotos und Videos werden aus Ihrer Sammlung ausgeblendet. Dies kann in den „Datenschutz“-Einstellungen wieder eingeblendet werden.\n\nSicher, dass diese ausblendet werden sollen?",
@ -239,6 +241,7 @@
"removeEntryMetadataDialogMore": "Mehr",
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP ist erforderlich, um das Video innerhalb eines bewegten Bildes abzuspielen.\n\nSicher, dass es entfernt werden soll?",
"convertMotionPhotoToStillImageWarningDialogMessage": "Sicher?",
"videoSpeedDialogLabel": "Wiedergabegeschwindigkeit",
@ -266,6 +269,13 @@
"tileLayoutGrid": "Kacheln",
"tileLayoutList": "Liste",
"coverDialogTabCover": "Titelbild",
"coverDialogTabApp": "App",
"coverDialogTabColor": "Farbe",
"appPickDialogTitle": "Wähle App",
"appPickDialogNone": "Nichts",
"aboutPageTitle": "Über",
"aboutLinkSources": "Quellen",
"aboutLinkLicense": "Lizenz",

View file

@ -86,7 +86,9 @@
"entryActionPrint": "Print",
"entryActionShare": "Share",
"entryActionViewSource": "View source",
"entryActionViewMotionPhotoVideo": "Open Motion Photo",
"entryActionShowGeoTiffOnMap": "Show as map overlay",
"entryActionConvertMotionPhotoToStillImage": "Convert to still image",
"entryActionViewMotionPhotoVideo": "Open video",
"entryActionEdit": "Edit",
"entryActionOpen": "Open with",
"entryActionSetAs": "Set as",
@ -304,8 +306,8 @@
"videoStartOverButtonLabel": "START OVER",
"videoResumeButtonLabel": "RESUME",
"setCoverDialogTitle": "Set Cover",
"setCoverDialogLatest": "Latest item",
"setCoverDialogAuto": "Auto",
"setCoverDialogCustom": "Custom",
"hideFilterConfirmationDialogMessage": "Matching photos and videos will be hidden from your collection. You can show them again from the “Privacy” settings.\n\nAre you sure you want to hide them?",
@ -369,6 +371,7 @@
"removeEntryMetadataDialogMore": "More",
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP is required to play the video inside a motion photo.\n\nAre you sure you want to remove it?",
"convertMotionPhotoToStillImageWarningDialogMessage": "Are you sure?",
"videoSpeedDialogLabel": "Playback speed",
@ -396,6 +399,13 @@
"tileLayoutGrid": "Grid",
"tileLayoutList": "List",
"coverDialogTabCover": "Cover",
"coverDialogTabApp": "App",
"coverDialogTabColor": "Color",
"appPickDialogTitle": "Pick App",
"appPickDialogNone": "None",
"aboutPageTitle": "About",
"aboutLinkSources": "Sources",
"aboutLinkLicense": "License",

View file

@ -58,7 +58,6 @@
"entryActionPrint": "Imprimir",
"entryActionShare": "Compartir",
"entryActionViewSource": "Ver fuente",
"entryActionViewMotionPhotoVideo": "Abrir foto en movimiento",
"entryActionEdit": "Editar",
"entryActionOpen": "Abrir con",
"entryActionSetAs": "Establecer como",
@ -184,7 +183,6 @@
"videoStartOverButtonLabel": "VOLVER A EMPEZAR",
"videoResumeButtonLabel": "REANUDAR",
"setCoverDialogTitle": "Elegir carátula",
"setCoverDialogLatest": "Elemento más reciente",
"setCoverDialogCustom": "Personalizado",

View file

@ -58,7 +58,9 @@
"entryActionPrint": "Imprimer",
"entryActionShare": "Partager",
"entryActionViewSource": "Voir le code",
"entryActionViewMotionPhotoVideo": "Ouvrir le clip vidéo",
"entryActionShowGeoTiffOnMap": "Superposer sur la carte",
"entryActionConvertMotionPhotoToStillImage": "Convertir en image fixe",
"entryActionViewMotionPhotoVideo": "Ouvrir la vidéo",
"entryActionEdit": "Modifier",
"entryActionOpen": "Ouvrir avec",
"entryActionSetAs": "Utiliser comme",
@ -184,8 +186,8 @@
"videoStartOverButtonLabel": "RECOMMENCER",
"videoResumeButtonLabel": "REPRENDRE",
"setCoverDialogTitle": "Modifier la couverture",
"setCoverDialogLatest": "dernier élément",
"setCoverDialogAuto": "automatique",
"setCoverDialogCustom": "personnalisé",
"hideFilterConfirmationDialogMessage": "Les images et vidéos correspondantes napparaîtront plus dans votre collection. Vous pouvez les montrer à nouveau via les réglages de «\u00A0Confidentialité\u00A0».\n\nVoulez-vous vraiment les masquer ?",
@ -239,6 +241,7 @@
"removeEntryMetadataDialogMore": "Voir plus",
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "Les métadonnées XMP sont nécessaires pour lire la vidéo dune photo animée.\n\nVoulez-vous vraiment les supprimer ?",
"convertMotionPhotoToStillImageWarningDialogMessage": "Êtes-vous sûr ?",
"videoSpeedDialogLabel": "Vitesse de lecture",
@ -266,6 +269,13 @@
"tileLayoutGrid": "Grille",
"tileLayoutList": "Liste",
"coverDialogTabCover": "Image",
"coverDialogTabApp": "App",
"coverDialogTabColor": "Couleur",
"appPickDialogTitle": "Sélection",
"appPickDialogNone": "Aucune",
"aboutPageTitle": "À propos",
"aboutLinkSources": "Sources",
"aboutLinkLicense": "Licence",

View file

@ -31,14 +31,14 @@
"doNotAskAgain": "Jangan tanya lagi",
"sourceStateLoading": "Memuat",
"sourceStateCataloguing": "Mengkatalog",
"sourceStateCataloguing": "Mengkataloging",
"sourceStateLocatingCountries": "Mencari negara",
"sourceStateLocatingPlaces": "Mencari tempat",
"chipActionDelete": "Hapus",
"chipActionGoToAlbumPage": "Tampilkan di Album",
"chipActionGoToCountryPage": "Tampilkan di Negara",
"chipActionGoToTagPage": "Tampilkan di Tag",
"chipActionGoToTagPage": "Tampilkan di Label",
"chipActionHide": "Sembunyikan",
"chipActionPin": "Sematkan ke atas",
"chipActionUnpin": "Lepas sematan dari atas",
@ -58,7 +58,9 @@
"entryActionPrint": "Cetak",
"entryActionShare": "Bagikan",
"entryActionViewSource": "Lihat sumber",
"entryActionViewMotionPhotoVideo": "Buka Foto bergerak",
"entryActionShowGeoTiffOnMap": "Tampilkan sebagai hamparan peta",
"entryActionConvertMotionPhotoToStillImage": "Ubah ke gambar tetap",
"entryActionViewMotionPhotoVideo": "Buka video",
"entryActionEdit": "Ubah",
"entryActionOpen": "Buka dengan",
"entryActionSetAs": "Tetapkan sebagai",
@ -68,9 +70,9 @@
"entryActionRemoveFavourite": "Hapus dari favorit",
"videoActionCaptureFrame": "Tangkap bingkai",
"videoActionMute": "Mute",
"videoActionUnmute": "Unmute",
"videoActionPause": "Henti",
"videoActionMute": "Matikan Suara",
"videoActionUnmute": "Hidupkan Suara",
"videoActionPause": "Hentikan",
"videoActionPlay": "Mainkan",
"videoActionReplay10": "Mundurkan 10 detik",
"videoActionSkip10": "Majukan 10 detik",
@ -81,20 +83,20 @@
"entryInfoActionEditDate": "Ubah tanggal & waktu",
"entryInfoActionEditLocation": "Ubah lokasi",
"entryInfoActionEditRating": "Ubah peringkat",
"entryInfoActionEditTags": "Ubah tag",
"entryInfoActionEditTags": "Ubah label",
"entryInfoActionRemoveMetadata": "Hapus metadata",
"filterBinLabel": "Tong sampah",
"filterFavouriteLabel": "Favorit",
"filterLocationEmptyLabel": "Lokasi yang tidak ditemukan",
"filterTagEmptyLabel": "Tidak ditag",
"filterTagEmptyLabel": "Tidak dilabel",
"filterRatingUnratedLabel": "Belum diberi peringkat",
"filterRatingRejectedLabel": "Ditolak",
"filterTypeAnimatedLabel": "Teranimasi",
"filterTypeMotionPhotoLabel": "Foto bergerak",
"filterTypePanoramaLabel": "Panorama",
"filterTypeRawLabel": "Raw",
"filterTypeSphericalVideoLabel": "Video 360°",
"filterTypeSphericalVideoLabel": "Vidio 360°",
"filterTypeGeotiffLabel": "GeoTIFF",
"filterMimeImageLabel": "Gambar",
"filterMimeVideoLabel": "Video",
@ -110,7 +112,7 @@
"unitSystemMetric": "Metrik",
"unitSystemImperial": "Imperial",
"videoLoopModeNever": "Tidak pernah",
"videoLoopModeNever": "Jangan pernah",
"videoLoopModeShortOnly": "Hanya video pendek",
"videoLoopModeAlways": "Selalu",
@ -130,11 +132,11 @@
"nameConflictStrategyReplace": "Ganti",
"nameConflictStrategySkip": "Lewati",
"keepScreenOnNever": "Tidak pernah",
"keepScreenOnViewerOnly": "Hanya halaman pemirsa",
"keepScreenOnNever": "Jangan pernah",
"keepScreenOnViewerOnly": "Hanya halaman penonton",
"keepScreenOnAlways": "Selalu",
"accessibilityAnimationsRemove": "Mencegah efek layar",
"accessibilityAnimationsRemove": "Cegah efek layar",
"accessibilityAnimationsKeep": "Simpan efek layar",
"displayRefreshRatePreferHighest": "Penyegaran tertinggi",
@ -153,7 +155,7 @@
"storageVolumeDescriptionFallbackPrimary": "Penyimpanan internal",
"storageVolumeDescriptionFallbackNonPrimary": "kartu SD",
"rootDirectoryDescription": "direktori root",
"otherDirectoryDescription": "“{name}” direktori",
"otherDirectoryDescription": "direktori “{name}”",
"storageAccessDialogTitle": "Akses Penyimpanan",
"storageAccessDialogMessage": "Silahkan pilih {directory} dari “{volume}” di layar berikutnya untuk memberikan akses aplikasi ini ke sana.",
"restrictedAccessDialogTitle": "Akses Terbatas",
@ -163,7 +165,7 @@
"missingSystemFilePickerDialogTitle": "Pemilih File Sistem Tidak Ada",
"missingSystemFilePickerDialogMessage": "Pemilih file sistem tidak ada atau dinonaktifkan. Harap aktifkan dan coba lagi.",
"unsupportedTypeDialogTitle": "Jenis Yang Tidak Didukung",
"unsupportedTypeDialogTitle": "Tipe Yang Tidak Didukung",
"unsupportedTypeDialogMessage": "{count, plural, other{Operasi ini tidak didukung untuk benda dari jenis berikut: {types}.}}",
"nameConflictDialogSingleSourceMessage": "Beberapa file di folder tujuan memiliki nama yang sama.",
@ -184,8 +186,8 @@
"videoStartOverButtonLabel": "ULANG DARI AWAL",
"videoResumeButtonLabel": "LANJUT",
"setCoverDialogTitle": "Setel Sampul",
"setCoverDialogLatest": "Benda terbaru",
"setCoverDialogAuto": "Otomatis",
"setCoverDialogCustom": "Kustom",
"hideFilterConfirmationDialogMessage": "Foto dan video yang cocok akan disembunyikan dari koleksi Anda. Anda dapat menampilkannya lagi dari pengaturan “Privasi”.\n\nApakah Anda yakin ingin menyembunyikannya?",
@ -203,11 +205,11 @@
"renameEntrySetPageInsertTooltip": "Masukkan bidang",
"renameEntrySetPagePreview": "Pratinjau",
"renameProcessorCounter": "Menangkal",
"renameProcessorCounter": "Penghitungan",
"renameProcessorName": "Nama",
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Anda yakin ingin menghapus album ini dan bendanya?} other{Apakah Anda yakin ingin menghapus album ini dan {count} bendanya?}}",
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Yakin ingin menghapus album ini dan bendanya?} other{Anda yakin ingin menghapus album ini dan {count} bendanya?}}",
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Apakah Anda yakin ingin menghapus album ini dan bendanya?} other{Apakah Anda yakin ingin menghapus album ini dan {count} bendanya?}}",
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Apakah Anda yakin ingin menghapus album ini dan bendanya?} other{Apakah Anda yakin ingin menghapus album ini dan {count} bendanya?}}",
"exportEntryDialogFormat": "Format:",
"exportEntryDialogWidth": "Lebar",
@ -239,6 +241,7 @@
"removeEntryMetadataDialogMore": "Lebih Banyak",
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP diperlukan untuk memutar video di dalam Foto bergerak.\n\nAnda yakin ingin menghapusnya?",
"convertMotionPhotoToStillImageWarningDialogMessage": "Apakah kamu yakin?",
"videoSpeedDialogLabel": "Kecepatan pemutaran",
@ -266,6 +269,13 @@
"tileLayoutGrid": "Grid",
"tileLayoutList": "Daftar",
"coverDialogTabCover": "Kover",
"coverDialogTabApp": "Aplikasi",
"coverDialogTabColor": "Warna",
"appPickDialogTitle": "Pilih Aplikasi",
"appPickDialogNone": "Tidak ada",
"aboutPageTitle": "Tentang",
"aboutLinkSources": "Sumber",
"aboutLinkLicense": "Lisensi",
@ -379,8 +389,8 @@
"countryPageTitle": "Negara",
"countryEmpty": "Tidak ada negara",
"tagPageTitle": "Tag",
"tagEmpty": "Tidak ada tag",
"tagPageTitle": "Label",
"tagEmpty": "Tidak ada label",
"binPageTitle": "Tong Sampah",
@ -389,7 +399,7 @@
"searchSectionAlbums": "Album",
"searchSectionCountries": "Negara",
"searchSectionPlaces": "Tempat",
"searchSectionTags": "Tag",
"searchSectionTags": "Label",
"searchSectionRating": "Peringkat",
"settingsPageTitle": "Pengaturan",
@ -539,14 +549,14 @@
"statsWithGps": "{count, plural, other{{count} benda dengan lokasi}}",
"statsTopCountries": "Negara Teratas",
"statsTopPlaces": "Tempat Teratas",
"statsTopTags": "Tag Teratas",
"statsTopTags": "Label Teratas",
"viewerOpenPanoramaButtonLabel": "BUKA PANORAMA",
"viewerErrorUnknown": "Ups!",
"viewerErrorDoesNotExist": "File tidak ada lagi.",
"viewerInfoPageTitle": "Info",
"viewerInfoBackToViewerTooltip": "Kembali ke pemirsa",
"viewerInfoBackToViewerTooltip": "Kembali ke penonton",
"viewerInfoUnknown": "tidak dikenal",
"viewerInfoLabelTitle": "Judul",
@ -582,9 +592,9 @@
"viewerInfoSearchSuggestionResolution": "Resolusi",
"viewerInfoSearchSuggestionRights": "Hak",
"tagEditorPageTitle": "Ubah Tag",
"tagEditorPageNewTagFieldLabel": "Tag baru",
"tagEditorPageAddTagTooltip": "Tambah tag",
"tagEditorPageTitle": "Ubah Label",
"tagEditorPageNewTagFieldLabel": "Label baru",
"tagEditorPageAddTagTooltip": "Tambah label",
"tagEditorSectionRecent": "Terkini",
"panoramaEnableSensorControl": "Aktifkan kontrol sensor",

610
lib/l10n/app_it.arb Normal file
View file

@ -0,0 +1,610 @@
{
"appName": "Aves",
"welcomeMessage": "Benvenuto in Aves",
"welcomeOptional": "Opzionale",
"welcomeTermsToggle": "Accetto i termini e le condizioni",
"itemCount": "{count, plural, =1{1 elemento} other{{count} elementi}}",
"timeSeconds": "{seconds, plural, =1{1 secondo} other{{seconds} secondi}}",
"timeMinutes": "{minutes, plural, =1{1 minuto} other{{minutes} minuti}}",
"timeDays": "{days, plural, =1{1 giorno} other{{days} giorni}}",
"focalLength": "{length} mm",
"applyButtonLabel": "APPLICA",
"deleteButtonLabel": "CANCELLA",
"nextButtonLabel": "AVANTI",
"showButtonLabel": "MOSTRA",
"hideButtonLabel": "NASCONDERE",
"continueButtonLabel": "CONTINUA",
"cancelTooltip": "Annulla",
"changeTooltip": "Cambia",
"clearTooltip": "Cancella",
"previousTooltip": "Precedente",
"nextTooltip": "Successivo",
"showTooltip": "Mostra",
"hideTooltip": "Nascondi",
"actionRemove": "Rimuovi",
"resetButtonTooltip": "Reimposta",
"doubleBackExitMessage": "Tocca di nuovo «indietro» per uscire",
"doNotAskAgain": "Non chiedere di nuovo",
"sourceStateLoading": "Caricamento",
"sourceStateCataloguing": "Catalogazione",
"sourceStateLocatingCountries": "Individuazione dei paesi",
"sourceStateLocatingPlaces": "Individuazione dei luoghi",
"chipActionDelete": "Elimina",
"chipActionGoToAlbumPage": "Mostra negli album",
"chipActionGoToCountryPage": "Mostra nei Paesi",
"chipActionGoToTagPage": "Mostra nelle etichette",
"chipActionHide": "Nascondi",
"chipActionPin": "Fissa in alto",
"chipActionUnpin": "Rimuovi dall'alto",
"chipActionRename": "Rinomina",
"chipActionSetCover": "Imposta copertina",
"chipActionCreateAlbum": "Crea album",
"entryActionCopyToClipboard": "Copia negli appunti",
"entryActionDelete": "Elimina",
"entryActionConvert": "Converti",
"entryActionExport": "Esportazione",
"entryActionRename": "Rinomina",
"entryActionRestore": "Ripristina",
"entryActionRotateCCW": "Ruota in senso antiorario",
"entryActionRotateCW": "Ruota in senso orario",
"entryActionFlip": "Capovolgi orizzontalmente",
"entryActionPrint": "Stampa",
"entryActionShare": "Condividi",
"entryActionViewSource": "Visualizza sorgente",
"entryActionShowGeoTiffOnMap": "Mostra sopra la mappa",
"entryActionConvertMotionPhotoToStillImage": "Converti in immagine statica",
"entryActionViewMotionPhotoVideo": "Apri foto in movimento",
"entryActionEdit": "Modifica",
"entryActionOpen": "Apri con",
"entryActionSetAs": "Imposta come",
"entryActionOpenMap": "Mostra in app mappa",
"entryActionRotateScreen": "Ruota lo schermo",
"entryActionAddFavourite": "Aggiungi ai preferiti",
"entryActionRemoveFavourite": "Rimuovi dai preferiti",
"videoActionCaptureFrame": "Cattura fotogramma",
"videoActionMute": "Disattiva audio",
"videoActionUnmute": "Riattiva audio",
"videoActionPause": "Pausa",
"videoActionPlay": "Riproduci",
"videoActionReplay10": "Cerca indietro di 10 secondi",
"videoActionSkip10": "Cerca in avanti di 10 secondi",
"videoActionSelectStreams": "Seleziona le tracce",
"videoActionSetSpeed": "Velocità di riproduzione",
"videoActionSettings": "Impostazioni",
"entryInfoActionEditDate": "Modifica data e ora",
"entryInfoActionEditLocation": "Modifica posizione",
"entryInfoActionEditRating": "Modifica valutazione",
"entryInfoActionEditTags": "Modifica etichetta",
"entryInfoActionRemoveMetadata": "Rimuovi metadati",
"filterBinLabel": "Cestino",
"filterFavouriteLabel": "Preferiti",
"filterLocationEmptyLabel": "Senza posizione",
"filterTagEmptyLabel": "Senza etichetta",
"filterRatingUnratedLabel": "Non valutato",
"filterRatingRejectedLabel": "Rifiutato",
"filterTypeAnimatedLabel": "Animato",
"filterTypeMotionPhotoLabel": "Foto in movimento",
"filterTypePanoramaLabel": "Panorama",
"filterTypeRawLabel": "Raw",
"filterTypeSphericalVideoLabel": "Video a 360°",
"filterTypeGeotiffLabel": "GeoTIFF",
"filterMimeImageLabel": "Immagine",
"filterMimeVideoLabel": "Video",
"coordinateFormatDms": "DMS",
"coordinateFormatDecimal": "Gradi decimali",
"coordinateDms": "{coordinate} {direction}",
"coordinateDmsNorth": "N",
"coordinateDmsSouth": "S",
"coordinateDmsEast": "E",
"coordinateDmsWest": "O",
"unitSystemMetric": "Metrico",
"unitSystemImperial": "Imperiale",
"videoLoopModeNever": "Mai",
"videoLoopModeShortOnly": "Solo video brevi",
"videoLoopModeAlways": "Sempre",
"videoControlsPlay": "Riproduci",
"videoControlsPlaySeek": "Riproduci e cerca avanti/indietro",
"videoControlsPlayOutside": "Apri con un altro lettore",
"videoControlsNone": "Nessuno",
"mapStyleGoogleNormal": "Google Maps",
"mapStyleGoogleHybrid": "Google Maps (Ibrido)",
"mapStyleGoogleTerrain": "Google Maps (Terreno)",
"mapStyleOsmHot": "OSM umanitario",
"mapStyleStamenToner": "Stamen Toner (Monocromatico)",
"mapStyleStamenWatercolor": "Stamen Watercolor (Acquerello)",
"nameConflictStrategyRename": "Rinomina",
"nameConflictStrategyReplace": "Sostituisci",
"nameConflictStrategySkip": "Salta",
"keepScreenOnNever": "Mai",
"keepScreenOnViewerOnly": "Solo pagina di visualizzazione",
"keepScreenOnAlways": "Sempre",
"accessibilityAnimationsRemove": "Evita gli effetti sullo schermo",
"accessibilityAnimationsKeep": "Mantieni gli effetti sullo schermo",
"displayRefreshRatePreferHighest": "Frequenza massima",
"displayRefreshRatePreferLowest": "Frequenza minima",
"themeBrightnessLight": "Chiaro",
"themeBrightnessDark": "Scuro",
"themeBrightnessBlack": "Nero",
"albumTierNew": "Nuovi",
"albumTierPinned": "Fissati",
"albumTierSpecial": "Frequenti",
"albumTierApps": "Applicazioni",
"albumTierRegular": "Altri",
"storageVolumeDescriptionFallbackPrimary": "Archiviazione interna",
"storageVolumeDescriptionFallbackNonPrimary": "Scheda SD",
"rootDirectoryDescription": "cartella root",
"otherDirectoryDescription": "cartella «{name}»",
"storageAccessDialogTitle": "Accesso a tutti i file",
"storageAccessDialogMessage": "Si prega di selezionare la {directory} di «{volume}» nella prossima schermata per dare accesso a questa applicazione",
"restrictedAccessDialogTitle": "Accesso limitato",
"restrictedAccessDialogMessage": "Questa applicazione non è autorizzata a modificare i file nella {directory} di «{volume}»",
"notEnoughSpaceDialogTitle": "Spazio insufficiente",
"notEnoughSpaceDialogMessage": "Questa operazione ha bisogno di {needSize} di spazio libero su «{volume}» per essere completata, ma è rimasto solo {freeSize}",
"missingSystemFilePickerDialogTitle": "Selezionatore file mancante",
"missingSystemFilePickerDialogMessage": "Il selezionatore file di sistema è mancante o disabilitato. Per favore abilitalo e riprova",
"unsupportedTypeDialogTitle": "Formati non supportati",
"unsupportedTypeDialogMessage": "{count, plural, =1{Questa operazione non è supportata per elementi del seguente tipo: {types}.} other{Questa operazione non è supportata per elementi dei seguenti tipi: {types}.}}",
"nameConflictDialogSingleSourceMessage": "Alcuni file nella cartella di destinazione hanno lo stesso nome",
"nameConflictDialogMultipleSourceMessage": "Alcuni file hanno lo stesso nome",
"addShortcutDialogLabel": "Etichetta della scorciatoia",
"addShortcutButtonLabel": "AGGIUNGI",
"noMatchingAppDialogTitle": "Nessuna app corrispondente",
"noMatchingAppDialogMessage": "Non ci sono app che possono gestire questo",
"binEntriesConfirmationDialogMessage": "{count, plural, =1{Spostare questo elemento nel cestino?} other{Spostare questi {count} elementi nel cestino?}}",
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Cancellare questo elemento?} other{Cancellare questi {count} elementi?}}",
"moveUndatedConfirmationDialogMessage": "Salvare le date degli elementi prima di procedere",
"moveUndatedConfirmationDialogSetDate": "Salvare le date",
"videoResumeDialogMessage": "Vuoi riprendere la riproduzione a {time}?",
"videoStartOverButtonLabel": "RICOMINCIA",
"videoResumeButtonLabel": "RIPRENDI",
"setCoverDialogLatest": "Ultimo elemento",
"setCoverDialogAuto": "Automatico",
"setCoverDialogCustom": "Personalizzato",
"hideFilterConfirmationDialogMessage": "Le foto e i video corrispondenti saranno nascosti dalla tua collezione. Puoi mostrarli di nuovo dalle impostazioni della «Privacy». Sei sicuro di volerli nascondere?",
"newAlbumDialogTitle": "Nuovo album",
"newAlbumDialogNameLabel": "Nome dell'album",
"newAlbumDialogNameLabelAlreadyExistsHelper": "La cartella esiste già",
"newAlbumDialogStorageLabel": "Archiviazione:",
"renameAlbumDialogLabel": "Nuovo nome",
"renameAlbumDialogLabelAlreadyExistsHelper": "La cartella esiste già",
"renameEntrySetPageTitle": "Rinomina",
"renameEntrySetPagePatternFieldLabel": "Schema per i nomi",
"renameEntrySetPageInsertTooltip": "Inserisci campo",
"renameEntrySetPagePreview": "Anteprima",
"renameProcessorCounter": "Contatore",
"renameProcessorName": "Nome",
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Cancellare questo album e i suoi elementi?} other{Cancellare questo album e i suoi {count} elementi?}}",
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Cancellare questi album e i loro elementi?} other{Cancellare questi album e i loro {count} elementi?}",
"exportEntryDialogFormat": "Formato:",
"exportEntryDialogWidth": "Larghezza",
"exportEntryDialogHeight": "Altezza",
"renameEntryDialogLabel": "Nuovo nome",
"editEntryDateDialogTitle": "Data e ora",
"editEntryDateDialogSetCustom": "Imposta data personalizzata",
"editEntryDateDialogCopyField": "Copia da un'altra data",
"editEntryDateDialogCopyItem": "Copia da un altro elemento",
"editEntryDateDialogExtractFromTitle": "Estrai dal titolo",
"editEntryDateDialogShift": "Turno",
"editEntryDateDialogSourceFileModifiedDate": "Data di modifica del file",
"editEntryDateDialogTargetFieldsHeader": "Campi da modificare",
"editEntryDateDialogHours": "Ore",
"editEntryDateDialogMinutes": "Minuti",
"editEntryLocationDialogTitle": "Posizione",
"editEntryLocationDialogChooseOnMapTooltip": "Scegli sulla mappa",
"editEntryLocationDialogLatitude": "Latitudine",
"editEntryLocationDialogLongitude": "Longitudine",
"locationPickerUseThisLocationButton": "Usa questa posizione",
"editEntryRatingDialogTitle": "Valutazione",
"removeEntryMetadataDialogTitle": "Rimozione dei metadati",
"removeEntryMetadataDialogMore": "Altro",
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP è richiesto per riprodurre il video all'interno di una foto in movimento.\n\nSei sicuro di volerlo rimuovere?",
"convertMotionPhotoToStillImageWarningDialogMessage": "Sei sicuro?",
"videoSpeedDialogLabel": "Velocità di riproduzione",
"videoStreamSelectionDialogVideo": "Video",
"videoStreamSelectionDialogAudio": "Audio",
"videoStreamSelectionDialogText": "Sottotitoli",
"videoStreamSelectionDialogOff": "Off",
"videoStreamSelectionDialogTrack": "Traccia",
"videoStreamSelectionDialogNoSelection": "Non ci sono altre tracce",
"genericSuccessFeedback": "Fatto!",
"genericFailureFeedback": "Fallito",
"menuActionConfigureView": "Vista",
"menuActionSelect": "Seleziona",
"menuActionSelectAll": "Seleziona tutto",
"menuActionSelectNone": "Deseleziona tutto",
"menuActionMap": "Mappa",
"menuActionStats": "Statistiche",
"viewDialogTabSort": "Ordina",
"viewDialogTabGroup": "Raggruppa",
"viewDialogTabLayout": "Layout",
"tileLayoutGrid": "Griglia",
"tileLayoutList": "Lista",
"coverDialogTabCover": "Copertina",
"coverDialogTabApp": "App",
"coverDialogTabColor": "Colore",
"appPickDialogTitle": "Seleziona App",
"appPickDialogNone": "Nessuna",
"aboutPageTitle": "Informazioni",
"aboutLinkSources": "Sorgenti",
"aboutLinkLicense": "Licenza",
"aboutLinkPolicy": "Informativa sulla privacy",
"aboutBug": "Segnalazione bug",
"aboutBugSaveLogInstruction": "Salva i log dell'app in un file",
"aboutBugSaveLogButton": "Salva",
"aboutBugCopyInfoInstruction": "Copia le informazioni di sistema",
"aboutBugCopyInfoButton": "Copia",
"aboutBugReportInstruction": "Segnala su GitHub con i log e le informazioni di sistema",
"aboutBugReportButton": "Segnala",
"aboutCredits": "Crediti",
"aboutCreditsWorldAtlas1": "Questa applicazione utilizza un file TopoJSON di",
"aboutCreditsWorldAtlas2": "sotto licenza ISC",
"aboutCreditsTranslators": "Traduttori",
"aboutLicenses": "Licenze Open-Source",
"aboutLicensesBanner": "Questa applicazione utilizza i seguenti pacchetti e librerie open-source",
"aboutLicensesAndroidLibraries": "Librerie Android",
"aboutLicensesFlutterPlugins": "Plugin Flutter",
"aboutLicensesFlutterPackages": "Pacchetti Flutter",
"aboutLicensesDartPackages": "Pacchetti Dart",
"aboutLicensesShowAllButtonLabel": "Mostra tutte le licenze",
"policyPageTitle": "Informativa sulla privacy",
"collectionPageTitle": "Collezione",
"collectionPickPageTitle": "Seleziona",
"collectionSelectPageTitle": "Seleziona elementi",
"collectionActionShowTitleSearch": "Filtra per titoli",
"collectionActionHideTitleSearch": "Nascondi filtro",
"collectionActionAddShortcut": "Aggiungi collegamento",
"collectionActionEmptyBin": "Svuota cestino",
"collectionActionCopy": "Copia nell'album",
"collectionActionMove": "Sposta nell'album",
"collectionActionRescan": "Riscansiona",
"collectionActionEdit": "Modifica",
"collectionSearchTitlesHintText": "Cerca titoli",
"collectionSortDate": "Per data",
"collectionSortSize": "Per dimensione",
"collectionSortName": "Per album e nome del file",
"collectionSortRating": "Per valutazione",
"collectionGroupAlbum": "Per album",
"collectionGroupMonth": "Per mese",
"collectionGroupDay": "Per giorno",
"collectionGroupNone": "Non raggruppare",
"sectionUnknown": "Sconosciuto",
"dateToday": "Oggi",
"dateYesterday": "Ieri",
"dateThisMonth": "Questo mese",
"collectionDeleteFailureFeedback": "{count, plural, =1{Impossibile cancellare 1 elemento} other{Impossibile cancellare {count} elementi}}",
"collectionCopyFailureFeedback": "{count, plural, =1{Impossibile copiare 1 elemento} other{Impossibile copiare {count} elementi}}",
"collectionMoveFailureFeedback": "{count, plural, =1{Impossibile spostare 1 elemento} other{Impossibile spostare {count} elementi}}",
"collectionRenameFailureFeedback": "{count, plural, =1{Impossibile rinominare 1 elemento} other{Impossibile rinominare {count} elementi}}",
"collectionEditFailureFeedback": "{count, plural, =1{Impossibile modificare 1 elemento} other{Impossibile modificare {count} elementi}}",
"collectionExportFailureFeedback": "{count, plural, =1{Impossibile esportare 1 elemento} other{Impossibile esportare {count} elementi}}",
"collectionCopySuccessFeedback": "{count, plural, =1{Copiato 1 elemento} other{Copiati {count} elementi}}",
"collectionMoveSuccessFeedback": "{count, plural, =1{Spostato 1 elemento} other{Spostati {count} elementi}}",
"collectionRenameSuccessFeedback": "{count, plural, =1{Rinominato 1 elemento} other{Rinominati {count} elementi}}",
"collectionEditSuccessFeedback": "{count, plural, =1{Modificato 1 elemento} other{Modificati {count} elementi}}",
"collectionEmptyFavourites": "Nessun preferito",
"collectionEmptyVideos": "Nessun video",
"collectionEmptyImages": "Nessuna immagine",
"collectionSelectSectionTooltip": "Seleziona sezione",
"collectionDeselectSectionTooltip": "Deseleziona sezione",
"drawerCollectionAll": "Tutte le collezioni",
"drawerCollectionFavourites": "Preferiti",
"drawerCollectionImages": "Immagini",
"drawerCollectionVideos": "Video",
"drawerCollectionAnimated": "Animazioni",
"drawerCollectionMotionPhotos": "Foto in movimento",
"drawerCollectionPanoramas": "Panorami",
"drawerCollectionRaws": "Foto raw",
"drawerCollectionSphericalVideos": "Video a 360°",
"chipSortDate": "Per data",
"chipSortName": "Per nome",
"chipSortCount": "Per numero di elementi",
"albumGroupTier": "Per importanza",
"albumGroupVolume": "Per volume di archiviazione",
"albumGroupNone": "Non raggruppare",
"albumPickPageTitleCopy": "Copia",
"albumPickPageTitleExport": "Esporta",
"albumPickPageTitleMove": "Sposta",
"albumPickPageTitlePick": "Seleziona",
"albumCamera": "Camera",
"albumDownload": "Download",
"albumScreenshots": "Screenshot",
"albumScreenRecordings": "Registrazioni schermo",
"albumVideoCaptures": "Scatti nei video",
"albumPageTitle": "Album",
"albumEmpty": "Nessun album",
"createAlbumTooltip": "Crea album",
"createAlbumButtonLabel": "CREA",
"newFilterBanner": "nuovo",
"countryPageTitle": "Paesi",
"countryEmpty": "Nessun paese",
"tagPageTitle": "Etichette",
"tagEmpty": "Nessuna etichetta",
"binPageTitle": "Cestino",
"searchCollectionFieldHint": "Cerca raccolta",
"searchSectionRecent": "Recenti",
"searchSectionAlbums": "Album",
"searchSectionCountries": "Paesi",
"searchSectionPlaces": "Luoghi",
"searchSectionTags": "Etichette",
"searchSectionRating": "Valutazioni",
"settingsPageTitle": "Impostazioni",
"settingsSystemDefault": "Sistema",
"settingsDefault": "Predefinite",
"settingsActionExport": "Esporta",
"settingsActionImport": "Importa",
"appExportCovers": "Copertine",
"appExportFavourites": "Preferiti",
"appExportSettings": "Impostazioni",
"settingsSectionNavigation": "Navigazione",
"settingsHome": "Pagina iniziale",
"settingsKeepScreenOnTile": "Mantieni acceso lo schermo",
"settingsKeepScreenOnTitle": "Illuminazione schermo",
"settingsDoubleBackExit": "Tocca «indietro» due volte per uscire",
"settingsConfirmationDialogTile": "Richieste di conferma",
"settingsConfirmationDialogTitle": "Richieste di conferma",
"settingsConfirmationDialogDeleteItems": "Chiedi prima di cancellare gli elementi definitivamente",
"settingsConfirmationDialogMoveToBinItems": "Chiedi prima di spostare gli elementi nel cestino",
"settingsConfirmationDialogMoveUndatedItems": "Chiedi prima di spostare gli elementi senza data",
"settingsNavigationDrawerTile": "Menu di navigazione",
"settingsNavigationDrawerEditorTitle": "Menu di navigazione",
"settingsNavigationDrawerBanner": "Tocca e tieni premuto per spostare e riordinare gli elementi del menu",
"settingsNavigationDrawerTabTypes": "Tipi",
"settingsNavigationDrawerTabAlbums": "Album",
"settingsNavigationDrawerTabPages": "Pagine",
"settingsNavigationDrawerAddAlbum": "Aggiungi album",
"settingsSectionThumbnails": "Miniature",
"settingsThumbnailShowFavouriteIcon": "Mostra icona preferita",
"settingsThumbnailShowLocationIcon": "Mostra l'icona della posizione",
"settingsThumbnailShowMotionPhotoIcon": "Mostra l'icona della foto in movimento",
"settingsThumbnailShowRating": "Mostra la valutazione",
"settingsThumbnailShowRawIcon": "Mostra icona raw",
"settingsThumbnailShowVideoDuration": "Mostra la durata del video",
"settingsCollectionQuickActionsTile": "Azioni rapide",
"settingsCollectionQuickActionEditorTitle": "Azioni rapide",
"settingsCollectionQuickActionTabBrowsing": "Navigazione",
"settingsCollectionQuickActionTabSelecting": "Selezione",
"settingsCollectionBrowsingQuickActionEditorBanner": "Tocca e tieni premuto per spostare i pulsanti e selezionare quali azioni vengono visualizzate durante la navigazione degli elementi",
"settingsCollectionSelectionQuickActionEditorBanner": "Tocca e tieni premuto per spostare i pulsanti e selezionare quali azioni vengono visualizzate quando si selezionano gli elementi",
"settingsSectionViewer": "Visualizzazione",
"settingsViewerUseCutout": "Usa area di ritaglio",
"settingsViewerMaximumBrightness": "Luminosità massima",
"settingsMotionPhotoAutoPlay": "Riproduzione automatica delle foto in movimento",
"settingsImageBackground": "Sfondo immagine",
"settingsViewerQuickActionsTile": "Azioni rapide",
"settingsViewerQuickActionEditorTitle": "Azioni rapide",
"settingsViewerQuickActionEditorBanner": "Tocca e tieni premuto per spostare i pulsanti e selezionare quali azioni vengono mostrate durante la visualizione",
"settingsViewerQuickActionEditorDisplayedButtons": "Pulsanti visualizzati",
"settingsViewerQuickActionEditorAvailableButtons": "Pulsanti disponibili",
"settingsViewerQuickActionEmpty": "Nessun pulsante",
"settingsViewerOverlayTile": "Sovrapposizione",
"settingsViewerOverlayTitle": "Sovrapposizione",
"settingsViewerShowOverlayOnOpening": "Mostra all'apertura",
"settingsViewerShowMinimap": "Mostra la minimappa",
"settingsViewerShowInformation": "Mostra informazioni",
"settingsViewerShowInformationSubtitle": "Mostra titolo, data, posizione, ecc.",
"settingsViewerShowShootingDetails": "Mostra i dettagli dello scatto",
"settingsViewerShowOverlayThumbnails": "Mostra le miniature",
"settingsViewerEnableOverlayBlurEffect": "Effetto sfocatura",
"settingsVideoPageTitle": "Impostazioni video",
"settingsSectionVideo": "Video",
"settingsVideoShowVideos": "Mostra video",
"settingsVideoEnableHardwareAcceleration": "Accelerazione hardware",
"settingsVideoEnableAutoPlay": "Riproduzione automatica",
"settingsVideoLoopModeTile": "Modalità loop",
"settingsVideoLoopModeTitle": "Modalità loop",
"settingsSubtitleThemeTile": "Sottotitoli",
"settingsSubtitleThemeTitle": "Sottotitoli",
"settingsSubtitleThemeSample": "Questo è un campione",
"settingsSubtitleThemeTextAlignmentTile": "Allineamento del testo",
"settingsSubtitleThemeTextAlignmentTitle": "Allineamento del testo",
"settingsSubtitleThemeTextSize": "Dimensione del testo",
"settingsSubtitleThemeShowOutline": "Mostra contorno e ombra",
"settingsSubtitleThemeTextColor": "Colore del testo",
"settingsSubtitleThemeTextOpacity": "Opacità del testo",
"settingsSubtitleThemeBackgroundColor": "Colore di sfondo",
"settingsSubtitleThemeBackgroundOpacity": "Opacità dello sfondo",
"settingsSubtitleThemeTextAlignmentLeft": "Sinistra",
"settingsSubtitleThemeTextAlignmentCenter": "Centro",
"settingsSubtitleThemeTextAlignmentRight": "Destra",
"settingsVideoControlsTile": "Controlli",
"settingsVideoControlsTitle": "Controlli",
"settingsVideoButtonsTile": "Pulsanti",
"settingsVideoButtonsTitle": "Pulsanti",
"settingsVideoGestureDoubleTapTogglePlay": "Doppio tocco per play/pausa",
"settingsVideoGestureSideDoubleTapSeek": "Doppio tocco sui bordi dello schermo per cercare avanti/indietro",
"settingsSectionPrivacy": "Privacy",
"settingsAllowInstalledAppAccess": "Consentire l'accesso all'inventario delle app",
"settingsAllowInstalledAppAccessSubtitle": "Utilizzato per migliorare la visualizzazione degli album",
"settingsAllowErrorReporting": "Consenti segnalazione anonima degli errori",
"settingsSaveSearchHistory": "Salva la cronologia delle ricerche",
"settingsEnableBin": "Usa il cestino",
"settingsEnableBinSubtitle": "Conserva gli elementi cancellati per 30 giorni",
"settingsHiddenItemsTile": "Elementi nascosti",
"settingsHiddenItemsTitle": "Elementi nascosti",
"settingsHiddenFiltersTitle": "Filtri nascosti",
"settingsHiddenFiltersBanner": "Le foto e i video che corrispondono ai filtri nascosti non appariranno nella tua collezione",
"settingsHiddenFiltersEmpty": "Nessun filtro nascosto",
"settingsHiddenPathsTitle": "Percorsi nascosti",
"settingsHiddenPathsBanner": "Le foto e i video in queste cartelle, o in qualsiasi loro sottocartella, non appariranno nella tua collezione",
"addPathTooltip": "Aggiungi percorso",
"settingsStorageAccessTile": "Accesso a tutti i file",
"settingsStorageAccessTitle": "Accesso a tutti i file",
"settingsStorageAccessBanner": "Alcune cartelle richiedono una concessione di accesso esplicita per modificare i file al loro interno. Puoi rivedere qui le cartelle a cui hai dato accesso in precedenza",
"settingsStorageAccessEmpty": "Nessuna autorizzazione concessa",
"settingsStorageAccessRevokeTooltip": "Rifiuta autorizzazione",
"settingsSectionAccessibility": "Accessibilità",
"settingsRemoveAnimationsTile": "Rimuovi animazioni",
"settingsRemoveAnimationsTitle": "Rimuovi animazioni",
"settingsTimeToTakeActionTile": "Tempo di reazione",
"settingsTimeToTakeActionTitle": "Tempo di reazione",
"settingsSectionDisplay": "Schermo",
"settingsThemeBrightness": "Tema",
"settingsThemeColorHighlights": "Colori evidenziati",
"settingsDisplayRefreshRateModeTile": "Frequenza di aggiornamento dello schermo",
"settingsDisplayRefreshRateModeTitle": "Frequenza di aggiornamento",
"settingsSectionLanguage": "Lingua e formati",
"settingsLanguage": "Lingua",
"settingsCoordinateFormatTile": "Formato coordinate",
"settingsCoordinateFormatTitle": "Formato coordinate",
"settingsUnitSystemTile": "Unità",
"settingsUnitSystemTitle": "Unità",
"statsPageTitle": "Statistiche",
"statsWithGps": "{count, plural, =1{1 elemento con posizione} other{{count} elementi con posizione}}",
"statsTopCountries": "Paesi più frequenti",
"statsTopPlaces": "Luoghi più frequenti",
"statsTopTags": "Etichette più frequenti",
"viewerOpenPanoramaButtonLabel": "APRI PANORAMA",
"viewerErrorUnknown": "Ops!",
"viewerErrorDoesNotExist": "Il file non esiste più",
"viewerInfoPageTitle": "Info",
"viewerInfoBackToViewerTooltip": "Torna alla visualizzazione",
"viewerInfoUnknown": "sconosciuto",
"viewerInfoLabelTitle": "Titolo",
"viewerInfoLabelDate": "Data",
"viewerInfoLabelResolution": "Risoluzione",
"viewerInfoLabelSize": "Dimensione",
"viewerInfoLabelUri": "URI",
"viewerInfoLabelPath": "Percorso",
"viewerInfoLabelDuration": "Durata",
"viewerInfoLabelOwner": "Di proprietà di",
"viewerInfoLabelCoordinates": "Coordinate",
"viewerInfoLabelAddress": "Indirizzo",
"mapStyleTitle": "Stile Mappa",
"mapStyleTooltip": "Seleziona lo stile della mappa",
"mapZoomInTooltip": "Zoom in",
"mapZoomOutTooltip": "Zoom out",
"mapPointNorthUpTooltip": "Punta a nord verso l'alto",
"mapAttributionOsmHot": "Dati della mappa © collaboratori di [OpenStreetMap](https://www.openstreetmap.org/copyright) - Titoli di [HOT](https://www.hotosm.org/) - Ospitato da [OSM France](https://openstreetmap.fr/)",
"mapAttributionStamen": "Dati della mappa © collaboratori di [OpenStreetMap](https://www.openstreetmap.org/copyright) - Titoli di [Stamen Design](http://stamen.com), [CC BY 3.0](http://creativecommons.org/licenses/by/3.0)",
"openMapPageTooltip": "Visualizza sulla pagina della mappa",
"mapEmptyRegion": "Nessuna immagine in questa regione",
"viewerInfoOpenEmbeddedFailureFeedback": "Fallita l'estrazione dei dati incorporati",
"viewerInfoOpenLinkText": "Apri",
"viewerInfoViewXmlLinkText": "Visualizza XML",
"viewerInfoSearchFieldLabel": "Metadati di ricerca",
"viewerInfoSearchEmpty": "Nessuna chiave corrispondente",
"viewerInfoSearchSuggestionDate": "Data e ora",
"viewerInfoSearchSuggestionDescription": "Descrizione",
"viewerInfoSearchSuggestionDimensions": "Dimensioni",
"viewerInfoSearchSuggestionResolution": "Risoluzione",
"viewerInfoSearchSuggestionRights": "Diritti",
"tagEditorPageTitle": "Modifica etichette",
"tagEditorPageNewTagFieldLabel": "Nuova etichetta",
"tagEditorPageAddTagTooltip": "Aggiungi etichetta",
"tagEditorSectionRecent": "Aggiunti di recente",
"panoramaEnableSensorControl": "Abilita il controllo del sensore",
"panoramaDisableSensorControl": "Disabilita il controllo del sensore",
"sourceViewerPageTitle": "Codice sorgente",
"filePickerShowHiddenFiles": "Mostra i file nascosti",
"filePickerDoNotShowHiddenFiles": "Non mostrare i file nascosti",
"filePickerOpenFrom": "Apri da",
"filePickerNoItems": "Nessun elemento",
"filePickerUseThisFolder": "Usa questa cartella"
}

View file

@ -1,6 +1,6 @@
{
"appName": "アヴェス",
"welcomeMessage": "アヴェスへようこそ",
"appName": "Aves",
"welcomeMessage": "Avesへようこそ",
"welcomeOptional": "オプション",
"welcomeTermsToggle": "利用規約に同意する",
"itemCount": "{count, plural, other{{count} 件のアイテム}}",
@ -58,7 +58,6 @@
"entryActionPrint": "印刷",
"entryActionShare": "共有",
"entryActionViewSource": "ソースを表示",
"entryActionViewMotionPhotoVideo": "モーションフォトを開く",
"entryActionEdit": "編集",
"entryActionOpen": "アプリで開く",
"entryActionSetAs": "登録",
@ -111,7 +110,7 @@
"unitSystemImperial": "ヤード・ポンド法",
"videoLoopModeNever": "ループ再生しない",
"videoLoopModeShortOnly": "短い動画のみループ再生",
"videoLoopModeShortOnly": "短い動画のみ",
"videoLoopModeAlways": "常にループ再生",
"videoControlsPlay": "再生",
@ -122,7 +121,7 @@
"mapStyleGoogleNormal": "Google マップ",
"mapStyleGoogleHybrid": "Google マップ(ハイブリッド)",
"mapStyleGoogleTerrain": "Google マップ(地形)",
"mapStyleOsmHot": "人道支援 OSM",
"mapStyleOsmHot": "Humanitarian OSM",
"mapStyleStamenToner": "Stamen Toner",
"mapStyleStamenWatercolor": "Stamen Watercolor",
@ -131,18 +130,18 @@
"nameConflictStrategySkip": "スキップ",
"keepScreenOnNever": "自動オフ",
"keepScreenOnViewerOnly": "ビューアー使用時のみオン",
"keepScreenOnViewerOnly": "ビューアー表示中のみ",
"keepScreenOnAlways": "常にオン",
"accessibilityAnimationsRemove": "画面エフェクトを利用しない",
"accessibilityAnimationsKeep": "画面エフェクトを利用",
"accessibilityAnimationsRemove": "画面効果を表示しない",
"accessibilityAnimationsKeep": "画面効果を表示",
"displayRefreshRatePreferHighest": "高レート",
"displayRefreshRatePreferLowest": "低レート",
"themeBrightnessLight": "ライト",
"themeBrightnessDark": "ダーク",
"themeBrightnessBlack": "",
"themeBrightnessBlack": "ブラック",
"albumTierNew": "新規",
"albumTierPinned": "固定",
@ -184,7 +183,6 @@
"videoStartOverButtonLabel": "最初から再生",
"videoResumeButtonLabel": "再開",
"setCoverDialogTitle": "カバーを設定",
"setCoverDialogLatest": "最新のアイテム",
"setCoverDialogCustom": "カスタム",
@ -405,8 +403,8 @@
"settingsSectionNavigation": "ナビゲーション",
"settingsHome": "ホーム",
"settingsKeepScreenOnTile": "画面表示をオンのままにする",
"settingsKeepScreenOnTitle": "画面表示をオンのままにする",
"settingsKeepScreenOnTile": "画面をオンのままにする",
"settingsKeepScreenOnTitle": "画面をオンのままにする",
"settingsDoubleBackExit": "「戻る」を2回タップして終了",
"settingsConfirmationDialogTile": "確認メッセージ",
@ -441,8 +439,8 @@
"settingsSectionViewer": "ビューアー",
"settingsViewerUseCutout": "切り取り領域を使用",
"settingsViewerMaximumBrightness": "明るさ最大",
"settingsMotionPhotoAutoPlay": "モーションフォト自動再生",
"settingsImageBackground": "画像背景",
"settingsMotionPhotoAutoPlay": "モーションフォト自動再生",
"settingsImageBackground": "画像背景",
"settingsViewerQuickActionsTile": "クイックアクション",
"settingsViewerQuickActionEditorTitle": "クイックアクション",
@ -453,7 +451,7 @@
"settingsViewerOverlayTile": "オーバーレイ",
"settingsViewerOverlayTitle": "オーバーレイ",
"settingsViewerShowOverlayOnOpening": "最初に表示",
"settingsViewerShowOverlayOnOpening": "起動時に表示",
"settingsViewerShowMinimap": "小さな地図を表示",
"settingsViewerShowInformation": "情報を表示",
"settingsViewerShowInformationSubtitle": "タイトル、日付、位置情報、その他を表示",
@ -512,7 +510,7 @@
"settingsStorageAccessTile": "ストレージへのアクセス",
"settingsStorageAccessTitle": "ストレージへのアクセス",
"settingsStorageAccessBanner": "ディレクトリによっては保存されているファイルの編集のためにアクセスを許可する必要があります。これまでにアクセスを許可したディレクトりはこちらで確認できます。",
"settingsStorageAccessBanner": "ディレクトリによっては、ファイルの編集のためにアクセス許可が必要です。ここには、これまでにアクセスを許可したディレクトリが表示されます。",
"settingsStorageAccessEmpty": "許可したアクセスはありません",
"settingsStorageAccessRevokeTooltip": "許可を取り消し",
@ -526,7 +524,7 @@
"settingsThemeBrightness": "テーマ",
"settingsThemeColorHighlights": "カラー強調表示",
"settingsDisplayRefreshRateModeTile": "ディスプレイ リフレッシュ レート",
"settingsDisplayRefreshRateModeTitle": "リフレッシュレート",
"settingsDisplayRefreshRateModeTitle": "リフレッシュ レート",
"settingsSectionLanguage": "言語と形式",
"settingsLanguage": "言語",

View file

@ -58,7 +58,9 @@
"entryActionPrint": "인쇄",
"entryActionShare": "공유",
"entryActionViewSource": "소스 코드 보기",
"entryActionViewMotionPhotoVideo": "모션 포토 보기",
"entryActionShowGeoTiffOnMap": "지도에 겹쳐그리기",
"entryActionConvertMotionPhotoToStillImage": "스틸 사진으로 변환",
"entryActionViewMotionPhotoVideo": "동영상 보기",
"entryActionEdit": "편집",
"entryActionOpen": "다른 앱에서 열기",
"entryActionSetAs": "다음 용도로 사용",
@ -91,7 +93,7 @@
"filterRatingUnratedLabel": "별점 없음",
"filterRatingRejectedLabel": "거부됨",
"filterTypeAnimatedLabel": "애니메이션",
"filterTypeMotionPhotoLabel": "모션 포토",
"filterTypeMotionPhotoLabel": "모션 사진",
"filterTypePanoramaLabel": "파노라마",
"filterTypeRawLabel": "Raw",
"filterTypeSphericalVideoLabel": "360° 동영상",
@ -182,10 +184,10 @@
"videoResumeDialogMessage": "{time}부터 재개하시겠습니까?",
"videoStartOverButtonLabel": "처음부터",
"videoResumeButtonLabel": "재개",
"videoResumeButtonLabel": "이어서",
"setCoverDialogTitle": "대표 이미지 변경",
"setCoverDialogLatest": "최근 항목",
"setCoverDialogAuto": "자동 설정",
"setCoverDialogCustom": "직접 설정",
"hideFilterConfirmationDialogMessage": "이 필터에 맞는 사진과 동영상이 보이지 않을 것입니다. “개인정보 보호” 설정을 수정하면 다시 보일 수 있습니다.\n\n이 필터를 숨기시겠습니까?",
@ -238,7 +240,8 @@
"removeEntryMetadataDialogTitle": "메타데이터 삭제",
"removeEntryMetadataDialogMore": "더 보기",
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP가 있어야 모션 포토에 포함되는 동영상을 재생할 수 있습니다.\n\n삭제하시겠습니까?",
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP가 있어야 모션 사진에 포함되는 동영상을 재생할 수 있습니다.\n\n삭제하시겠습니까?",
"convertMotionPhotoToStillImageWarningDialogMessage": "확실합니까?",
"videoSpeedDialogLabel": "재생 배속",
@ -266,6 +269,13 @@
"tileLayoutGrid": "바둑판",
"tileLayoutList": "목록",
"coverDialogTabCover": "이미지",
"coverDialogTabApp": "앱",
"coverDialogTabColor": "색깔",
"appPickDialogTitle": "앱 선택",
"appPickDialogNone": "없음",
"aboutPageTitle": "앱 정보",
"aboutLinkSources": "소스 코드",
"aboutLinkLicense": "라이선스",
@ -346,7 +356,7 @@
"drawerCollectionImages": "사진",
"drawerCollectionVideos": "동영상",
"drawerCollectionAnimated": "애니메이션",
"drawerCollectionMotionPhotos": "모션 포토",
"drawerCollectionMotionPhotos": "모션 사진",
"drawerCollectionPanoramas": "파노라마",
"drawerCollectionRaws": "Raw 이미지",
"drawerCollectionSphericalVideos": "360° 동영상",
@ -426,7 +436,7 @@
"settingsSectionThumbnails": "섬네일",
"settingsThumbnailShowFavouriteIcon": "즐겨찾기 아이콘 표시",
"settingsThumbnailShowLocationIcon": "위치 아이콘 표시",
"settingsThumbnailShowMotionPhotoIcon": "모션 포토 아이콘 표시",
"settingsThumbnailShowMotionPhotoIcon": "모션 사진 아이콘 표시",
"settingsThumbnailShowRating": "별점 표시",
"settingsThumbnailShowRawIcon": "Raw 아이콘 표시",
"settingsThumbnailShowVideoDuration": "동영상 길이 표시",
@ -441,7 +451,7 @@
"settingsSectionViewer": "뷰어",
"settingsViewerUseCutout": "컷아웃 영역 사용",
"settingsViewerMaximumBrightness": "최대 밝기",
"settingsMotionPhotoAutoPlay": "모션 포토 자동 재생",
"settingsMotionPhotoAutoPlay": "모션 사진 자동 재생",
"settingsImageBackground": "이미지 배경",
"settingsViewerQuickActionsTile": "빠른 작업",

View file

@ -58,7 +58,9 @@
"entryActionPrint": "Imprimir",
"entryActionShare": "Compartilhado",
"entryActionViewSource": "Ver fonte",
"entryActionViewMotionPhotoVideo": "Abrir foto em movimento",
"entryActionShowGeoTiffOnMap": "Mostrar como sobreposição de mapa",
"entryActionConvertMotionPhotoToStillImage": "Converter para imagem estática",
"entryActionViewMotionPhotoVideo": "Abrir vídeo",
"entryActionEdit": "Editar",
"entryActionOpen": "Abrir com",
"entryActionSetAs": "Definir como",
@ -184,8 +186,8 @@
"videoStartOverButtonLabel": "RECOMEÇAR",
"videoResumeButtonLabel": "RETOMAR",
"setCoverDialogTitle": "Definir capa",
"setCoverDialogLatest": "Último item",
"setCoverDialogAuto": "Auto",
"setCoverDialogCustom": "Personalizado",
"hideFilterConfirmationDialogMessage": "Fotos e vídeos correspondentes serão ocultados da sua coleção. Você pode mostrá-los novamente nas configurações de “Privacidade”.\n\nTem certeza de que deseja ocultá-los?",
@ -239,6 +241,7 @@
"removeEntryMetadataDialogMore": "Mais",
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP é necessário para reproduzir o vídeo dentro de uma foto em movimento.\n\nTem certeza de que deseja removê-lo?",
"convertMotionPhotoToStillImageWarningDialogMessage": "Tem certeza?",
"videoSpeedDialogLabel": "Velocidade de reprodução",
@ -266,6 +269,13 @@
"tileLayoutGrid": "Grid",
"tileLayoutList": "Lista",
"coverDialogTabCover": "Capa",
"coverDialogTabApp": "Aplicativo",
"coverDialogTabColor": "Cor",
"appPickDialogTitle": "Escolha o aplicativo",
"appPickDialogNone": "Nenhum",
"aboutPageTitle": "Sobre",
"aboutLinkSources": "Fontes",
"aboutLinkLicense": "Licença",

View file

@ -27,7 +27,7 @@
"actionRemove": "Удалить",
"resetButtonTooltip": "Сбросить",
"doubleBackExitMessage": "Нажмите «назад» еще раз, чтобы выйти.",
"doubleBackExitMessage": "Нажмите «Назад» еще раз, чтобы выйти.",
"doNotAskAgain": "Больше не спрашивать",
"sourceStateLoading": "Загрузка",
@ -58,7 +58,9 @@
"entryActionPrint": "Печать",
"entryActionShare": "Поделиться",
"entryActionViewSource": "Посмотреть источник",
"entryActionViewMotionPhotoVideo": "Открыть «Живые фото»",
"entryActionShowGeoTiffOnMap": "Показать на карте",
"entryActionConvertMotionPhotoToStillImage": "Конвертировать в статичное изображение",
"entryActionViewMotionPhotoVideo": "Открыть видео",
"entryActionEdit": "Изменить",
"entryActionOpen": "Открыть с помощью",
"entryActionSetAs": "Установить как",
@ -137,6 +139,13 @@
"accessibilityAnimationsRemove": "Предотвратить экранные эффекты",
"accessibilityAnimationsKeep": "Сохранить экранные эффекты",
"displayRefreshRatePreferHighest": "Наивысшая частота",
"displayRefreshRatePreferLowest": "Наименьшая частота",
"themeBrightnessLight": "Светлая",
"themeBrightnessDark": "Тёмная",
"themeBrightnessBlack": "Чёрная",
"albumTierNew": "Новые",
"albumTierPinned": "Закрепленные",
"albumTierSpecial": "Стандартные",
@ -150,7 +159,7 @@
"storageAccessDialogTitle": "Доступ к хранилищу",
"storageAccessDialogMessage": "Пожалуйста, выберите {directory} на накопителе «{volume}» на следующем экране, чтобы предоставить этому приложению доступ к нему.",
"restrictedAccessDialogTitle": "Ограниченный доступ",
"restrictedAccessDialogMessage": "Этому приложению не разрешается изменять файлы в каталоге {directory} накопителя «{volume}».\n\nПожалуйста, используйте предустановленный файловый менеджер или галерею, чтобы переместить элементы в другой каталог.",
"restrictedAccessDialogMessage": "Этому приложению не разрешается изменять файлы в каталоге {directory} накопителя «{volume}».\n\nПожалуйста, используйте предустановленный файловый менеджер или галерею, чтобы переместить объекты в другой каталог.",
"notEnoughSpaceDialogTitle": "Недостаточно свободного места.",
"notEnoughSpaceDialogMessage": "Для завершения этой операции требуется {neededSize} свободного места на «{volume}», но осталось только {freeSize}.",
"missingSystemFilePickerDialogTitle": "Отсутствует системное приложение выбора файлов",
@ -168,15 +177,17 @@
"noMatchingAppDialogTitle": "Нет подходящего приложения",
"noMatchingAppDialogMessage": "Нет приложений, которые могли бы с этим справиться.",
"binEntriesConfirmationDialogMessage": "{count, plural, =1{Переместить этот элемент в корзину?} few{Переместить эти {count} элемента в корзину?} other{Переместить эти {count} элементов в корзину?}}",
"binEntriesConfirmationDialogMessage": "{count, plural, =1{Переместить этот объект в корзину?} few{Переместить эти {count} объекта в корзину?} other{Переместить эти {count} объектов в корзину?}}",
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Вы уверены, что хотите удалить этот объект?} few{Вы уверены, что хотите удалить эти {count} объекта?} other{Вы уверены, что хотите удалить эти {count} объектов?}}",
"moveUndatedConfirmationDialogMessage": "Некоторые объекты не имеют даты в метаданных. Их текущая дата будет сброшена с помощью этой операции, если не задана дата в метаданных.",
"moveUndatedConfirmationDialogSetDate": "Установить дату",
"videoResumeDialogMessage": "Хотите ли вы возобновить воспроизведение на {time}?",
"videoStartOverButtonLabel": "ВОСПРОИЗВЕСТИ СНАЧАЛА",
"videoResumeButtonLabel": "ПРОДОЛЖИТЬ",
"setCoverDialogTitle": "Установить обложку",
"setCoverDialogLatest": "Последний объект",
"setCoverDialogAuto": "Авто",
"setCoverDialogCustom": "Собственная",
"hideFilterConfirmationDialogMessage": "Соответствующие фотографии и видео будут скрыты из вашей коллекции. Вы можете показать их снова в настройках в разделе «Конфиденциальность».\n\nВы уверены, что хотите их скрыть?",
@ -189,6 +200,14 @@
"renameAlbumDialogLabel": "Новое название",
"renameAlbumDialogLabelAlreadyExistsHelper": "Каталог уже существует",
"renameEntrySetPageTitle": "Переименовать",
"renameEntrySetPagePatternFieldLabel": "Образец наименования",
"renameEntrySetPageInsertTooltip": "Вставить поле",
"renameEntrySetPagePreview": "Предпросмотр",
"renameProcessorCounter": "Счётчик",
"renameProcessorName": "Название",
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Вы уверены, что хотите удалить этот альбом и его объект?} few{Вы уверены, что хотите удалить этот альбом и его {count} объекта?} other{Вы уверены, что хотите удалить этот альбом и его {count} объектов?}}",
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Вы уверены, что хотите удалить эти альбомы и их объекты?} few{Вы уверены, что хотите удалить эти альбомы и их {count} объекта?} other{Вы уверены, что хотите удалить эти альбомы и их {count} объектов?}}",
@ -201,6 +220,7 @@
"editEntryDateDialogTitle": "Дата и время",
"editEntryDateDialogSetCustom": "Установить дату",
"editEntryDateDialogCopyField": "Копировать с другой даты",
"editEntryDateDialogCopyItem": "Скопировать из другого объекта",
"editEntryDateDialogExtractFromTitle": "Извлечь из названия",
"editEntryDateDialogShift": "Сдвиг",
"editEntryDateDialogSourceFileModifiedDate": "Дата изменения файла",
@ -221,6 +241,7 @@
"removeEntryMetadataDialogMore": "Дополнительно",
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "Для воспроизведения видео внутри этой живой фотографии требуется XMP профиль.\n\nВы уверены, что хотите удалить его?",
"convertMotionPhotoToStillImageWarningDialogMessage": "Вы уверены?",
"videoSpeedDialogLabel": "Скорость воспроизведения",
@ -248,6 +269,13 @@
"tileLayoutGrid": "Сетка",
"tileLayoutList": "Список",
"coverDialogTabCover": "Обложка",
"coverDialogTabApp": "Приложение",
"coverDialogTabColor": "Цвет",
"appPickDialogTitle": "Выберите приложение",
"appPickDialogNone": "Ничего",
"aboutPageTitle": "О нас",
"aboutLinkSources": "Исходники",
"aboutLinkLicense": "Лицензия",
@ -308,10 +336,12 @@
"collectionDeleteFailureFeedback": "{count, plural, =1{Не удалось удалить 1 объект} few{Не удалось удалить {count} объекта} other{Не удалось удалить {count} объектов}}",
"collectionCopyFailureFeedback": "{count, plural, =1{Не удалось скопировать 1 объект} few{Не удалось скопировать {count} объекта} other{Не удалось скопировать {count} объектов}}",
"collectionMoveFailureFeedback": "{count, plural, =1{Не удалось переместить 1 объект} few{Не удалось переместить {count} объекта} other{Не удалось переместить {count} объектов}}",
"collectionRenameFailureFeedback": "{count, plural, =1{Не удалось переименовать 1 объект} few{Не удалось переименовать (count) объекта} other{Не удалось переименовать (count) объектов}}",
"collectionEditFailureFeedback": "{count, plural, =1{Не удалось изменить 1 объект} few{Не удалось изменить {count} объекта} other{Не удалось изменить {count} объектов}}",
"collectionExportFailureFeedback": "{count, plural, =1{Не удалось экспортировать 1 страницу} few{Не удалось экспортировать {count} страницы} other{Не удалось экспортировать {count} страниц}}",
"collectionCopySuccessFeedback": "{count, plural, =1{Скопирован 1 объект} few{Скопировано {count} объекта} other{Скопировано {count} объектов}}",
"collectionMoveSuccessFeedback": "{count, plural, =1{Перемещен 1 объект} few{Перемещено {count} объекта} other{Перемещено {count} объектов}}",
"collectionMoveSuccessFeedback": "{count, plural, =1{Перемещён 1 объект} few{Перемещено {count} объекта} other{Перемещено {count} объектов}}",
"collectionRenameSuccessFeedback": "{count, plural, =1{Переименован 1 объект} few{Переименовао {count} объекта} other{Переименовано {count} объектов}}",
"collectionEditSuccessFeedback": "{count, plural, =1{Изменён 1 объект} few{Изменено {count} объекта} other{Изменено {count} объектов}}",
"collectionEmptyFavourites": "Нет избранных",
@ -391,8 +421,9 @@
"settingsConfirmationDialogTile": "Диалоги подтверждения",
"settingsConfirmationDialogTitle": "Диалоги подтверждения",
"settingsConfirmationDialogDeleteItems": "Спросить, прежде чем удалять элементы навсегда",
"settingsConfirmationDialogMoveToBinItems": "Спросить, прежде чем перемещать элементы в корзину",
"settingsConfirmationDialogDeleteItems": "Спросить, прежде чем удалять объекты навсегда",
"settingsConfirmationDialogMoveToBinItems": "Спросить, прежде чем перемещать объекты в корзину",
"settingsConfirmationDialogMoveUndatedItems": "Спросить, прежде чем перемещать объекты без даты в метаданных",
"settingsNavigationDrawerTile": "Навигационное меню",
"settingsNavigationDrawerEditorTitle": "Навигационное меню",
@ -414,7 +445,7 @@
"settingsCollectionQuickActionEditorTitle": "Быстрые действия",
"settingsCollectionQuickActionTabBrowsing": "Просмотр",
"settingsCollectionQuickActionTabSelecting": "Выбор",
"settingsCollectionBrowsingQuickActionEditorBanner": "Коснитесь и удерживайте для перемещения кнопок и выбора действий, отображаемых при просмотре объектов.",
"settingsCollectionBrowsingQuickActionEditorBanner": "Нажмите и удерживайте для перемещения кнопок и выбора действий, отображаемых при просмотре объектов.",
"settingsCollectionSelectionQuickActionEditorBanner": "Нажмите и удерживайте, чтобы переместить кнопки и выбрать, какие действия будут отображаться при выборе элементов.",
"settingsSectionViewer": "Просмотрщик",
@ -476,7 +507,7 @@
"settingsAllowErrorReporting": "Разрешить анонимную отправку логов",
"settingsSaveSearchHistory": "Сохранять историю поиска",
"settingsEnableBin": "Использовать корзину",
"settingsEnableBinSubtitle": "Хранить удалённые элементы в течение 30 дней",
"settingsEnableBinSubtitle": "Хранить удалённые объекты в течение 30 дней",
"settingsHiddenItemsTile": "Скрытые объекты",
"settingsHiddenItemsTitle": "Скрытые объекты",
@ -501,6 +532,12 @@
"settingsTimeToTakeActionTile": "Время на выполнение действия",
"settingsTimeToTakeActionTitle": "Время на выполнение действия",
"settingsSectionDisplay": "Отображение",
"settingsThemeBrightness": "Тема",
"settingsThemeColorHighlights": "Цветовые акценты",
"settingsDisplayRefreshRateModeTile": "Частота обновления экрана",
"settingsDisplayRefreshRateModeTitle": "Частота обновления",
"settingsSectionLanguage": "Язык и форматы",
"settingsLanguage": "Язык",
"settingsCoordinateFormatTile": "Формат координат",

610
lib/l10n/app_zh.arb Normal file
View file

@ -0,0 +1,610 @@
{
"appName": "Aves",
"welcomeMessage": "欢迎使用 Aves",
"welcomeOptional": "可选",
"welcomeTermsToggle": "我同意这些使用条款和条件",
"itemCount": "{count, plural, other{{count} 项}}",
"timeSeconds": "{seconds, plural, other{{seconds} 秒}}",
"timeMinutes": "{minutes, plural, other{{minutes} 分}}",
"timeDays": "{days, plural, other{{days} 天}}",
"focalLength": "{length} mm",
"applyButtonLabel": "应用",
"deleteButtonLabel": "删除",
"nextButtonLabel": "下一步",
"showButtonLabel": "显示",
"hideButtonLabel": "隐藏",
"continueButtonLabel": "继续",
"cancelTooltip": "取消",
"changeTooltip": "更改",
"clearTooltip": "清除",
"previousTooltip": "上一组",
"nextTooltip": "下一组",
"showTooltip": "显示",
"hideTooltip": "隐藏",
"actionRemove": "移除",
"resetButtonTooltip": "重置",
"doubleBackExitMessage": "再按一次退出",
"doNotAskAgain": "不再询问",
"sourceStateLoading": "加载中",
"sourceStateCataloguing": "正在进行编目",
"sourceStateLocatingCountries": "正在定位国家",
"sourceStateLocatingPlaces": "正在定位地点",
"chipActionDelete": "删除",
"chipActionGoToAlbumPage": "在相册中显示",
"chipActionGoToCountryPage": "在国家中显示",
"chipActionGoToTagPage": "在标签中显示",
"chipActionHide": "隐藏",
"chipActionPin": "置顶",
"chipActionUnpin": "取消置顶",
"chipActionRename": "重命名",
"chipActionSetCover": "设置封面",
"chipActionCreateAlbum": "创建相册",
"entryActionCopyToClipboard": "复制到剪贴板",
"entryActionDelete": "删除",
"entryActionConvert": "转换",
"entryActionExport": "导出",
"entryActionRename": "重命名",
"entryActionRestore": "恢复",
"entryActionRotateCCW": "逆时针旋转",
"entryActionRotateCW": "顺时针旋转",
"entryActionFlip": "水平翻转",
"entryActionPrint": "打印",
"entryActionShare": "分享",
"entryActionViewSource": "查看源码",
"entryActionShowGeoTiffOnMap": "显示为地图叠加层",
"entryActionConvertMotionPhotoToStillImage": "转换为静态图像",
"entryActionViewMotionPhotoVideo": "打开视频",
"entryActionEdit": "编辑",
"entryActionOpen": "打开方式",
"entryActionSetAs": "设置为",
"entryActionOpenMap": "在地图应用中显示",
"entryActionRotateScreen": "旋转屏幕",
"entryActionAddFavourite": "加为收藏",
"entryActionRemoveFavourite": "取消收藏",
"videoActionCaptureFrame": "捕获帧",
"videoActionMute": "静音",
"videoActionUnmute": "取消静音",
"videoActionPause": "暂停",
"videoActionPlay": "播放",
"videoActionReplay10": "前进 10 秒",
"videoActionSkip10": "后退 10 秒",
"videoActionSelectStreams": "选择音轨",
"videoActionSetSpeed": "播放速度",
"videoActionSettings": "设置",
"entryInfoActionEditDate": "编辑日期和时间",
"entryInfoActionEditLocation": "编辑位置",
"entryInfoActionEditRating": "修改评分",
"entryInfoActionEditTags": "编辑标签",
"entryInfoActionRemoveMetadata": "移除元数据",
"filterBinLabel": "回收站",
"filterFavouriteLabel": "收藏夹",
"filterLocationEmptyLabel": "未定位",
"filterTagEmptyLabel": "无标签",
"filterRatingUnratedLabel": "未评分",
"filterRatingRejectedLabel": "拒绝",
"filterTypeAnimatedLabel": "动画",
"filterTypeMotionPhotoLabel": "动态照片",
"filterTypePanoramaLabel": "全景图",
"filterTypeRawLabel": "Raw",
"filterTypeSphericalVideoLabel": "360° 视频",
"filterTypeGeotiffLabel": "GeoTIFF",
"filterMimeImageLabel": "图像",
"filterMimeVideoLabel": "视频",
"coordinateFormatDms": "DMS",
"coordinateFormatDecimal": "十进制度",
"coordinateDms": "{coordinate} {direction}",
"coordinateDmsNorth": "N",
"coordinateDmsSouth": "S",
"coordinateDmsEast": "E",
"coordinateDmsWest": "W",
"unitSystemMetric": "公制",
"unitSystemImperial": "英制",
"videoLoopModeNever": "从不",
"videoLoopModeShortOnly": "仅短视频",
"videoLoopModeAlways": "始终",
"videoControlsPlay": "播放",
"videoControlsPlaySeek": "播放和步进/步退",
"videoControlsPlayOutside": "用其他播放器打开",
"videoControlsNone": "无",
"mapStyleGoogleNormal": "Google Maps",
"mapStyleGoogleHybrid": "Google Maps (Hybrid)",
"mapStyleGoogleTerrain": "Google Maps (Terrain)",
"mapStyleOsmHot": "Humanitarian OSM",
"mapStyleStamenToner": "Stamen Toner",
"mapStyleStamenWatercolor": "Stamen Watercolor",
"nameConflictStrategyRename": "重命名",
"nameConflictStrategyReplace": "替换",
"nameConflictStrategySkip": "跳过",
"keepScreenOnNever": "从不",
"keepScreenOnViewerOnly": "仅查看器页面",
"keepScreenOnAlways": "始终",
"accessibilityAnimationsRemove": "禁用屏幕效果",
"accessibilityAnimationsKeep": "保留屏幕效果",
"displayRefreshRatePreferHighest": "最高刷新率",
"displayRefreshRatePreferLowest": "最低刷新率",
"themeBrightnessLight": "浅色",
"themeBrightnessDark": "深色",
"themeBrightnessBlack": "黑色",
"albumTierNew": "新的",
"albumTierPinned": "钉选",
"albumTierSpecial": "普通",
"albumTierApps": "应用",
"albumTierRegular": "其他",
"storageVolumeDescriptionFallbackPrimary": "内部存储",
"storageVolumeDescriptionFallbackNonPrimary": "SD 卡",
"rootDirectoryDescription": "根目录",
"otherDirectoryDescription": "“{name}” 目录",
"storageAccessDialogTitle": "存储访问权限",
"storageAccessDialogMessage": "请在下一屏幕中选择“{volume}”的{directory},以授予本应用对其的访问权限",
"restrictedAccessDialogTitle": "限制访问",
"restrictedAccessDialogMessage": "本应用无权修改“{volume}”的{directory}中的文件\n\n请使用预装的文件管理器或图库应用将项目移至其他目录",
"notEnoughSpaceDialogTitle": "空间不足",
"notEnoughSpaceDialogMessage": "此操作需要“{volume}”上具有 {neededSize} 可用空间才能完成,实际仅剩 {freeSize}",
"missingSystemFilePickerDialogTitle": "找不到系统文件选择器",
"missingSystemFilePickerDialogMessage": "系统文件选择器缺失或被禁用,请将其启用后再试",
"unsupportedTypeDialogTitle": "不支持的类型",
"unsupportedTypeDialogMessage": "{count, plural, other{此操作不支持以下类型的项目: {types}.}}",
"nameConflictDialogSingleSourceMessage": "目标文件夹中具有同名文件",
"nameConflictDialogMultipleSourceMessage": "存在同名文件",
"addShortcutDialogLabel": "快捷方式标签",
"addShortcutButtonLabel": "添加",
"noMatchingAppDialogTitle": "无匹配的应用",
"noMatchingAppDialogMessage": "无对应的处理程序",
"binEntriesConfirmationDialogMessage": "{count, plural, =1{将此项移至回收站?} other{将这 {count} 项移至回收站?}}",
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{删除此项?} other{删除这 {count} 项?}}",
"moveUndatedConfirmationDialogMessage": "继续之前保存项目日期?",
"moveUndatedConfirmationDialogSetDate": "保存日期",
"videoResumeDialogMessage": "想接着在 {time} 继续播放吗?",
"videoStartOverButtonLabel": "从头播放",
"videoResumeButtonLabel": "继续",
"setCoverDialogLatest": "最新项",
"setCoverDialogAuto": "自动",
"setCoverDialogCustom": "自定义",
"hideFilterConfirmationDialogMessage": "匹配的照片和视频将从收藏中隐藏,你可以通过“隐私”设置中再次显示它们\n\n确定要将其隐藏吗",
"newAlbumDialogTitle": "新相册",
"newAlbumDialogNameLabel": "相册名称",
"newAlbumDialogNameLabelAlreadyExistsHelper": "目录已存在",
"newAlbumDialogStorageLabel": "存储:",
"renameAlbumDialogLabel": "新名称",
"renameAlbumDialogLabelAlreadyExistsHelper": "目录已存在",
"renameEntrySetPageTitle": "重命名",
"renameEntrySetPagePatternFieldLabel": "命名模式",
"renameEntrySetPageInsertTooltip": "插入字段",
"renameEntrySetPagePreview": "预览",
"renameProcessorCounter": "计数器",
"renameProcessorName": "名称",
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{删除此相册及其内容?} other{删除此相册及其 {count} 项内容?}}",
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{删除这些相册及其内容?} other{删除这些相册及其 {count} 项内容?}}",
"exportEntryDialogFormat": "格式:",
"exportEntryDialogWidth": "宽度",
"exportEntryDialogHeight": "高度",
"renameEntryDialogLabel": "新名称",
"editEntryDateDialogTitle": "日期和时间",
"editEntryDateDialogSetCustom": "设置自定义日期",
"editEntryDateDialogCopyField": "复制自其他日期",
"editEntryDateDialogCopyItem": "复制自其他项目",
"editEntryDateDialogExtractFromTitle": "从标题提取",
"editEntryDateDialogShift": "转移",
"editEntryDateDialogSourceFileModifiedDate": "文件修改日期",
"editEntryDateDialogTargetFieldsHeader": "待修改的字段",
"editEntryDateDialogHours": "时",
"editEntryDateDialogMinutes": "分",
"editEntryLocationDialogTitle": "位置",
"editEntryLocationDialogChooseOnMapTooltip": "从地图上选择",
"editEntryLocationDialogLatitude": "纬度",
"editEntryLocationDialogLongitude": "经度",
"locationPickerUseThisLocationButton": "使用此位置",
"editEntryRatingDialogTitle": "评分",
"removeEntryMetadataDialogTitle": "元数据移除工具",
"removeEntryMetadataDialogMore": "更多",
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "播放动态照片中的视频需要 XMP\n\n确定要删除它吗",
"convertMotionPhotoToStillImageWarningDialogMessage": "你确定吗?",
"videoSpeedDialogLabel": "播放速度",
"videoStreamSelectionDialogVideo": "视频",
"videoStreamSelectionDialogAudio": "音频",
"videoStreamSelectionDialogText": "字幕",
"videoStreamSelectionDialogOff": "关",
"videoStreamSelectionDialogTrack": "音轨",
"videoStreamSelectionDialogNoSelection": "无其他音轨",
"genericSuccessFeedback": "完成!",
"genericFailureFeedback": "失败",
"menuActionConfigureView": "查看",
"menuActionSelect": "选择",
"menuActionSelectAll": "全选",
"menuActionSelectNone": "全不选",
"menuActionMap": "地图",
"menuActionStats": "统计",
"viewDialogTabSort": "排序",
"viewDialogTabGroup": "分组",
"viewDialogTabLayout": "布局",
"tileLayoutGrid": "网格",
"tileLayoutList": "列表",
"coverDialogTabCover": "封面",
"coverDialogTabApp": "应用",
"coverDialogTabColor": "颜色",
"appPickDialogTitle": "选择应用",
"appPickDialogNone": "无",
"aboutPageTitle": "关于",
"aboutLinkSources": "源码",
"aboutLinkLicense": "许可协议",
"aboutLinkPolicy": "隐私政策",
"aboutBug": "报告错误",
"aboutBugSaveLogInstruction": "将应用日志保存到文件",
"aboutBugSaveLogButton": "保存",
"aboutBugCopyInfoInstruction": "复制系统信息",
"aboutBugCopyInfoButton": "复制",
"aboutBugReportInstruction": "在 GitHub 上报告日志和系统信息",
"aboutBugReportButton": "报告",
"aboutCredits": "鸣谢",
"aboutCreditsWorldAtlas1": "本应用使用的 TopoJSON 文件来自",
"aboutCreditsWorldAtlas2": "符合 ISC 许可协议",
"aboutCreditsTranslators": "翻译人员",
"aboutLicenses": "开源许可协议",
"aboutLicensesBanner": "本应用使用以下开源软件包和库",
"aboutLicensesAndroidLibraries": "Android Libraries",
"aboutLicensesFlutterPlugins": "Flutter Plugins",
"aboutLicensesFlutterPackages": "Flutter Packages",
"aboutLicensesDartPackages": "Dart Packages",
"aboutLicensesShowAllButtonLabel": "显示所有许可协议",
"policyPageTitle": "隐私政策",
"collectionPageTitle": "媒体集",
"collectionPickPageTitle": "挑选",
"collectionSelectPageTitle": "选择项目",
"collectionActionShowTitleSearch": "显示标题过滤器",
"collectionActionHideTitleSearch": "隐藏标题过滤器",
"collectionActionAddShortcut": "添加快捷方式",
"collectionActionEmptyBin": "清空回收站",
"collectionActionCopy": "复制到相册",
"collectionActionMove": "移至相册",
"collectionActionRescan": "重新扫描",
"collectionActionEdit": "编辑",
"collectionSearchTitlesHintText": "搜索标题",
"collectionSortDate": "按日期",
"collectionSortSize": "按大小",
"collectionSortName": "按相册和文件名",
"collectionSortRating": "按评分",
"collectionGroupAlbum": "按相册",
"collectionGroupMonth": "按月份",
"collectionGroupDay": "按天",
"collectionGroupNone": "不分组",
"sectionUnknown": "未知",
"dateToday": "今天",
"dateYesterday": "昨天",
"dateThisMonth": "本月",
"collectionDeleteFailureFeedback": "{count, plural, other{{count} 项删除失败}}",
"collectionCopyFailureFeedback": "{count, plural, other{{count} 项复制失败}}",
"collectionMoveFailureFeedback": "{count, plural, other{{count} 项移动失败}}",
"collectionRenameFailureFeedback": "{count, plural, other{{count} 项重命名失败}}",
"collectionEditFailureFeedback": "{count, plural, other{{count} 项编辑失败}}",
"collectionExportFailureFeedback": "{count, plural, other{{count} 页导出失败}}",
"collectionCopySuccessFeedback": "{count, plural, other{已复制 {count} 项}}",
"collectionMoveSuccessFeedback": "{count, plural, other{已移动 {count} 项}}",
"collectionRenameSuccessFeedback": "{count, plural, other{已重命名 {count} 项}}",
"collectionEditSuccessFeedback": "{count, plural, other{已编辑 {count} 项}}",
"collectionEmptyFavourites": "无收藏项",
"collectionEmptyVideos": "无视频",
"collectionEmptyImages": "无图像",
"collectionSelectSectionTooltip": "选择部分",
"collectionDeselectSectionTooltip": "取消选择部分",
"drawerCollectionAll": "所有媒体集",
"drawerCollectionFavourites": "收藏夹",
"drawerCollectionImages": "图像",
"drawerCollectionVideos": "视频",
"drawerCollectionAnimated": "动画",
"drawerCollectionMotionPhotos": "动态照片",
"drawerCollectionPanoramas": "全景图",
"drawerCollectionRaws": "Raw 照片",
"drawerCollectionSphericalVideos": "360° 视频",
"chipSortDate": "按日期",
"chipSortName": "按名称",
"chipSortCount": "按数量",
"albumGroupTier": "按层级",
"albumGroupVolume": "按存储卷",
"albumGroupNone": "不分组",
"albumPickPageTitleCopy": "复制到相册",
"albumPickPageTitleExport": "导出到相册",
"albumPickPageTitleMove": "移至相册",
"albumPickPageTitlePick": "选择相册",
"albumCamera": "相机",
"albumDownload": "下载",
"albumScreenshots": "截图",
"albumScreenRecordings": "屏幕录制",
"albumVideoCaptures": "视频捕获",
"albumPageTitle": "相册",
"albumEmpty": "无相册",
"createAlbumTooltip": "创建相册",
"createAlbumButtonLabel": "创建",
"newFilterBanner": "新的",
"countryPageTitle": "国家",
"countryEmpty": "无国家",
"tagPageTitle": "标签",
"tagEmpty": "无标签",
"binPageTitle": "回收站",
"searchCollectionFieldHint": "搜索媒体集",
"searchSectionRecent": "最近",
"searchSectionAlbums": "相册",
"searchSectionCountries": "国家",
"searchSectionPlaces": "地点",
"searchSectionTags": "标签",
"searchSectionRating": "评分",
"settingsPageTitle": "设置",
"settingsSystemDefault": "系统",
"settingsDefault": "默认",
"settingsActionExport": "导出",
"settingsActionImport": "导入",
"appExportCovers": "封面",
"appExportFavourites": "收藏夹",
"appExportSettings": "设置",
"settingsSectionNavigation": "导航",
"settingsHome": "主页",
"settingsKeepScreenOnTile": "保持亮屏",
"settingsKeepScreenOnTitle": "保持亮屏",
"settingsDoubleBackExit": "按两次返回键退出",
"settingsConfirmationDialogTile": "确认对话框",
"settingsConfirmationDialogTitle": "确认对话框",
"settingsConfirmationDialogDeleteItems": "永久删除项目之前询问",
"settingsConfirmationDialogMoveToBinItems": "移至回收站之前询问",
"settingsConfirmationDialogMoveUndatedItems": "移动未注明日期的项目之前询问",
"settingsNavigationDrawerTile": "导航栏菜单",
"settingsNavigationDrawerEditorTitle": "导航栏菜单",
"settingsNavigationDrawerBanner": "长按移动和重新排序菜单项",
"settingsNavigationDrawerTabTypes": "类型",
"settingsNavigationDrawerTabAlbums": "相册",
"settingsNavigationDrawerTabPages": "页面",
"settingsNavigationDrawerAddAlbum": "添加相册",
"settingsSectionThumbnails": "缩略图",
"settingsThumbnailShowFavouriteIcon": "显示收藏图标",
"settingsThumbnailShowLocationIcon": "显示位置图标",
"settingsThumbnailShowMotionPhotoIcon": "显示动态照片图标",
"settingsThumbnailShowRating": "显示评分",
"settingsThumbnailShowRawIcon": "显示 raw 图标",
"settingsThumbnailShowVideoDuration": "显示视频时长",
"settingsCollectionQuickActionsTile": "快速操作",
"settingsCollectionQuickActionEditorTitle": "快速操作",
"settingsCollectionQuickActionTabBrowsing": "浏览",
"settingsCollectionQuickActionTabSelecting": "选择",
"settingsCollectionBrowsingQuickActionEditorBanner": "按住并拖拽可移动按钮并选择浏览项目时显示的操作",
"settingsCollectionSelectionQuickActionEditorBanner": "按住并拖拽可移动按钮并选择选择项目时显示的操作",
"settingsSectionViewer": "查看器",
"settingsViewerUseCutout": "使用剪切区域",
"settingsViewerMaximumBrightness": "最大亮度",
"settingsMotionPhotoAutoPlay": "自动播放动态照片",
"settingsImageBackground": "图像背景",
"settingsViewerQuickActionsTile": "快速操作",
"settingsViewerQuickActionEditorTitle": "快速操作",
"settingsViewerQuickActionEditorBanner": "按住并拖拽可移动按钮并选择查看器中显示的操作",
"settingsViewerQuickActionEditorDisplayedButtons": "显示的按钮",
"settingsViewerQuickActionEditorAvailableButtons": "可用按钮",
"settingsViewerQuickActionEmpty": "无按钮",
"settingsViewerOverlayTile": "叠加层",
"settingsViewerOverlayTitle": "叠加层",
"settingsViewerShowOverlayOnOpening": "打开时显示",
"settingsViewerShowMinimap": "显示小地图",
"settingsViewerShowInformation": "显示信息",
"settingsViewerShowInformationSubtitle": "显示标题、日期、位置等",
"settingsViewerShowShootingDetails": "显示拍摄详情",
"settingsViewerShowOverlayThumbnails": "显示缩略图",
"settingsViewerEnableOverlayBlurEffect": "模糊特效",
"settingsVideoPageTitle": "视频设置",
"settingsSectionVideo": "视频",
"settingsVideoShowVideos": "显示视频",
"settingsVideoEnableHardwareAcceleration": "硬件加速",
"settingsVideoEnableAutoPlay": "自动播放",
"settingsVideoLoopModeTile": "循环模式",
"settingsVideoLoopModeTitle": "循环模式",
"settingsSubtitleThemeTile": "字幕",
"settingsSubtitleThemeTitle": "字幕",
"settingsSubtitleThemeSample": "这是一个字幕示例。",
"settingsSubtitleThemeTextAlignmentTile": "对齐方式",
"settingsSubtitleThemeTextAlignmentTitle": "对齐方式",
"settingsSubtitleThemeTextSize": "文本大小",
"settingsSubtitleThemeShowOutline": "显示轮廓和阴影",
"settingsSubtitleThemeTextColor": "文本颜色",
"settingsSubtitleThemeTextOpacity": "文本透明度",
"settingsSubtitleThemeBackgroundColor": "背景色",
"settingsSubtitleThemeBackgroundOpacity": "背景透明度",
"settingsSubtitleThemeTextAlignmentLeft": "居左",
"settingsSubtitleThemeTextAlignmentCenter": "居中",
"settingsSubtitleThemeTextAlignmentRight": "居右",
"settingsVideoControlsTile": "控件",
"settingsVideoControlsTitle": "控件",
"settingsVideoButtonsTile": "按钮",
"settingsVideoButtonsTitle": "按钮",
"settingsVideoGestureDoubleTapTogglePlay": "双击播放/暂停",
"settingsVideoGestureSideDoubleTapSeek": "双击屏幕边缘步进/步退",
"settingsSectionPrivacy": "隐私",
"settingsAllowInstalledAppAccess": "允许访问应用清单",
"settingsAllowInstalledAppAccessSubtitle": "用于改善相册显示结果",
"settingsAllowErrorReporting": "允许匿名错误报告",
"settingsSaveSearchHistory": "保存搜索历史记录",
"settingsEnableBin": "启用回收站",
"settingsEnableBinSubtitle": "将删除项保留 30 天",
"settingsHiddenItemsTile": "隐藏项",
"settingsHiddenItemsTitle": "隐藏项",
"settingsHiddenFiltersTitle": "隐藏过滤器",
"settingsHiddenFiltersBanner": "匹配隐藏过滤器的照片和视频将不会出现在你的媒体集中",
"settingsHiddenFiltersEmpty": "无隐藏过滤器",
"settingsHiddenPathsTitle": "隐藏路径",
"settingsHiddenPathsBanner": "以下文件夹及其子文件夹中的照片和视频将不会出现在你的媒体集中",
"addPathTooltip": "添加路径",
"settingsStorageAccessTile": "存储访问",
"settingsStorageAccessTitle": "存储访问",
"settingsStorageAccessBanner": "某些目录需要具有明确的访问权限才能修改其中的文件,你可以在此处查看你之前已授予访问权限的目录",
"settingsStorageAccessEmpty": "尚未授予访问权限",
"settingsStorageAccessRevokeTooltip": "撤消",
"settingsSectionAccessibility": "无障碍",
"settingsRemoveAnimationsTile": "移除动画",
"settingsRemoveAnimationsTitle": "移除动画",
"settingsTimeToTakeActionTile": "生效时间",
"settingsTimeToTakeActionTitle": "生效时间",
"settingsSectionDisplay": "显示",
"settingsThemeBrightness": "主题",
"settingsThemeColorHighlights": "色彩强调",
"settingsDisplayRefreshRateModeTile": "显示刷新率",
"settingsDisplayRefreshRateModeTitle": "刷新率",
"settingsSectionLanguage": "语言和格式",
"settingsLanguage": "界面语言",
"settingsCoordinateFormatTile": "坐标格式",
"settingsCoordinateFormatTitle": "坐标格式",
"settingsUnitSystemTile": "单位",
"settingsUnitSystemTitle": "单位",
"statsPageTitle": "统计",
"statsWithGps": "{count, plural, other{{count} 项带位置信息}}",
"statsTopCountries": "热门国家",
"statsTopPlaces": "热门地点",
"statsTopTags": "热门标签",
"viewerOpenPanoramaButtonLabel": "打开全景",
"viewerErrorUnknown": "糟糕!",
"viewerErrorDoesNotExist": "该文件不存在",
"viewerInfoPageTitle": "信息",
"viewerInfoBackToViewerTooltip": "返回查看器",
"viewerInfoUnknown": "未知",
"viewerInfoLabelTitle": "标题",
"viewerInfoLabelDate": "日期",
"viewerInfoLabelResolution": "分辨率",
"viewerInfoLabelSize": "大小",
"viewerInfoLabelUri": "URI",
"viewerInfoLabelPath": "路径",
"viewerInfoLabelDuration": "时长",
"viewerInfoLabelOwner": "所有者",
"viewerInfoLabelCoordinates": "坐标",
"viewerInfoLabelAddress": "地址",
"mapStyleTitle": "地图样式",
"mapStyleTooltip": "选择地图样式",
"mapZoomInTooltip": "放大",
"mapZoomOutTooltip": "缩小",
"mapPointNorthUpTooltip": "上北下南",
"mapAttributionOsmHot": "地图数据由 © [OpenStreetMap](https://www.openstreetmap.org/copyright) 贡献 • 绘制于 [HOT](https://www.hotosm.org/) • 主办方 [OSM France](https://openstreetmap.fr/)",
"mapAttributionStamen": "地图数据由 © [OpenStreetMap](https://www.openstreetmap.org/copyright) 贡献 • 绘制于 [Stamen Design](http://stamen.com), [CC BY 3.0](http://creativecommons.org/licenses/by/3.0)",
"openMapPageTooltip": "在地图页面上查看",
"mapEmptyRegion": "该地区没有图片",
"viewerInfoOpenEmbeddedFailureFeedback": "提取嵌入数据失败",
"viewerInfoOpenLinkText": "打开",
"viewerInfoViewXmlLinkText": "查看 XML",
"viewerInfoSearchFieldLabel": "搜索元数据",
"viewerInfoSearchEmpty": "无匹配键",
"viewerInfoSearchSuggestionDate": "日期和时间",
"viewerInfoSearchSuggestionDescription": "描述",
"viewerInfoSearchSuggestionDimensions": "尺寸",
"viewerInfoSearchSuggestionResolution": "分辨率",
"viewerInfoSearchSuggestionRights": "所有权",
"tagEditorPageTitle": "编辑标签",
"tagEditorPageNewTagFieldLabel": "新标签",
"tagEditorPageAddTagTooltip": "添加标签",
"tagEditorSectionRecent": "最近",
"panoramaEnableSensorControl": "启用传感器控制",
"panoramaDisableSensorControl": "禁用传感器控制",
"sourceViewerPageTitle": "源码",
"filePickerShowHiddenFiles": "显示隐藏文件",
"filePickerDoNotShowHiddenFiles": "不显示隐藏文件",
"filePickerOpenFrom": "打开自",
"filePickerNoItems": "无项目",
"filePickerUseThisFolder": "使用此文件夹"
}

View file

@ -10,19 +10,27 @@ enum EntryInfoAction {
editRating,
editTags,
removeMetadata,
// GeoTIFF
showGeoTiffOnMap,
// motion photo
convertMotionPhotoToStillImage,
viewMotionPhotoVideo,
// debug
debug,
}
class EntryInfoActions {
static const all = [
static const common = [
EntryInfoAction.editDate,
EntryInfoAction.editLocation,
EntryInfoAction.editRating,
EntryInfoAction.editTags,
EntryInfoAction.removeMetadata,
];
static const formatSpecific = [
EntryInfoAction.showGeoTiffOnMap,
EntryInfoAction.convertMotionPhotoToStillImage,
EntryInfoAction.viewMotionPhotoVideo,
];
}
@ -41,7 +49,12 @@ extension ExtraEntryInfoAction on EntryInfoAction {
return context.l10n.entryInfoActionEditTags;
case EntryInfoAction.removeMetadata:
return context.l10n.entryInfoActionRemoveMetadata;
// GeoTIFF
case EntryInfoAction.showGeoTiffOnMap:
return context.l10n.entryActionShowGeoTiffOnMap;
// motion photo
case EntryInfoAction.convertMotionPhotoToStillImage:
return context.l10n.entryActionConvertMotionPhotoToStillImage;
case EntryInfoAction.viewMotionPhotoVideo:
return context.l10n.entryActionViewMotionPhotoVideo;
// debug
@ -77,9 +90,14 @@ extension ExtraEntryInfoAction on EntryInfoAction {
return AIcons.editTags;
case EntryInfoAction.removeMetadata:
return AIcons.clear;
// GeoTIFF
case EntryInfoAction.showGeoTiffOnMap:
return AIcons.map;
// motion photo
case EntryInfoAction.convertMotionPhotoToStillImage:
return AIcons.convertToStillImage;
case EntryInfoAction.viewMotionPhotoVideo:
return AIcons.motionPhoto;
return AIcons.openVideo;
// debug
case EntryInfoAction.debug:
return AIcons.debug;

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
@ -7,10 +9,22 @@ import 'package:aves/utils/android_file_utils.dart';
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:tuple/tuple.dart';
final Covers covers = Covers._private();
class Covers with ChangeNotifier {
class Covers {
final StreamController<Set<CollectionFilter>?> _entryChangeStreamController = StreamController.broadcast();
final StreamController<Set<CollectionFilter>?> _packageChangeStreamController = StreamController.broadcast();
final StreamController<Set<CollectionFilter>?> _colorChangeStreamController = StreamController.broadcast();
Stream<Set<CollectionFilter>?> get entryChangeStream => _entryChangeStreamController.stream;
Stream<Set<CollectionFilter>?> get packageChangeStream => _packageChangeStreamController.stream;
Stream<Set<CollectionFilter>?> get colorChangeStream => _colorChangeStreamController.stream;
Set<CoverRow> _rows = {};
Covers._private();
@ -23,58 +37,88 @@ class Covers with ChangeNotifier {
Set<CoverRow> get all => Set.unmodifiable(_rows);
int? coverEntryId(CollectionFilter filter) => _rows.firstWhereOrNull((row) => row.filter == filter)?.entryId;
Tuple3<int?, String?, Color?>? of(CollectionFilter filter) {
final row = _rows.firstWhereOrNull((row) => row.filter == filter);
return row != null ? Tuple3(row.entryId, row.packageName, row.color) : null;
}
Future<void> set(CollectionFilter filter, int? entryId) async {
Future<void> set({
required CollectionFilter filter,
required int? entryId,
required String? packageName,
required Color? color,
}) async {
// erase contextual properties from filters before saving them
if (filter is AlbumFilter) {
filter = AlbumFilter(filter.album, null);
}
_rows.removeWhere((row) => row.filter == filter);
if (entryId == null) {
final oldRows = _rows.where((row) => row.filter == filter).toSet();
_rows.removeAll(oldRows);
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 {
final row = CoverRow(filter: filter, entryId: entryId);
final row = CoverRow(
filter: filter,
entryId: entryId,
packageName: packageName,
color: color,
);
_rows.add(row);
await metadataDb.addCovers({row});
}
notifyListeners();
if (oldEntry != entryId) _entryChangeStreamController.add({filter});
if (oldPackage != packageName) _packageChangeStreamController.add({filter});
if (oldColor != color) _colorChangeStreamController.add({filter});
}
Future<void> moveEntry(AvesEntry entry, {required bool persist}) async {
Future<void> _removeEntryFromRows(Set<CoverRow> rows) {
return Future.forEach<CoverRow>(
rows,
(row) => set(
filter: row.filter,
entryId: null,
packageName: row.packageName,
color: row.color,
));
}
Future<void> moveEntry(AvesEntry entry) async {
final entryId = entry.id;
final rows = _rows.where((row) => row.entryId == entryId).toSet();
for (final row in rows) {
final filter = row.filter;
if (!filter.test(entry)) {
_rows.remove(row);
if (persist) {
await metadataDb.removeCovers({filter});
}
}
}
notifyListeners();
await _removeEntryFromRows(_rows.where((row) => row.entryId == entryId && !row.filter.test(entry)).toSet());
}
Future<void> removeEntries(Set<AvesEntry> entries) => removeIds(entries.map((entry) => entry.id).toSet());
Future<void> removeIds(Set<int> entryIds) async {
final removedRows = _rows.where((row) => entryIds.contains(row.entryId)).toSet();
await metadataDb.removeCovers(removedRows.map((row) => row.filter).toSet());
_rows.removeAll(removedRows);
notifyListeners();
await _removeEntryFromRows(_rows.where((row) => entryIds.contains(row.entryId)).toSet());
}
Future<void> clear() async {
await metadataDb.clearCovers();
_rows.clear();
notifyListeners();
_entryChangeStreamController.add(null);
_packageChangeStreamController.add(null);
_colorChangeStreamController.add(null);
}
AlbumType effectiveAlbumType(String albumPath) {
final filterPackage = of(AlbumFilter(albumPath, null))?.item2;
if (filterPackage != null) {
return filterPackage.isEmpty ? AlbumType.regular : AlbumType.app;
} else {
return androidFileUtils.getAlbumType(albumPath);
}
}
String? effectiveAlbumPackage(String albumPath) {
final filterPackage = of(AlbumFilter(albumPath, null))?.item2;
return filterPackage ?? androidFileUtils.getAlbumAppPackageName(albumPath);
}
// import/export
@ -85,16 +129,17 @@ class Covers with ChangeNotifier {
.map((row) {
final entryId = row.entryId;
final path = visibleEntries.firstWhereOrNull((entry) => entryId == entry.id)?.path;
if (path == null) return null;
final volume = androidFileUtils.getStorageVolume(path)?.path;
if (volume == null) return null;
final relativePath = volume != null ? path?.substring(volume.length) : null;
final packageName = row.packageName;
final colorValue = row.color?.value;
final relativePath = path.substring(volume.length);
return {
'filter': row.filter.toJson(),
'volume': volume,
'relativePath': relativePath,
if (volume != null) 'volume': volume,
if (relativePath != null) 'relativePath': relativePath,
if (packageName != null) 'packageName': packageName,
if (colorValue != null) 'color': colorValue,
};
})
.whereNotNull()
@ -116,18 +161,27 @@ class Covers with ChangeNotifier {
return;
}
final volume = row['volume'];
final relativePath = row['relativePath'];
if (volume is String && relativePath is String) {
final volume = row['volume'] as String?;
final relativePath = row['relativePath'] as String?;
final packageName = row['packageName'] as String?;
final colorValue = row['color'] as int?;
AvesEntry? entry;
if (volume != null && relativePath != null) {
final path = pContext.join(volume, relativePath);
final entry = visibleEntries.firstWhereOrNull((entry) => entry.path == path && filter.test(entry));
if (entry != null) {
covers.set(filter, entry.id);
} else {
debugPrint('failed to import cover for path=$path, filter=$filter');
entry = visibleEntries.firstWhereOrNull((entry) => entry.path == path && filter.test(entry));
if (entry == null) {
debugPrint('failed to import cover entry for path=$path, filter=$filter');
}
} else {
debugPrint('failed to import cover for volume=$volume, relativePath=$relativePath, filter=$filter');
}
if (entry != null || packageName != null || colorValue != null) {
covers.set(
filter: filter,
entryId: entry?.id,
packageName: packageName,
color: colorValue != null ? Color(colorValue) : null,
);
}
});
}
@ -136,27 +190,38 @@ class Covers with ChangeNotifier {
@immutable
class CoverRow extends Equatable {
final CollectionFilter filter;
final int entryId;
final int? entryId;
final String? packageName;
final Color? color;
@override
List<Object?> get props => [filter, entryId];
List<Object?> get props => [filter, entryId, packageName, color];
const CoverRow({
required this.filter,
required this.entryId,
required this.packageName,
required this.color,
});
static CoverRow? fromMap(Map map) {
final filter = CollectionFilter.fromJson(map['filter']);
if (filter == null) return null;
final colorValue = map['color'] as int?;
final color = colorValue != null ? Color(colorValue) : null;
return CoverRow(
filter: filter,
entryId: map['entryId'],
entryId: map['entryId'] as int?,
packageName: map['packageName'] as String?,
color: color,
);
}
Map<String, dynamic> toMap() => {
'filter': filter.toJson(),
'entryId': entryId,
'packageName': packageName,
'color': color?.value,
};
}

View file

@ -85,6 +85,8 @@ class SqfliteMetadataDb implements MetadataDb {
await db.execute('CREATE TABLE $coverTable('
'filter TEXT PRIMARY KEY'
', entryId INTEGER'
', packageName TEXT'
', color INTEGER'
')');
await db.execute('CREATE TABLE $trashTable('
'id INTEGER PRIMARY KEY'
@ -97,7 +99,7 @@ class SqfliteMetadataDb implements MetadataDb {
')');
},
onUpgrade: MetadataDbUpgrader.upgradeDb,
version: 7,
version: 8,
);
final maxIdRows = await _db.rawQuery('SELECT max(id) AS maxId FROM $entryTable');

View file

@ -35,6 +35,9 @@ class MetadataDbUpgrader {
case 6:
await _upgradeFrom6(db);
break;
case 7:
await _upgradeFrom7(db);
break;
}
oldVersion++;
}
@ -269,4 +272,10 @@ class MetadataDbUpgrader {
', dateMillis INTEGER'
')');
}
static Future<void> _upgradeFrom7(Database db) async {
debugPrint('upgrading DB from v7');
await db.execute('ALTER TABLE $coverTable ADD COLUMN packageName TEXT;');
await db.execute('ALTER TABLE $coverTable ADD COLUMN color INTEGER;');
}
}

View file

@ -5,6 +5,7 @@ import 'dart:ui';
import 'package:aves/geo/countries.dart';
import 'package:aves/model/entry_cache.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/geotiff.dart';
import 'package:aves/model/metadata/address.dart';
import 'package:aves/model/metadata/catalog.dart';
import 'package:aves/model/metadata/trash.dart';
@ -504,16 +505,35 @@ class AvesEntry {
}
catalogMetadata = CatalogMetadata(id: id);
} else {
// pre-processing
if (isVideo && (!isSized || durationMillis == 0)) {
// exotic video that is not sized during loading
final fields = await VideoMetadataFormatter.getLoadingMetadata(this);
await applyNewFields(fields, persist: persist);
}
// cataloguing on platform
catalogMetadata = await metadataFetchService.getCatalogMetadata(this, background: background);
// post-processing
if (isVideo && (catalogMetadata?.dateMillis ?? 0) == 0) {
catalogMetadata = await VideoMetadataFormatter.getCatalogMetadata(this);
}
if (isGeotiff && !hasGps) {
final info = await metadataFetchService.getGeoTiffInfo(this);
if (info != null) {
final center = MappedGeoTiff(
info: info,
entry: this,
).center;
if (center != null) {
catalogMetadata = catalogMetadata?.copyWith(
latitude: center.latitude,
longitude: center.longitude,
);
}
}
}
}
}

View file

@ -63,6 +63,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
editCreateDateXmp(descriptions, null);
break;
}
return true;
}),
};
final newFields = await metadataEditService.editMetadata(this, metadata);
@ -156,10 +157,11 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
if (canEditXmp) {
metadata[MetadataType.xmp] = await _editXmp((descriptions) {
if (missingDate != null) {
final modified = editTagsXmp(descriptions, tags);
if (modified && missingDate != null) {
editCreateDateXmp(descriptions, missingDate);
}
editTagsXmp(descriptions, tags);
return modified;
});
}
@ -185,10 +187,11 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
if (canEditXmp) {
metadata[MetadataType.xmp] = await _editXmp((descriptions) {
if (missingDate != null) {
final modified = editRatingXmp(descriptions, rating);
if (modified && missingDate != null) {
editCreateDateXmp(descriptions, missingDate);
}
editRatingXmp(descriptions, rating);
return modified;
});
}
@ -199,6 +202,36 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
return dataTypes;
}
// remove:
// - trailer video
// - XMP / Container:Directory
// - XMP / GCamera:MicroVideo*
// - XMP / GCamera:MotionPhoto*
Future<Set<EntryDataType>> removeTrailerVideo() async {
final Set<EntryDataType> dataTypes = {};
final Map<MetadataType, dynamic> metadata = {};
if (!canEditXmp) return dataTypes;
final missingDate = await _missingDateCheckAndExifEdit(dataTypes);
final newFields = await metadataEditService.removeTrailerVideo(this);
metadata[MetadataType.xmp] = await _editXmp((descriptions) {
final modified = removeContainerXmp(descriptions);
if (modified && missingDate != null) {
editCreateDateXmp(descriptions, missingDate);
}
return modified;
});
newFields.addAll(await metadataEditService.editMetadata(this, metadata, autoCorrectTrailerOffset: false));
if (newFields.isNotEmpty) {
dataTypes.add(EntryDataType.catalog);
}
return dataTypes;
}
Future<Set<EntryDataType>> removeMetadata(Set<MetadataType> types) async {
final newFields = await metadataEditService.removeTypes(this, types);
return newFields.isEmpty
@ -232,8 +265,8 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
}
@visibleForTesting
static void editTagsXmp(List<XmlNode> descriptions, Set<String> tags) {
XMP.setStringBag(
static bool editTagsXmp(List<XmlNode> descriptions, Set<String> tags) {
return XMP.setStringBag(
descriptions,
XMP.dcSubject,
tags,
@ -243,21 +276,55 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
}
@visibleForTesting
static void editRatingXmp(List<XmlNode> descriptions, int? rating) {
XMP.setAttribute(
static bool editRatingXmp(List<XmlNode> descriptions, int? rating) {
bool modified = false;
modified |= XMP.setAttribute(
descriptions,
XMP.xmpRating,
(rating ?? 0) == 0 ? null : '$rating',
namespace: Namespaces.xmp,
strat: XmpEditStrategy.always,
);
XMP.setAttribute(
modified |= XMP.setAttribute(
descriptions,
XMP.msPhotoRating,
XMP.toMsPhotoRating(rating),
namespace: Namespaces.microsoftPhoto,
strat: XmpEditStrategy.updateIfPresent,
);
return modified;
}
@visibleForTesting
static bool removeContainerXmp(List<XmlNode> descriptions) {
bool modified = false;
modified |= XMP.removeElements(
descriptions,
XMP.containerDirectory,
Namespaces.container,
);
modified |= [
XMP.gCameraMicroVideo,
XMP.gCameraMicroVideoVersion,
XMP.gCameraMicroVideoOffset,
XMP.gCameraMicroVideoPresentationTimestampUs,
XMP.gCameraMotionPhoto,
XMP.gCameraMotionPhotoVersion,
XMP.gCameraMotionPhotoPresentationTimestampUs,
].fold<bool>(modified, (prev, name) {
return prev |= XMP.removeElements(
descriptions,
name,
Namespaces.gCamera,
);
});
return modified;
}
// convenience methods
@ -328,7 +395,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
}
}
Future<Map<String, String?>> _editXmp(void Function(List<XmlNode> descriptions) apply) async {
Future<Map<String, String?>> _editXmp(bool Function(List<XmlNode> descriptions) apply) async {
final xmp = await metadataFetchService.getXmp(this);
final xmpString = xmp?.xmpString;
final extendedXmpString = xmp?.extendedXmpString;

View file

@ -1,3 +1,4 @@
import 'package:aves/model/covers.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/theme/colors.dart';
@ -8,7 +9,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:provider/provider.dart';
class AlbumFilter extends CollectionFilter {
class AlbumFilter extends CoveredCollectionFilter {
static const type = 'album';
final String album;
@ -53,10 +54,14 @@ class AlbumFilter extends CollectionFilter {
@override
Future<Color> color(BuildContext context) {
final colors = context.watch<AvesColorsData>();
// custom color has precedence over others, even custom app color
final customColor = covers.of(this)?.item3;
if (customColor != null) return SynchronousFuture(customColor);
final colors = context.read<AvesColorsData>();
// do not use async/await and rely on `SynchronousFuture`
// to prevent rebuilding of the `FutureBuilder` listening on this future
final albumType = androidFileUtils.getAlbumType(album);
final albumType = covers.effectiveAlbumType(album);
switch (albumType) {
case AlbumType.regular:
break;

View file

@ -35,7 +35,7 @@ class FavouriteFilter extends CollectionFilter {
@override
Future<Color> color(BuildContext context) {
final colors = context.watch<AvesColorsData>();
final colors = context.read<AvesColorsData>();
return SynchronousFuture(colors.favourite);
}

View file

@ -1,5 +1,6 @@
import 'dart:convert';
import 'package:aves/model/covers.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/coordinate.dart';
@ -95,7 +96,7 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => null;
Future<Color> color(BuildContext context) {
final colors = context.watch<AvesColorsData>();
final colors = context.read<AvesColorsData>();
return SynchronousFuture(colors.fromString(getLabel(context)));
}
@ -114,6 +115,20 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
}
}
@immutable
abstract class CoveredCollectionFilter extends CollectionFilter {
const CoveredCollectionFilter({bool not = false}) : super(not: not);
@override
Future<Color> color(BuildContext context) {
final customColor = covers.of(this)?.item3;
if (customColor != null) {
return SynchronousFuture(customColor);
}
return super.color(context);
}
}
@immutable
class FilterGridItem<T extends CollectionFilter> with EquatableMixin {
final T filter;

View file

@ -5,7 +5,7 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
class LocationFilter extends CollectionFilter {
class LocationFilter extends CoveredCollectionFilter {
static const type = 'location';
static const locationSeparator = ';';

View file

@ -77,7 +77,7 @@ class MimeFilter extends CollectionFilter {
@override
Future<Color> color(BuildContext context) {
final colors = context.watch<AvesColorsData>();
final colors = context.read<AvesColorsData>();
switch (mime) {
case MimeTypes.anyImage:
return SynchronousFuture(colors.image);

View file

@ -73,7 +73,7 @@ class QueryFilter extends CollectionFilter {
return super.color(context);
}
final colors = context.watch<AvesColorsData>();
final colors = context.read<AvesColorsData>();
return SynchronousFuture(colors.neutral);
}

View file

@ -3,7 +3,7 @@ import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart';
class TagFilter extends CollectionFilter {
class TagFilter extends CoveredCollectionFilter {
static const type = 'tag';
final String tag;

View file

@ -1,7 +1,7 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/icons.dart';
import 'package:flutter/material.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/material.dart';
class TrashFilter extends CollectionFilter {
static const type = 'trash';

View file

@ -101,7 +101,7 @@ class TypeFilter extends CollectionFilter {
@override
Future<Color> color(BuildContext context) {
final colors = context.watch<AvesColorsData>();
final colors = context.read<AvesColorsData>();
switch (itemType) {
case _animated:
return SynchronousFuture(colors.animated);

231
lib/model/geotiff.dart Normal file
View file

@ -0,0 +1,231 @@
import 'dart:async';
import 'dart:math';
import 'dart:ui' as ui;
import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_images.dart';
import 'package:aves/ref/geotiff.dart';
import 'package:aves/utils/geo_utils.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:aves/widgets/common/map/tile.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import 'package:proj4dart/proj4dart.dart' as proj4;
@immutable
class GeoTiffInfo extends Equatable {
final List<double>? modelPixelScale, modelTiePoints, modelTransformation;
final int? projCSType, projLinearUnits;
@override
List<Object?> get props => [modelPixelScale, modelTiePoints, modelTransformation, projCSType, projLinearUnits];
const GeoTiffInfo({
this.modelPixelScale,
this.modelTiePoints,
this.modelTransformation,
this.projCSType,
this.projLinearUnits,
});
factory GeoTiffInfo.fromMap(Map map) {
return GeoTiffInfo(
modelPixelScale: (map[GeoTiffExifTags.modelPixelScale] as List?)?.cast<double>(),
modelTiePoints: (map[GeoTiffExifTags.modelTiePoints] as List?)?.cast<double>(),
modelTransformation: (map[GeoTiffExifTags.modelTransformation] as List?)?.cast<double>(),
projCSType: map[GeoTiffKeys.projCSType] as int?,
projLinearUnits: map[GeoTiffKeys.projLinearUnits] as int?,
);
}
}
class MappedGeoTiff {
final AvesEntry entry;
late LatLng? Function(Point<int> pixel) pointToLatLng;
late Point<int>? Function(Point<double> smPoint) epsg3857ToPoint;
static final mapServiceTileSize = (256 * ui.window.devicePixelRatio).round();
static final mapServiceHelper = MapServiceHelper(mapServiceTileSize);
static final tileImagePaint = Paint();
static final tileMissingPaint = Paint()
..style = PaintingStyle.fill
..color = Colors.black;
MappedGeoTiff({
required GeoTiffInfo info,
required this.entry,
}) {
pointToLatLng = (_) => null;
epsg3857ToPoint = (_) => null;
// limitation: only support some UTM coordinate systems
final projCSType = info.projCSType;
final srcProj4 = GeoUtils.epsgToProj4(projCSType);
if (srcProj4 == null) {
debugPrint('unsupported projCSType=$projCSType');
return;
}
// limitation: only support model space values in units of meters
// TODO TLAD [geotiff] default from parsing proj4 instead of meter?
final projLinearUnits = info.projLinearUnits ?? GeoTiffUnits.linearMeter;
if (projLinearUnits != GeoTiffUnits.linearMeter) {
debugPrint('unsupported projLinearUnits=$projLinearUnits');
return;
}
// limitation: only support tie points, not transformation matrix
final modelTiePoints = info.modelTiePoints;
if (modelTiePoints == null) return;
if (modelTiePoints.length < 6) return;
// map image space (I,J,K) to model space (X,Y,Z)
final tpI = modelTiePoints[0];
final tpJ = modelTiePoints[1];
final tpK = modelTiePoints[2];
final tpX = modelTiePoints[3];
final tpY = modelTiePoints[4];
final tpZ = modelTiePoints[5];
// limitation: expect 0,0,0,X,Y,0
if (tpI != 0 || tpJ != 0 || tpK != 0 || tpZ != 0) return;
final modelPixelScale = info.modelPixelScale;
if (modelPixelScale == null || modelPixelScale.length < 2) return;
final xScale = modelPixelScale[0];
final yScale = modelPixelScale[1];
final geoTiffProjection = proj4.Projection.parse(srcProj4);
final projToLatLng = proj4.ProjectionTuple(
fromProj: geoTiffProjection,
toProj: proj4.Projection.WGS84,
);
pointToLatLng = (pixel) {
final srcPoint = proj4.Point(
x: tpX + pixel.x * xScale,
y: tpY - pixel.y * yScale,
);
final destPoint = projToLatLng.forward(srcPoint);
final latitude = destPoint.y;
final longitude = destPoint.x;
if (latitude >= -90.0 && latitude <= 90.0 && longitude >= -180.0 && longitude <= 180.0) {
return LatLng(latitude, longitude);
}
return null;
};
final projFromMapService = proj4.ProjectionTuple(
fromProj: proj4.Projection.GOOGLE,
toProj: geoTiffProjection,
);
epsg3857ToPoint = (smPoint) {
final srcPoint = proj4.Point(x: smPoint.x, y: smPoint.y);
final destPoint = projFromMapService.forward(srcPoint);
return Point(((destPoint.x - tpX) / xScale).round(), -((destPoint.y - tpY) / yScale).round());
};
}
Future<MapTile?> getTile(int tx, int ty, int? zoomLevel) async {
zoomLevel ??= 0;
// global projected coordinates in meters (EPSG:3857 Spherical Mercator)
final tileTopLeft3857 = mapServiceHelper.tileTopLeft(tx, ty, zoomLevel);
final tileBottomRight3857 = mapServiceHelper.tileTopLeft(tx + 1, ty + 1, zoomLevel);
// image region coordinates in pixels
final tileTopLeftPx = epsg3857ToPoint(tileTopLeft3857);
final tileBottomRightPx = epsg3857ToPoint(tileBottomRight3857);
if (tileTopLeftPx == null || tileBottomRightPx == null) return null;
final tileLeft = tileTopLeftPx.x;
final tileRight = tileBottomRightPx.x;
final tileTop = tileTopLeftPx.y;
final tileBottom = tileBottomRightPx.y;
final regionLeft = tileLeft.clamp(0, width);
final regionRight = tileRight.clamp(0, width);
final regionTop = tileTop.clamp(0, height);
final regionBottom = tileBottom.clamp(0, height);
final regionWidth = regionRight - regionLeft;
final regionHeight = regionBottom - regionTop;
if (regionWidth == 0 || regionHeight == 0) return null;
final tileXScale = (tileRight - tileLeft) / mapServiceTileSize;
final sampleSize = max<int>(1, highestPowerOf2(tileXScale));
final region = entry.getRegion(
sampleSize: sampleSize,
region: Rectangle(regionLeft, regionTop, regionWidth, regionHeight),
);
final imageInfoCompleter = Completer<ImageInfo?>();
final imageStream = region.resolve(ImageConfiguration.empty);
final imageStreamListener = ImageStreamListener((image, synchronousCall) {
imageInfoCompleter.complete(image);
}, onError: imageInfoCompleter.completeError);
imageStream.addListener(imageStreamListener);
ImageInfo? regionImageInfo;
try {
regionImageInfo = await imageInfoCompleter.future;
} catch (error) {
debugPrint('failed to get image for region=$region with error=$error');
}
imageStream.removeListener(imageStreamListener);
final imageOffset = Offset(
regionLeft > tileLeft ? (regionLeft - tileLeft).toDouble() : 0,
regionTop > tileTop ? (regionTop - tileTop).toDouble() : 0,
);
final tileImageScaleX = (tileRight - tileLeft) / mapServiceTileSize;
final tileImageScaleY = (tileBottom - tileTop) / mapServiceTileSize;
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
canvas.scale(1 / tileImageScaleX, 1 / tileImageScaleY);
if (regionImageInfo != null) {
final s = sampleSize.toDouble();
canvas.scale(s, s);
canvas.drawImage(regionImageInfo.image, imageOffset / s, tileImagePaint);
canvas.scale(1 / s, 1 / s);
} else {
// fallback to show area
canvas.drawRect(
Rect.fromLTWH(
imageOffset.dx,
imageOffset.dy,
regionWidth.toDouble(),
regionHeight.toDouble(),
),
tileMissingPaint,
);
}
canvas.scale(tileImageScaleX, tileImageScaleY);
final picture = recorder.endRecording();
final tileImage = await picture.toImage(mapServiceTileSize, mapServiceTileSize);
final byteData = await tileImage.toByteData(format: ui.ImageByteFormat.png);
if (byteData == null) return null;
return MapTile(
width: tileImage.width,
height: tileImage.height,
data: byteData.buffer.asUint8List(),
);
}
int get width => entry.width;
int get height => entry.height;
bool get canOverlay => center != null;
LatLng? get center => pointToLatLng(Point((width / 2).round(), (height / 2).round()));
LatLng? get topLeft => pointToLatLng(const Point(0, 0));
LatLng? get bottomRight => pointToLatLng(Point(width, height));
}

View file

@ -55,6 +55,8 @@ class CatalogMetadata {
int? dateMillis,
bool? isMultiPage,
int? rotationDegrees,
double? latitude,
double? longitude,
}) {
return CatalogMetadata(
id: id ?? this.id,
@ -68,8 +70,8 @@ class CatalogMetadata {
rotationDegrees: rotationDegrees ?? this.rotationDegrees,
xmpSubjects: xmpSubjects,
xmpTitleDescription: xmpTitleDescription,
latitude: latitude,
longitude: longitude,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
rating: rating,
);
}

View file

@ -28,7 +28,7 @@ class Query extends ChangeNotifier {
void toggle() => enabled = !enabled;
final StreamController<bool> _enabledStreamController = StreamController<bool>.broadcast();
final StreamController<bool> _enabledStreamController = StreamController.broadcast();
Stream<bool> get enabledStream => _enabledStreamController.stream;

View file

@ -19,7 +19,7 @@ final Settings settings = Settings._private();
class Settings extends ChangeNotifier {
final EventChannel _platformSettingsChangeChannel = const EventChannel('deckers.thibault/aves/settings_change');
final StreamController<SettingsChangedEvent> _updateStreamController = StreamController<SettingsChangedEvent>.broadcast();
final StreamController<SettingsChangedEvent> _updateStreamController = StreamController.broadcast();
Stream<SettingsChangedEvent> get updateStream => _updateStreamController.stream;

View file

@ -178,17 +178,17 @@ mixin AlbumMixin on SourceBase {
return volume.getDescription(context);
}
String unique(String dirPath, Set<String?> others) {
String unique(String dirPath, Set<String> others) {
final parts = pContext.split(dirPath);
for (var i = parts.length - 1; i > 0; i--) {
final name = pContext.joinAll(['', ...parts.skip(i)]);
final testName = '$separator$name';
if (others.every((item) => !item!.endsWith(testName))) return name;
if (others.every((item) => !item.endsWith(testName))) return name;
}
return dirPath;
}
final otherAlbumsOnDevice = _directories.where((item) => item != dirPath).toSet();
final otherAlbumsOnDevice = _directories.whereNotNull().where((item) => item != dirPath).toSet();
final uniqueNameInDevice = unique(dirPath, otherAlbumsOnDevice);
if (uniqueNameInDevice.length <= relativeDir.length) {
return uniqueNameInDevice;
@ -196,7 +196,7 @@ mixin AlbumMixin on SourceBase {
final volumePath = dir.volumePath;
String trimVolumePath(String? path) => path!.substring(dir.volumePath.length);
final otherAlbumsOnVolume = otherAlbumsOnDevice.where((path) => path!.startsWith(volumePath)).map(trimVolumePath).toSet();
final otherAlbumsOnVolume = otherAlbumsOnDevice.where((path) => path.startsWith(volumePath)).map(trimVolumePath).toSet();
final uniqueNameInVolume = unique(trimVolumePath(dirPath), otherAlbumsOnVolume);
final volume = androidFileUtils.getStorageVolume(dirPath)!;
if (volume.isPrimary) {

View file

@ -62,19 +62,19 @@ class CollectionLens with ChangeNotifier {
break;
case MoveType.move:
case MoveType.fromBin:
_refresh();
refresh();
break;
case MoveType.toBin:
_onEntryRemoved(e.entries);
break;
}
}));
_subscriptions.add(sourceEvents.on<EntryRefreshedEvent>().listen((e) => _refresh()));
_subscriptions.add(sourceEvents.on<FilterVisibilityChangedEvent>().listen((e) => _refresh()));
_subscriptions.add(sourceEvents.on<CatalogMetadataChangedEvent>().listen((e) => _refresh()));
_subscriptions.add(sourceEvents.on<EntryRefreshedEvent>().listen((e) => refresh()));
_subscriptions.add(sourceEvents.on<FilterVisibilityChangedEvent>().listen((e) => refresh()));
_subscriptions.add(sourceEvents.on<CatalogMetadataChangedEvent>().listen((e) => refresh()));
_subscriptions.add(sourceEvents.on<AddressMetadataChangedEvent>().listen((e) {
if (this.filters.any((filter) => filter is LocationFilter)) {
_refresh();
refresh();
}
}));
favourites.addListener(_onFavouritesChanged);
@ -85,7 +85,7 @@ class CollectionLens with ChangeNotifier {
Settings.collectionGroupFactorKey,
].contains(event.key))
.listen((_) => _onSettingsChanged()));
_refresh();
refresh();
}
@override
@ -171,7 +171,7 @@ class CollectionLens with ChangeNotifier {
}
void _onFilterChanged() {
_refresh();
refresh();
filterChangeNotifier.notifyListeners();
}
@ -259,7 +259,7 @@ class CollectionLens with ChangeNotifier {
// metadata change should also trigger a full refresh
// as dates impact sorting and sectioning
void _refresh() {
void refresh() {
_applyFilters();
_applySort();
_applySection();
@ -267,7 +267,7 @@ class CollectionLens with ChangeNotifier {
void _onFavouritesChanged() {
if (filters.any((filter) => filter is FavouriteFilter)) {
_refresh();
refresh();
}
}
@ -292,7 +292,7 @@ class CollectionLens with ChangeNotifier {
}
void _onEntryAdded(Set<AvesEntry>? entries) {
_refresh();
refresh();
}
void _onEntryRemoved(Set<AvesEntry> entries) {

View file

@ -219,9 +219,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
entry.uri = 'file://${entry.trashDetails?.path}';
}
await covers.moveEntry(entry, persist: persist);
if (persist) {
await covers.moveEntry(entry);
final id = entry.id;
await metadataDb.updateEntry(id, entry);
await metadataDb.updateCatalogMetadata(id, entry.catalogMetadata);
@ -236,7 +235,15 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
final bookmark = settings.drawerAlbumBookmarks?.indexOf(sourceAlbum);
final pinned = settings.pinnedFilters.contains(oldFilter);
await covers.set(newFilter, covers.coverEntryId(oldFilter));
final existingCover = covers.of(oldFilter);
await covers.set(
filter: newFilter,
entryId: existingCover?.item1,
packageName: existingCover?.item2,
color: existingCover?.item3,
);
renameNewAlbum(sourceAlbum, destinationAlbum);
await updateAfterMove(
todoEntries: entries,
@ -441,7 +448,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
}
AvesEntry? coverEntry(CollectionFilter filter) {
final id = covers.coverEntryId(filter);
final id = covers.of(filter)?.item1;
if (id != null) {
final entry = visibleEntries.firstWhereOrNull((entry) => entry.id == id);
if (entry != null) return entry;

29
lib/ref/geotiff.dart Normal file
View file

@ -0,0 +1,29 @@
class GeoTiffExifTags {
static const int modelPixelScale = 0x830e;
static const int modelTiePoints = 0x8482;
static const int modelTransformation = 0x85d8;
static const int geoKeyDirectory = 0x87af;
static const int geoDoubleParams = 0x87b0;
static const int geoAsciiParams = 0x87b1;
}
class GeoTiffKeys {
static const int modelType = 0x0400;
static const int rasterType = 0x0401;
static const int geographicType = 0x0800;
static const int geogGeodeticDatum = 0x0802;
static const int geogAngularUnits = 0x0806;
static const int geogEllipsoid = 0x0808;
static const int projCSType = 0x0c00;
static const int projection = 0x0c02;
static const int projCoordinateTransform = 0x0c03;
static const int projLinearUnits = 0x0c04;
static const int verticalUnits = 0x1003;
}
class GeoTiffUnits {
static const int linearMeter = 9001;
static const int linearFootUSSurvey = 9003;
double footUSSurveyToMeter(double input) => input * 1200 / 3937;
}

View file

@ -14,6 +14,8 @@ abstract class AndroidAppService {
Future<Uint8List> getAppIcon(String packageName, double size);
Future<String?> getAppInstaller();
Future<bool> copyToClipboard(String uri, String? label);
Future<bool> edit(String uri, String mimeType);
@ -73,6 +75,16 @@ class PlatformAndroidAppService implements AndroidAppService {
return Uint8List(0);
}
@override
Future<String?> getAppInstaller() async {
try {
return await platform.invokeMethod('getAppInstaller');
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return null;
}
@override
Future<bool> copyToClipboard(String uri, String? label) async {
try {

View file

@ -8,7 +8,7 @@ import 'package:tuple/tuple.dart';
final ServicePolicy servicePolicy = ServicePolicy._private();
class ServicePolicy {
final StreamController<QueueState> _queueStreamController = StreamController<QueueState>.broadcast();
final StreamController<QueueState> _queueStreamController = StreamController.broadcast();
final Map<Object, Tuple2<int, _Task>> _paused = {};
final SplayTreeMap<int, LinkedHashMap<Object, _Task>> _queues = SplayTreeMap();
final LinkedHashMap<Object, _Task> _runningQueue = LinkedHashMap();

View file

@ -142,7 +142,11 @@ class PlatformMediaFileService implements MediaFileService {
}) as Map;
return AvesEntry.fromMap(result);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
// do not report issues with simple parameter-less media content
// as it is likely an obsolete Media Store entry
if (!uri.startsWith('content://media/external/') || uri.contains('?')) {
await reportService.recordError(e, stack);
}
}
return null;
}

View file

@ -15,7 +15,9 @@ abstract class MetadataEditService {
Future<Map<String, dynamic>> editExifDate(AvesEntry entry, DateModifier modifier);
Future<Map<String, dynamic>> editMetadata(AvesEntry entry, Map<MetadataType, dynamic> modifier);
Future<Map<String, dynamic>> editMetadata(AvesEntry entry, Map<MetadataType, dynamic> modifier, {bool autoCorrectTrailerOffset = true});
Future<Map<String, dynamic>> removeTrailerVideo(AvesEntry entry);
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types);
}
@ -90,11 +92,31 @@ class PlatformMetadataEditService implements MetadataEditService {
}
@override
Future<Map<String, dynamic>> editMetadata(AvesEntry entry, Map<MetadataType, dynamic> metadata) async {
Future<Map<String, dynamic>> editMetadata(
AvesEntry entry,
Map<MetadataType, dynamic> metadata, {
bool autoCorrectTrailerOffset = true,
}) async {
try {
final result = await platform.invokeMethod('editMetadata', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
'metadata': metadata.map((type, value) => MapEntry(_toPlatformMetadataType(type), value)),
'autoCorrectTrailerOffset': autoCorrectTrailerOffset,
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) {
if (!entry.isMissingAtPath) {
await reportService.recordError(e, stack);
}
}
return {};
}
@override
Future<Map<String, dynamic>> removeTrailerVideo(AvesEntry entry) async {
try {
final result = await platform.invokeMethod('removeTrailerVideo', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) {

View file

@ -1,4 +1,5 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/geotiff.dart';
import 'package:aves/model/metadata/catalog.dart';
import 'package:aves/model/metadata/fields.dart';
import 'package:aves/model/metadata/overlay.dart';
@ -19,6 +20,8 @@ abstract class MetadataFetchService {
Future<OverlayMetadata?> getOverlayMetadata(AvesEntry entry);
Future<GeoTiffInfo?> getGeoTiffInfo(AvesEntry entry);
Future<MultiPageInfo?> getMultiPageInfo(AvesEntry entry);
Future<PanoramaInfo?> getPanoramaInfo(AvesEntry entry);
@ -117,6 +120,23 @@ class PlatformMetadataFetchService implements MetadataFetchService {
return null;
}
@override
Future<GeoTiffInfo?> getGeoTiffInfo(AvesEntry entry) async {
try {
final result = await platform.invokeMethod('getGeoTiffInfo', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
}) as Map;
return GeoTiffInfo.fromMap(result);
} on PlatformException catch (e, stack) {
if (!entry.isMissingAtPath) {
await reportService.recordError(e, stack);
}
}
return null;
}
@override
Future<MultiPageInfo?> getMultiPageInfo(AvesEntry entry) async {
try {

View file

@ -24,7 +24,7 @@ abstract class StorageService {
Future<int> deleteEmptyDirectories(Iterable<String> dirPaths);
// returns whether user granted access to a directory of his choosing
Future<bool> requestDirectoryAccess(String volumePath);
Future<bool> requestDirectoryAccess(String path);
Future<bool> canRequestMediaFileAccess();
@ -158,12 +158,12 @@ class PlatformStorageService implements StorageService {
// returns whether user granted access to a directory of his choosing
@override
Future<bool> requestDirectoryAccess(String volumePath) async {
Future<bool> requestDirectoryAccess(String path) async {
try {
final completer = Completer<bool>();
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'requestDirectoryAccess',
'path': volumePath,
'path': path,
}).listen(
(data) => completer.complete(data as bool),
onError: completer.completeError,

View file

@ -1,7 +1,7 @@
import 'package:aves/image_providers/app_icon_image_provider.dart';
import 'package:aves/model/covers.dart';
import 'package:aves/model/settings/enums/enums.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:palette_generator/palette_generator.dart';
@ -55,7 +55,7 @@ abstract class AvesColorsData {
Future<Color>? appColor(String album) {
if (_appColors.containsKey(album)) return SynchronousFuture(_appColors[album]!);
final packageName = androidFileUtils.getAlbumAppPackageName(album);
final packageName = covers.effectiveAlbumPackage(album);
if (packageName == null) return null;
return PaletteGenerator.fromImageProvider(
@ -69,6 +69,8 @@ abstract class AvesColorsData {
});
}
void clearAppColor(String album) => _appColors.remove(album);
static const Color _neutralOnDark = Colors.white;
static const Color _neutralOnLight = Color(0xAA000000);

View file

@ -9,6 +9,7 @@ class AIcons {
static const IconData accessibility = Icons.accessibility_new_outlined;
static const IconData android = Icons.android;
static const IconData app = Icons.apps_outlined;
static const IconData bin = Icons.delete_outlined;
static const IconData broken = Icons.broken_image_outlined;
static const IconData checked = Icons.done_outlined;
@ -20,10 +21,12 @@ class AIcons {
static const IconData folder = Icons.folder_outlined;
static const IconData grid = Icons.grid_on_outlined;
static const IconData home = Icons.home_outlined;
static const IconData important = Icons.label_important_outline;
static const IconData language = Icons.translate_outlined;
static const IconData location = Icons.place_outlined;
static const IconData locationUnlocated = Icons.location_off_outlined;
static const IconData mainStorage = Icons.smartphone_outlined;
static const IconData opacity = Icons.opacity;
static const IconData privacy = MdiIcons.shieldAccountOutline;
static const IconData rating = Icons.star_border_outlined;
static const IconData ratingFull = Icons.star;
@ -52,6 +55,7 @@ class AIcons {
static const IconData clear = Icons.clear_outlined;
static const IconData clipboard = Icons.content_copy_outlined;
static const IconData convert = Icons.transform_outlined;
static const IconData convertToStillImage = MdiIcons.movieRemoveOutline;
static const IconData copy = Icons.file_copy_outlined;
static const IconData debug = Icons.whatshot_outlined;
static const IconData delete = Icons.delete_outlined;
@ -79,6 +83,7 @@ class AIcons {
static const IconData name = Icons.abc_outlined;
static const IconData newTier = Icons.fiber_new_outlined;
static const IconData openOutside = Icons.open_in_new_outlined;
static const IconData openVideo = MdiIcons.moviePlayOutline;
static const IconData pin = Icons.push_pin_outlined;
static const IconData unpin = MdiIcons.pinOffOutline;
static const IconData play = Icons.play_arrow;

View file

@ -183,6 +183,8 @@ class VolumeRelativeDirectory extends Equatable {
@override
List<Object?> get props => [volumePath, relativeDir];
String get dirPath => '$volumePath$relativeDir';
const VolumeRelativeDirectory({
required this.volumePath,
required this.relativeDir,

View file

@ -317,6 +317,11 @@ class Constants {
license: 'Apache 2.0',
sourceUrl: 'https://github.com/DavBfr/dart_pdf',
),
Dependency(
name: 'Proj4dart',
license: 'MIT',
sourceUrl: 'https://github.com/maRci002/proj4dart',
),
Dependency(
name: 'Stack Trace',
license: 'BSD 3-Clause',

View file

@ -56,4 +56,98 @@ class GeoUtils {
final east = ne.longitude;
return (south <= lat && lat <= north) && (west <= east ? (west <= lng && lng <= east) : (west <= lng || lng <= east));
}
// cf https://epsg.io/EPSGCODE.proj4
// cf https://github.com/stevage/epsg
// cf https://github.com/maRci002/proj4dart/blob/master/test/data/all_proj4_defs.dart
static String? epsgToProj4(int? epsg) {
// `32767` refers to user defined values
if (epsg == null || epsg == 32767) return null;
if (3038 <= epsg && epsg <= 3051) {
// ETRS89 / UTM (N-E)
final zone = epsg - 3012;
return '+proj=utm +zone=$zone +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs';
} else if (26700 <= epsg && epsg <= 26799) {
// US State Plane (NAD27): 267xx/320xx
if (26703 <= epsg && epsg <= 26722) {
final zone = epsg - 26700;
return '+proj=utm +zone=$zone +datum=NAD27 +units=m +no_defs';
}
// NAD27 datum requires loading `nadgrids` for accurate transformation:
// cf https://github.com/proj4js/proj4js/pull/363
// cf https://github.com/maRci002/proj4dart/issues/8
if (epsg == 26746) {
// NAD27 / California zone VI
return '+proj=lcc +lat_1=33.88333333333333 +lat_2=32.78333333333333 +lat_0=32.16666666666666 +lon_0=-116.25 +x_0=609601.2192024384 +y_0=0 +datum=NAD27 +units=us-ft +no_defs';
} else if (epsg == 26771) {
// NAD27 / Illinois East
return '+proj=tmerc +lat_0=36.66666666666666 +lon_0=-88.33333333333333 +k=0.9999749999999999 +x_0=152400.3048006096 +y_0=0 +datum=NAD27 +units=us-ft +no_defs';
}
} else if ((26900 <= epsg && epsg <= 26999) || (32100 <= epsg && epsg <= 32199)) {
// US State Plane (NAD83): 269xx/321xx
if (epsg == 26966) {
// NAD83 Georgia East
return '+proj=tmerc +lat_0=30 +lon_0=-82.16666666666667 +k=0.9999 +x_0=200000 +y_0=0 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs';
}
} else if (32200 <= epsg && epsg <= 32299) {
// WGS72 / UTM northern hemisphere: 322zz where zz is UTM zone number
final zone = epsg - 32200;
return '+proj=utm +zone=$zone +ellps=WGS72 +towgs84=0,0,4.5,0,0,0.554,0.2263 +units=m +no_defs';
} else if (32300 <= epsg && epsg <= 32399) {
// WGS72 / UTM southern hemisphere: 323zz where zz is UTM zone number
final zone = epsg - 32300;
return '+proj=utm +zone=$zone +south +ellps=WGS72 +towgs84=0,0,4.5,0,0,0.554,0.2263 +units=m +no_defs';
} else if (32400 <= epsg && epsg <= 32460) {
// WGS72BE / UTM northern hemisphere: 324zz where zz is UTM zone number
final zone = epsg - 32400;
return '+proj=utm +zone=$zone +ellps=WGS72 +towgs84=0,0,1.9,0,0,0.814,-0.38 +units=m +no_defs';
} else if (32500 <= epsg && epsg <= 32599) {
// WGS72BE / UTM southern hemisphere: 325zz where zz is UTM zone number
final zone = epsg - 32500;
return '+proj=utm +zone=$zone +south +ellps=WGS72 +towgs84=0,0,1.9,0,0,0.814,-0.38 +units=m +no_defs';
} else if (32600 <= epsg && epsg <= 32699) {
// WGS84 / UTM northern hemisphere: 326zz where zz is UTM zone number
final zone = epsg - 32600;
return '+proj=utm +zone=$zone +datum=WGS84 +units=m +no_defs';
} else if (32700 <= epsg && epsg <= 32799) {
// WGS84 / UTM southern hemisphere: 327zz where zz is UTM zone number
final zone = epsg - 32700;
return '+proj=utm +zone=$zone +south +datum=WGS84 +units=m +no_defs';
}
return null;
}
}
// cf https://www.maptiler.com/google-maps-coordinates-tile-bounds-projection/
class MapServiceHelper {
final int tileSize;
late final double initialResolution, originShift;
MapServiceHelper(this.tileSize) {
initialResolution = 2 * pi * 6378137 / tileSize;
originShift = 2 * pi * 6378137 / 2.0;
}
int matrixSize(int zoomLevel) {
return 1 << zoomLevel;
}
Point<double> pixelsToMeters(double px, double py, int zoomLevel) {
double res = resolution(zoomLevel);
double mx = px * res - originShift;
double my = -py * res + originShift;
return Point(mx, my);
}
double resolution(int zoomLevel) {
return initialResolution / matrixSize(zoomLevel);
}
Point<double> tileTopLeft(int tx, int ty, int zoomLevel) {
final px = tx * tileSize;
final py = ty * tileSize;
return pixelsToMeters(px.toDouble(), py.toDouble(), zoomLevel);
}
}

View file

@ -2,7 +2,9 @@ import 'package:intl/intl.dart';
import 'package:xml/xml.dart';
class Namespaces {
static const container = 'http://ns.google.com/photos/1.0/container/';
static const dc = 'http://purl.org/dc/elements/1.1/';
static const gCamera = 'http://ns.google.com/photos/1.0/camera/';
static const microsoftPhoto = 'http://ns.microsoft.com/photo/1.0/';
static const rdf = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#';
static const x = 'adobe:ns:meta/';
@ -10,7 +12,9 @@ class Namespaces {
static const xmpNote = 'http://ns.adobe.com/xmp/note/';
static final defaultPrefixes = {
container: 'Container',
dc: 'dc',
gCamera: 'GCamera',
microsoftPhoto: 'MicrosoftPhoto',
rdf: 'rdf',
x: 'x',
@ -30,6 +34,7 @@ class XMP {
static const xXmpmeta = 'xmpmeta';
static const rdfRoot = 'RDF';
static const rdfDescription = 'Description';
static const containerDirectory = 'Directory';
static const dcSubject = 'subject';
static const msPhotoRating = 'Rating';
static const xmpRating = 'Rating';
@ -37,6 +42,13 @@ class XMP {
// attributes
static const xXmptk = 'xmptk';
static const rdfAbout = 'about';
static const gCameraMicroVideo = 'MicroVideo';
static const gCameraMicroVideoVersion = 'MicroVideoVersion';
static const gCameraMicroVideoOffset = 'MicroVideoOffset';
static const gCameraMicroVideoPresentationTimestampUs = 'MicroVideoPresentationTimestampUs';
static const gCameraMotionPhoto = 'MotionPhoto';
static const gCameraMotionPhotoVersion = 'MotionPhotoVersion';
static const gCameraMotionPhotoPresentationTimestampUs = 'MotionPhotoPresentationTimestampUs';
static const xmpCreateDate = 'CreateDate';
static const xmpMetadataDate = 'MetadataDate';
static const xmpModifyDate = 'ModifyDate';
@ -97,7 +109,7 @@ class XMP {
static void _addNamespaces(XmlNode node, Map<String, String> namespaces) => namespaces.forEach((uri, prefix) => node.setAttribute('$xmlnsPrefix:$prefix', uri));
// remove elements and attributes
static bool _removeElements(List<XmlNode> nodes, String name, String namespace) {
static bool removeElements(List<XmlNode> nodes, String name, String namespace) {
var removed = false;
nodes.forEach((node) {
final elements = node.findElements(name, namespace: namespace).toSet();
@ -115,17 +127,18 @@ class XMP {
}
// remove attribute/element from all nodes, and set attribute with new value, if any, in the first node
static void setAttribute(
static bool setAttribute(
List<XmlNode> nodes,
String name,
String? value, {
required String namespace,
required XmpEditStrategy strat,
}) {
final removed = _removeElements(nodes, name, namespace);
final removed = removeElements(nodes, name, namespace);
if (value == null) return;
if (value == null) return removed;
bool modified = removed;
if (strat == XmpEditStrategy.always || (strat == XmpEditStrategy.updateIfPresent && removed)) {
final node = nodes.first;
_addNamespaces(node, {namespace: prefixOf(namespace)});
@ -133,7 +146,10 @@ class XMP {
// use qualified name, otherwise the namespace prefix is not added
final qualifiedName = '${prefixOf(namespace)}$propNamespaceSeparator$name';
node.setAttribute(qualifiedName, value);
modified = true;
}
return modified;
}
// remove attribute/element from all nodes, and create element with new value, if any, in the first node
@ -144,7 +160,7 @@ class XMP {
required String namespace,
required XmpEditStrategy strat,
}) {
final removed = _removeElements(nodes, name, namespace);
final removed = removeElements(nodes, name, namespace);
if (value == null) return;
@ -162,7 +178,7 @@ class XMP {
}
// remove bag from all nodes, and create bag with new values, if any, in the first node
static void setStringBag(
static bool setStringBag(
List<XmlNode> nodes,
String name,
Set<String> values, {
@ -170,10 +186,11 @@ class XMP {
required XmpEditStrategy strat,
}) {
// remove existing
final removed = _removeElements(nodes, name, namespace);
final removed = removeElements(nodes, name, namespace);
if (values.isEmpty) return;
if (values.isEmpty) return removed;
bool modified = removed;
if (strat == XmpEditStrategy.always || (strat == XmpEditStrategy.updateIfPresent && removed)) {
final node = nodes.first;
_addNamespaces(node, {namespace: prefixOf(namespace)});
@ -192,13 +209,16 @@ class XMP {
});
});
node.children.last.children.add(bagBuilder.buildFragment());
modified = true;
}
return modified;
}
static Future<String?> edit(
String? xmpString,
Future<String> Function() toolkit,
void Function(List<XmlNode> descriptions) apply, {
bool Function(List<XmlNode> descriptions) apply, {
DateTime? modifyDate,
}) async {
XmlDocument? xmpDoc;
@ -244,7 +264,7 @@ class XMP {
// get element because doc fragment cannot be used to edit
descriptions.add(rdf.getElement(rdfDescription, namespace: Namespaces.rdf)!);
}
apply(descriptions);
final modified = apply(descriptions);
// clean description nodes with no children
descriptions.where((v) => !_hasMeaningfulChildren(v)).forEach((v) => v.children.clear());
@ -253,10 +273,12 @@ class XMP {
rdf.children.removeWhere((v) => !_hasMeaningfulChildren(v) && !_hasMeaningfulAttributes(v));
if (rdf.children.isNotEmpty) {
_addNamespaces(descriptions.first, {Namespaces.xmp: prefixOf(Namespaces.xmp)});
final xmpDate = toXmpDate(modifyDate ?? DateTime.now());
setAttribute(descriptions, xmpMetadataDate, xmpDate, namespace: Namespaces.xmp, strat: XmpEditStrategy.always);
setAttribute(descriptions, xmpModifyDate, xmpDate, namespace: Namespaces.xmp, strat: XmpEditStrategy.always);
if (modified) {
_addNamespaces(descriptions.first, {Namespaces.xmp: prefixOf(Namespaces.xmp)});
final xmpDate = toXmpDate(modifyDate ?? DateTime.now());
setAttribute(descriptions, xmpMetadataDate, xmpDate, namespace: Namespaces.xmp, strat: XmpEditStrategy.always);
setAttribute(descriptions, xmpModifyDate, xmpDate, namespace: Namespaces.xmp, strat: XmpEditStrategy.always);
}
} else {
// clear XMP if there are no attributes or elements worth preserving
xmpDoc = null;

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