Merge branch 'develop'
9
.github/workflows/check.yml
vendored
|
@ -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
|
||||
|
|
15
.github/workflows/release.yml
vendored
|
@ -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:
|
||||
|
|
23
CHANGELOG.md
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
10
android/app/src/main/res/values-it/strings.xml
Normal 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 & videos</string>
|
||||
<string name="analysis_notification_default_title">Scansione in corso</string>
|
||||
<string name="analysis_notification_action_stop">Annulla</string>
|
||||
</resources>
|
|
@ -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>
|
||||
|
|
10
android/app/src/main/res/values-zh/strings.xml
Normal 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">扫描图像 & 视频</string>
|
||||
<string name="analysis_notification_default_title">正在扫描媒体库</string>
|
||||
<string name="analysis_notification_action_stop">停止</string>
|
||||
</resources>
|
|
@ -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'
|
||||
|
|
5
fastlane/metadata/android/en-US/changelogs/1070.txt
Normal 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
|
5
fastlane/metadata/android/it/full_description.txt
Normal 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>.
|
BIN
fastlane/metadata/android/it/images/featureGraphic.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
fastlane/metadata/android/it/images/phoneScreenshots/1.png
Normal file
After Width: | Height: | Size: 273 KiB |
BIN
fastlane/metadata/android/it/images/phoneScreenshots/2.png
Normal file
After Width: | Height: | Size: 494 KiB |
BIN
fastlane/metadata/android/it/images/phoneScreenshots/3.png
Normal file
After Width: | Height: | Size: 210 KiB |
BIN
fastlane/metadata/android/it/images/phoneScreenshots/4.png
Normal file
After Width: | Height: | Size: 89 KiB |
BIN
fastlane/metadata/android/it/images/phoneScreenshots/5.png
Normal file
After Width: | Height: | Size: 81 KiB |
BIN
fastlane/metadata/android/it/images/phoneScreenshots/6.png
Normal file
After Width: | Height: | Size: 362 KiB |
1
fastlane/metadata/android/it/short_description.txt
Normal file
|
@ -0,0 +1 @@
|
|||
Galleria e esploratore di metadati
|
7
fastlane/metadata/android/ja/full_description.txt
Normal 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>としても機能します。
|
BIN
fastlane/metadata/android/ja/images/featureGraphic.png
Normal file
After Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 13 KiB |
1
fastlane/metadata/android/ja/short_description.txt
Normal file
|
@ -0,0 +1 @@
|
|||
ギャラリーとメタデータエクスプローラー
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 9.4 KiB |
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
5
fastlane/metadata/android/zh-CN/full_description.txt
Normal 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>。
|
BIN
fastlane/metadata/android/zh-CN/images/featureGraphic.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
fastlane/metadata/android/zh-CN/images/phoneScreenshots/1.png
Normal file
After Width: | Height: | Size: 272 KiB |
BIN
fastlane/metadata/android/zh-CN/images/phoneScreenshots/2.png
Normal file
After Width: | Height: | Size: 494 KiB |
BIN
fastlane/metadata/android/zh-CN/images/phoneScreenshots/3.png
Normal file
After Width: | Height: | Size: 208 KiB |
BIN
fastlane/metadata/android/zh-CN/images/phoneScreenshots/4.png
Normal file
After Width: | Height: | Size: 83 KiB |
BIN
fastlane/metadata/android/zh-CN/images/phoneScreenshots/5.png
Normal file
After Width: | Height: | Size: 79 KiB |
BIN
fastlane/metadata/android/zh-CN/images/phoneScreenshots/6.png
Normal file
After Width: | Height: | Size: 362 KiB |
1
fastlane/metadata/android/zh-CN/short_description.txt
Normal file
|
@ -0,0 +1 @@
|
|||
相册和元数据浏览器
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
||||
|
|
|
@ -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 n’apparaî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 d’une 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",
|
||||
|
|
|
@ -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
|
@ -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"
|
||||
}
|
|
@ -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": "言語",
|
||||
|
|
|
@ -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": "빠른 작업",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
@ -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": "使用此文件夹"
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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;');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 = ';';
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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
|
@ -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));
|
||||
}
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
@ -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;
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|