Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2021-12-01 17:57:20 +09:00
commit 0a4c04b2dd
168 changed files with 4226 additions and 1227 deletions

View file

@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
## [v1.5.7] - 2021-12-01
### Added
- add and remove tags to JPEG/GIF/PNG/TIFF images
- French translation
- support for Android KitKat (without Google Maps)
- Viewer: maximum brightness option
### Changed
- Settings: select hidden path directory with a custom file picker instead of the native SAF one
- Viewer: video cover (before playing the video) is now loaded at original resolution and can be zoomed
### Fixed
- pinch-to-zoom gesture on thumbnails was difficult to trigger
- double-tap gesture in the viewer was ignored in some cases
- copied items had the wrong date
## [v1.5.6] - 2021-11-12
### Added

View file

@ -29,7 +29,7 @@ It scans your media collection to identify **motion photos**, **panoramas** (aka
**Navigation and search** is an important part of Aves. The goal is for users to easily flow from albums to photos to tags to maps, etc.
Aves integrates with Android (from **API 20 to 31**, i.e. from Lollipop to S) with features such as **app shortcuts** and **global search** handling. It also works as a **media viewer and picker**.
Aves integrates with Android (from **API 19 to 31**, i.e. from KitKat to S) with features such as **app shortcuts** and **global search** handling. It also works as a **media viewer and picker**.
## Screenshots
@ -55,7 +55,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, feel free to open a PR or send the translation by [email](mailto:gallery.aves@gmail.com). You can find some localization notes in [pubspec.yaml](https://github.com/deckerst/aves/blob/develop/pubspec.yaml). English, Korean and French (soon™) are already handled.
If you want to translate this app in your language and share the result, feel free to open a PR or send the translation by [email](mailto:gallery.aves@gmail.com). You can find some localization notes in [pubspec.yaml](https://github.com/deckerst/aves/blob/develop/pubspec.yaml). English, Korean and French are already handled.
### Donations
@ -82,5 +82,10 @@ To run the app:
# flutter run -t lib/main_play.dart --flavor play
```
To run the app on API 19 emulators:
```
# flutter run -t lib/main_play.dart --flavor play --enable-software-rendering
```
[Version badge]: https://img.shields.io/github/v/release/deckerst/aves?include_prereleases&sort=semver
[Build badge]: https://img.shields.io/github/workflow/status/deckerst/aves/Quality%20check

View file

@ -55,9 +55,8 @@ android {
applicationId appId
// minSdkVersion constraints:
// - Flutter & other plugins: 16
// - google_maps_flutter v2.0.5: 20
// - Aves native: 19
minSdkVersion 20
// - google_maps_flutter v2.1.1: 20
minSdkVersion 19
targetSdkVersion 31
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
@ -149,7 +148,7 @@ dependencies {
// forked, built by JitPack, cf https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a'
// forked, built by JitPack, cf https://jitpack.io/p/deckerst/pixymeta-android
implementation 'com.github.deckerst:pixymeta-android:0bea51ead2'
implementation 'com.github.deckerst:pixymeta-android:706bd73d6e'
implementation 'com.github.bumptech.glide:glide:4.12.0'
kapt 'androidx.annotation:annotation:1.3.0'

View file

@ -4,21 +4,12 @@
android:installLocation="auto">
<!--
Scoped storage for primary storage is unusable on Android Q,
because users are required to confirm each file to be edited or deleted.
These items can only be deleted one by one after catching
a `RecoverableSecurityException` and requesting permission for each.
Android R improvements:
- bulk changes (e.g. `createDeleteRequest`):
https://developer.android.com/preview/privacy/storage#media-file-access
- raw path access:
https://developer.android.com/preview/privacy/storage#media-files-raw-paths
Scoped storage on Android Q is inconvenient because users need to confirm edition on each individual file.
So we request `WRITE_EXTERNAL_STORAGE` until Q (29), and enable `requestLegacyExternalStorage`
-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<!-- request write permission until Q (29) included, because scoped storage is unusable -->
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29"
@ -34,6 +25,9 @@
<!-- for API < 26 -->
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
<!-- allow install on API 19, but Google Maps is from API 20 -->
<uses-sdk tools:overrideLibrary="io.flutter.plugins.googlemaps" />
<!-- from Android R, we should define <queries> to make other apps visible to this app -->
<queries>
<intent>

View file

@ -23,7 +23,6 @@ import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.runBlocking
import java.util.*
class AnalysisService : MethodChannel.MethodCallHandler, Service() {
private var backgroundFlutterEngine: FlutterEngine? = null
@ -44,7 +43,7 @@ class AnalysisService : MethodChannel.MethodCallHandler, Service() {
val messenger = backgroundFlutterEngine!!.dartExecutor.binaryMessenger
// channels for analysis
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler())
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(this))
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
@ -141,11 +140,12 @@ class AnalysisService : MethodChannel.MethodCallHandler, Service() {
getString(R.string.analysis_notification_action_stop),
stopServiceIntent
).build()
val icon = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) R.drawable.ic_notification else R.mipmap.ic_launcher_round
return NotificationCompat.Builder(this, CHANNEL_ANALYSIS)
.setContentTitle(title ?: getText(R.string.analysis_notification_default_title))
.setContentText(message)
.setBadgeIconType(NotificationCompat.BADGE_ICON_NONE)
.setSmallIcon(R.drawable.ic_notification)
.setSmallIcon(icon)
.setContentIntent(openAppIntent)
.setPriority(NotificationCompat.PRIORITY_LOW)
.addAction(stopAction)

View file

@ -59,7 +59,7 @@ class MainActivity : FlutterActivity() {
MethodChannel(messenger, AnalysisHandler.CHANNEL).setMethodCallHandler(analysisHandler)
MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler())
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(this))
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
MethodChannel(messenger, GlobalSearchHandler.CHANNEL).setMethodCallHandler(GlobalSearchHandler(this))
@ -151,8 +151,7 @@ class MainActivity : FlutterActivity() {
DELETE_SINGLE_PERMISSION_REQUEST,
MEDIA_WRITE_BULK_PERMISSION_REQUEST -> onScopedStoragePermissionResult(resultCode)
CREATE_FILE_REQUEST,
OPEN_FILE_REQUEST,
SELECT_DIRECTORY_REQUEST -> onStorageAccessResult(requestCode, data?.data)
OPEN_FILE_REQUEST -> onStorageAccessResult(requestCode, data?.data)
}
}
@ -164,11 +163,13 @@ class MainActivity : FlutterActivity() {
return
}
// save access permissions across reboots
val takeFlags = (data.flags
and (Intent.FLAG_GRANT_READ_URI_PERMISSION
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION))
contentResolver.takePersistableUriPermission(treeUri, takeFlags)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
// save access permissions across reboots
val takeFlags = (data.flags
and (Intent.FLAG_GRANT_READ_URI_PERMISSION
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION))
contentResolver.takePersistableUriPermission(treeUri, takeFlags)
}
// resume pending action
onStorageAccessResult(requestCode, treeUri)
@ -183,45 +184,45 @@ class MainActivity : FlutterActivity() {
private fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
when (intent?.action) {
Intent.ACTION_MAIN -> {
intent.getStringExtra("page")?.let { page ->
var filters = intent.getStringArrayExtra("filters")?.toList()
intent.getStringExtra(SHORTCUT_KEY_PAGE)?.let { page ->
var filters = intent.getStringArrayExtra(SHORTCUT_KEY_FILTERS_ARRAY)?.toList()
if (filters == null) {
// fallback for shortcuts created on API < 26
val filterString = intent.getStringExtra("filtersString")
val filterString = intent.getStringExtra(SHORTCUT_KEY_FILTERS_STRING)
if (filterString != null) {
filters = filterString.split(EXTRA_STRING_ARRAY_SEPARATOR)
}
}
return hashMapOf(
"page" to page,
"filters" to filters,
INTENT_DATA_KEY_PAGE to page,
INTENT_DATA_KEY_FILTERS to filters,
)
}
}
Intent.ACTION_VIEW, Intent.ACTION_SEND, "com.android.camera.action.REVIEW" -> {
(intent.data ?: (intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri))?.let { uri ->
return hashMapOf(
"action" to "view",
"uri" to uri.toString(),
"mimeType" to intent.type, // MIME type is optional
INTENT_DATA_KEY_ACTION to INTENT_ACTION_VIEW,
INTENT_DATA_KEY_MIME_TYPE to intent.type, // MIME type is optional
INTENT_DATA_KEY_URI to uri.toString(),
)
}
}
Intent.ACTION_GET_CONTENT, Intent.ACTION_PICK -> {
return hashMapOf(
"action" to "pick",
"mimeType" to intent.type,
INTENT_DATA_KEY_ACTION to INTENT_ACTION_PICK,
INTENT_DATA_KEY_MIME_TYPE to intent.type,
)
}
Intent.ACTION_SEARCH -> {
val viewUri = intent.dataString
return if (viewUri != null) hashMapOf(
"action" to "view",
"uri" to viewUri,
"mimeType" to intent.getStringExtra(SearchManager.EXTRA_DATA_KEY),
INTENT_DATA_KEY_ACTION to INTENT_ACTION_VIEW,
INTENT_DATA_KEY_MIME_TYPE to intent.getStringExtra(SearchManager.EXTRA_DATA_KEY),
INTENT_DATA_KEY_URI to viewUri,
) else hashMapOf(
"action" to "search",
"query" to intent.getStringExtra(SearchManager.QUERY),
INTENT_DATA_KEY_ACTION to INTENT_ACTION_SEARCH,
INTENT_DATA_KEY_QUERY to intent.getStringExtra(SearchManager.QUERY),
)
}
Intent.ACTION_RUN -> {
@ -261,7 +262,7 @@ class MainActivity : FlutterActivity() {
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_search else R.drawable.ic_shortcut_search))
.setIntent(
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
.putExtra("page", "/search")
.putExtra(SHORTCUT_KEY_PAGE, "/search")
)
.build()
@ -270,7 +271,7 @@ class MainActivity : FlutterActivity() {
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_movie else R.drawable.ic_shortcut_movie))
.setIntent(
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
.putExtra("page", "/collection")
.putExtra(SHORTCUT_KEY_PAGE, "/collection")
.putExtra("filters", arrayOf("{\"type\":\"mime\",\"mime\":\"video/*\"}"))
)
.build()
@ -290,9 +291,23 @@ class MainActivity : FlutterActivity() {
const val OPEN_FROM_ANALYSIS_SERVICE = 2
const val CREATE_FILE_REQUEST = 3
const val OPEN_FILE_REQUEST = 4
const val SELECT_DIRECTORY_REQUEST = 5
const val DELETE_SINGLE_PERMISSION_REQUEST = 6
const val MEDIA_WRITE_BULK_PERMISSION_REQUEST = 7
const val DELETE_SINGLE_PERMISSION_REQUEST = 5
const val MEDIA_WRITE_BULK_PERMISSION_REQUEST = 6
const val INTENT_DATA_KEY_ACTION = "action"
const val INTENT_DATA_KEY_FILTERS = "filters"
const val INTENT_DATA_KEY_MIME_TYPE = "mimeType"
const val INTENT_DATA_KEY_PAGE = "page"
const val INTENT_DATA_KEY_URI = "uri"
const val INTENT_DATA_KEY_QUERY = "query"
const val INTENT_ACTION_PICK = "pick"
const val INTENT_ACTION_SEARCH = "search"
const val INTENT_ACTION_VIEW = "view"
const val SHORTCUT_KEY_PAGE = "page"
const val SHORTCUT_KEY_FILTERS_ARRAY = "filters"
const val SHORTCUT_KEY_FILTERS_STRING = "filtersString"
// request code to pending runnable
val pendingStorageAccessResultHandlers = ConcurrentHashMap<Int, PendingStorageAccessResultHandler>()

View file

@ -24,10 +24,12 @@ class AccessibilityHandler(private val activity: Activity) : MethodCallHandler {
private fun areAnimationsRemoved(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
var removed = false
try {
removed = Settings.Global.getFloat(activity.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE) == 0f
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get settings", e)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
try {
removed = Settings.Global.getFloat(activity.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE) == 0f
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null)
}
}
result.success(removed)
}

View file

@ -52,7 +52,7 @@ class AnalysisHandler(private val activity: Activity, private val onAnalysisComp
}
// can be null or empty
val contentIds = call.argument<List<Int>>("contentIds");
val contentIds = call.argument<List<Int>>("contentIds")
if (!activity.isMyServiceRunning(AnalysisService::class.java)) {
val intent = Intent(activity, AnalysisService::class.java)

View file

@ -18,6 +18,10 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.MainActivity
import deckers.thibault.aves.MainActivity.Companion.EXTRA_STRING_ARRAY_SEPARATOR
import deckers.thibault.aves.MainActivity.Companion.SHORTCUT_KEY_FILTERS_ARRAY
import deckers.thibault.aves.MainActivity.Companion.SHORTCUT_KEY_FILTERS_STRING
import deckers.thibault.aves.MainActivity.Companion.SHORTCUT_KEY_PAGE
import deckers.thibault.aves.R
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
@ -47,8 +51,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
"openMap" -> safe(call, result, ::openMap)
"setAs" -> safe(call, result, ::setAs)
"share" -> safe(call, result, ::share)
"canPin" -> safe(call, result, ::canPin)
"pin" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::pin) }
"pinShortcut" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::pinShortcut) }
else -> result.notImplemented()
}
}
@ -59,7 +62,14 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
fun addPackageDetails(intent: Intent) {
// apps tend to use their name in English when creating directories
// so we get their names in English as well as the current locale
val englishConfig = Configuration().apply { setLocale(Locale.ENGLISH) }
val englishConfig = Configuration().apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
setLocale(Locale.ENGLISH)
} else {
@Suppress("deprecation")
locale = Locale.ENGLISH
}
}
val pm = context.packageManager
for (resolveInfo in pm.queryIntentActivities(intent, 0)) {
@ -319,13 +329,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
// shortcuts
private fun isPinSupported() = ShortcutManagerCompat.isRequestPinShortcutSupported(context)
private fun canPin(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(isPinSupported())
}
private fun pin(call: MethodCall, result: MethodChannel.Result) {
private fun pinShortcut(call: MethodCall, result: MethodChannel.Result) {
val label = call.argument<String>("label")
val iconBytes = call.argument<ByteArray>("iconBytes")
val filters = call.argument<List<String>>("filters")
@ -335,7 +339,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
return
}
if (!isPinSupported()) {
if (!ShortcutManagerCompat.isRequestPinShortcutSupported(context)) {
result.error("pin-unsupported", "failed because the launcher does not support pinning shortcuts", null)
return
}
@ -360,11 +364,11 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val intent = when {
uri != null -> Intent(Intent.ACTION_VIEW, uri, context, MainActivity::class.java)
filters != null -> Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
.putExtra("page", "/collection")
.putExtra("filters", filters.toTypedArray())
.putExtra(SHORTCUT_KEY_PAGE, "/collection")
.putExtra(SHORTCUT_KEY_FILTERS_ARRAY, filters.toTypedArray())
// on API 25, `String[]` or `ArrayList` extras are null when using the shortcut
// so we use a joined `String` as fallback
.putExtra("filtersString", filters.joinToString(MainActivity.EXTRA_STRING_ARRAY_SEPARATOR))
.putExtra(SHORTCUT_KEY_FILTERS_STRING, filters.joinToString(EXTRA_STRING_ARRAY_SEPARATOR))
else -> {
result.error("pin-intent", "failed to build intent", null)
return

View file

@ -1,21 +1,42 @@
package deckers.thibault.aves.channel.calls
import android.content.Context
import android.os.Build
import androidx.core.content.pm.ShortcutManagerCompat
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import java.util.*
class DeviceHandler : MethodCallHandler {
class DeviceHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getCapabilities" -> safe(call, result, ::getCapabilities)
"getDefaultTimeZone" -> safe(call, result, ::getDefaultTimeZone)
"getPerformanceClass" -> safe(call, result, ::getPerformanceClass)
else -> result.notImplemented()
}
}
private fun getCapabilities(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
val sdkInt = Build.VERSION.SDK_INT
result.success(
hashMapOf(
"canGrantDirectoryAccess" to (sdkInt >= Build.VERSION_CODES.LOLLIPOP),
"canPinShortcut" to ShortcutManagerCompat.isRequestPinShortcutSupported(context),
"canPrint" to (sdkInt >= Build.VERSION_CODES.KITKAT),
"canRenderFlagEmojis" to (sdkInt >= Build.VERSION_CODES.LOLLIPOP),
// as of google_maps_flutter v2.1.1, minSDK is 20 because of default PlatformView usage,
// but using hybrid composition would make it usable on API 19 too,
// cf https://github.com/flutter/flutter/issues/23728
"canRenderGoogleMaps" to (sdkInt >= Build.VERSION_CODES.KITKAT_WATCH),
"hasFilePicker" to (sdkInt >= Build.VERSION_CODES.KITKAT),
"showPinShortcutFeedback" to (sdkInt >= Build.VERSION_CODES.O),
)
)
}
private fun getDefaultTimeZone(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(TimeZone.getDefault().id)
}

View file

@ -20,6 +20,8 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
"editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) }
"setIptc" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::setIptc) }
"setXmp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::setXmp) }
"removeTypes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::removeTypes) }
else -> result.notImplemented()
}
@ -97,6 +99,64 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
})
}
private fun setIptc(call: MethodCall, result: MethodChannel.Result) {
val iptc = call.argument<List<FieldMap>>("iptc")
val entryMap = call.argument<FieldMap>("entry")
val postEditScan = call.argument<Boolean>("postEditScan")
if (entryMap == null || postEditScan == null) {
result.error("setIptc-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("setIptc-args", "failed because entry fields are missing", null)
return
}
val provider = getProvider(uri)
if (provider == null) {
result.error("setIptc-provider", "failed to find provider for uri=$uri", null)
return
}
provider.setIptc(activity, path, uri, mimeType, postEditScan, iptc = iptc, callback = object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("setIptc-failure", "failed to set IPTC for mimeType=$mimeType uri=$uri", throwable.message)
})
}
private fun setXmp(call: MethodCall, result: MethodChannel.Result) {
val xmp = call.argument<String>("xmp")
val extendedXmp = call.argument<String>("extendedXmp")
val entryMap = call.argument<FieldMap>("entry")
if (entryMap == null) {
result.error("setXmp-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("setXmp-args", "failed because entry fields are missing", null)
return
}
val provider = getProvider(uri)
if (provider == null) {
result.error("setXmp-provider", "failed to find provider for uri=$uri", null)
return
}
provider.setXmp(activity, path, uri, mimeType, coreXmp = xmp, extendedXmp = extendedXmp, callback = object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("setXmp-failure", "failed to set XMP for mimeType=$mimeType uri=$uri", throwable.message)
})
}
private fun removeTypes(call: MethodCall, result: MethodChannel.Result) {
val types = call.argument<List<String>>("types")
val entryMap = call.argument<FieldMap>("entry")

View file

@ -10,6 +10,8 @@ import android.provider.MediaStore
import android.util.Log
import androidx.exifinterface.media.ExifInterface
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
@ -71,6 +73,7 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets
import java.text.ParseException
import java.util.*
@ -84,6 +87,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
"getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getOverlayMetadata) }
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) }
"getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPanoramaInfo) }
"getIptc" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getIptc) }
"getXmp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getXmp) }
"hasContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::hasContentResolverProp) }
"getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) }
else -> result.notImplemented()
@ -185,7 +190,15 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
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) StandardCharsets.UTF_8 else kv.value.charset
val charset = if (baseDirName == PNG_ITXT_DIR_NAME) {
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) {
@ -571,7 +584,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
var flags = (metadataMap[KEY_FLAGS] ?: 0) as Int
try {
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { metadataMap[KEY_ROTATION_DEGREES] = it }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { metadataMap[KEY_ROTATION_DEGREES] = it }
}
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { metadataMap[KEY_DATE_MILLIS] = it }
}
@ -621,16 +636,16 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
return
}
val saveExposureTime: (value: Rational) -> Unit = {
val saveExposureTime = fun(value: Rational) {
// `TAG_EXPOSURE_TIME` as a string is sometimes a ratio, sometimes a decimal
// so we explicitly request it as a rational (e.g. 1/100, 1/14, 71428571/1000000000, 4000/1000, 2000000000/500000000)
// and process it to make sure the numerator is `1` when the ratio value is less than 1
val num = it.numerator
val denom = it.denominator
val num = value.numerator
val denom = value.denominator
metadataMap[KEY_EXPOSURE_TIME] = when {
num >= denom -> "${it.toSimpleString(true)}"
num >= denom -> "${value.toSimpleString(true)}"
num != 1L && num != 0L -> Rational(1, (denom / num.toDouble()).roundToLong()).toString()
else -> it.toString()
else -> value.toString()
}
}
@ -734,6 +749,59 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
result.error("getPanoramaInfo-empty", "failed to read XMP for mimeType=$mimeType uri=$uri", null)
}
private fun getIptc(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (mimeType == null || uri == null) {
result.error("getIptc-args", "failed because of missing arguments", null)
return
}
if (MimeTypes.canReadWithPixyMeta(mimeType)) {
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
val iptcDataList = PixyMetaHelper.getIptc(input)
result.success(iptcDataList)
return
}
} catch (e: Exception) {
result.error("getIptc-exception", "failed to read IPTC for mimeType=$mimeType uri=$uri", e.message)
return
}
}
result.success(null)
}
private fun getXmp(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("getXmp-args", "failed because of missing arguments", null)
return
}
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
val xmpStrings = metadata.getDirectoriesOfType(XmpDirectory::class.java).mapNotNull { XMPMetaFactory.serializeToString(it.xmpMeta, xmpSerializeOptions) }
result.success(xmpStrings.toMutableList())
return
}
} catch (e: Exception) {
result.error("getXmp-exception", "failed to read XMP for mimeType=$mimeType uri=$uri", e.message)
return
} catch (e: NoClassDefFoundError) {
result.error("getXmp-error", "failed to read XMP for mimeType=$mimeType uri=$uri", e.message)
return
}
}
result.success(null)
}
private fun hasContentResolverProp(call: MethodCall, result: MethodChannel.Result) {
val prop = call.argument<String>("prop")
if (prop == null) {
@ -829,6 +897,11 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
"XMP",
)
private val xmpSerializeOptions = SerializeOptions().apply {
omitPacketWrapper = true // e.g. <?xpacket begin="..." id="W5M0MpCehiHzreSzNTczkc9d"?>...<?xpacket end="r"?>
omitXmpMetaElement = false // e.g. <x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core Test.SNAPSHOT">...</x:xmpmeta>
}
// catalog metadata
private const val KEY_MIME_TYPE = "mimeType"
private const val KEY_DATE_MILLIS = "dateMillis"

View file

@ -45,7 +45,7 @@ class WindowHandler(private val activity: Activity) : MethodCallHandler {
try {
locked = Settings.System.getInt(activity.contentResolver, Settings.System.ACCELEROMETER_ROTATION) == 0
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get settings", e)
Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null)
}
result.success(locked)
}

View file

@ -6,6 +6,7 @@ import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder
import android.graphics.Rect
import android.net.Uri
import android.os.Build
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy
@ -68,7 +69,12 @@ class RegionFetcher internal constructor(
if (currentDecoderRef == null) {
val newDecoder = StorageUtils.openInputStream(context, uri)?.use { input ->
@Suppress("BlockingMethodInNonBlockingContext")
BitmapRegionDecoder.newInstance(input, false)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
BitmapRegionDecoder.newInstance(input)
} else {
@Suppress("deprecation")
BitmapRegionDecoder.newInstance(input, false)
}
}
if (newDecoder == null) {
result.error("getRegion-read-null", "failed to open file for uri=$uri regionRect=$regionRect", null)

View file

@ -3,6 +3,7 @@ package deckers.thibault.aves.channel.streams
import android.content.Context
import android.database.ContentObserver
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.provider.Settings
@ -32,12 +33,13 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
override fun onChange(selfChange: Boolean, uri: Uri?) {
if (update()) {
success(
hashMapOf(
Settings.System.ACCELEROMETER_ROTATION to accelerometerRotation,
Settings.Global.TRANSITION_ANIMATION_SCALE to transitionAnimationScale,
)
val settings: FieldMap = hashMapOf(
Settings.System.ACCELEROMETER_ROTATION to accelerometerRotation,
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
settings[Settings.Global.TRANSITION_ANIMATION_SCALE] = transitionAnimationScale
}
success(settings)
}
}
@ -49,14 +51,15 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
accelerometerRotation = newAccelerometerRotation
changed = true
}
val newTransitionAnimationScale = Settings.Global.getFloat(context.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE)
if (transitionAnimationScale != newTransitionAnimationScale) {
transitionAnimationScale = newTransitionAnimationScale
changed = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
val newTransitionAnimationScale = Settings.Global.getFloat(context.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE)
if (transitionAnimationScale != newTransitionAnimationScale) {
transitionAnimationScale = newTransitionAnimationScale
changed = true
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get settings", e)
Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null)
}
return changed
}

View file

@ -11,7 +11,6 @@ import deckers.thibault.aves.MainActivity
import deckers.thibault.aves.PendingStorageAccessResultHandler
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.PermissionManager
import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.Dispatchers
@ -44,7 +43,6 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
"requestMediaFileAccess" -> GlobalScope.launch(Dispatchers.IO) { requestMediaFileAccess() }
"createFile" -> GlobalScope.launch(Dispatchers.IO) { createFile() }
"openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() }
"selectDirectory" -> GlobalScope.launch(Dispatchers.IO) { selectDirectory() }
else -> endOfStream()
}
}
@ -93,6 +91,12 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
}
private fun createFile() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
// TODO TLAD [<=API18] create file
error("createFile-sdk", "unsupported SDK version=${Build.VERSION.SDK_INT}", null)
return
}
val name = args["name"] as String?
val mimeType = args["mimeType"] as String?
val bytes = args["bytes"] as ByteArray?
@ -130,6 +134,12 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
private fun openFile() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
// TODO TLAD [<=API18] open file
error("openFile-sdk", "unsupported SDK version=${Build.VERSION.SDK_INT}", null)
return
}
val mimeType = args["mimeType"] as String?
if (mimeType == null) {
error("openFile-args", "failed because of missing arguments", null)
@ -158,24 +168,6 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
activity.startActivityForResult(intent, MainActivity.OPEN_FILE_REQUEST)
}
private fun selectDirectory() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
MainActivity.pendingStorageAccessResultHandlers[MainActivity.SELECT_DIRECTORY_REQUEST] = PendingStorageAccessResultHandler(null, { uri ->
success(StorageUtils.convertTreeUriToDirPath(activity, uri))
endOfStream()
}, {
success(null)
endOfStream()
})
activity.startActivityForResult(intent, MainActivity.SELECT_DIRECTORY_REQUEST)
} else {
success(null)
endOfStream()
}
}
override fun onCancel(arguments: Any?) {}
private fun success(result: Any?) {

View file

@ -62,7 +62,7 @@ internal class SvgFetcher(val model: SvgThumbnail, val width: Int, val height: I
val bitmapHeight: Int
if (width / height > svgWidth / svgHeight) {
bitmapWidth = ceil(svgWidth * height / svgHeight).toInt()
bitmapHeight = height;
bitmapHeight = height
} else {
bitmapWidth = width
bitmapHeight = ceil(svgHeight * width / svgWidth).toInt()

View file

@ -223,7 +223,9 @@ object ExifInterfaceHelper {
val dirs = DirType.values().map { Pair(it, it.createDirectory()) }.toMap()
// exclude Exif directory when it only includes image size
val isUselessExif: (Map<String, String>) -> Boolean = { it.size == 2 && it.containsKey("Image Height") && it.containsKey("Image Width") }
val isUselessExif = fun(it: Map<String, String>): Boolean {
return it.size == 2 && it.containsKey("Image Height") && it.containsKey("Image Width")
}
return HashMap<String, Map<String, String>>().apply {
put("Exif", describeDir(exif, dirs, baseTags).takeUnless(isUselessExif) ?: hashMapOf())

View file

@ -27,11 +27,13 @@ object MediaMetadataRetrieverHelper {
MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS to "Number of Tracks",
MediaMetadataRetriever.METADATA_KEY_TITLE to "Title",
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT to "Video Height",
MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION to "Video Rotation",
MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH to "Video Width",
MediaMetadataRetriever.METADATA_KEY_WRITER to "Writer",
MediaMetadataRetriever.METADATA_KEY_YEAR to "Year",
).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
put(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION, "Video Rotation")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
put(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE, "Capture Framerate")
}

View file

@ -32,12 +32,12 @@ object Metadata {
const val DIR_PNG_TEXTUAL_DATA = "PNG Textual Data" // custom
// types of metadata
const val TYPE_COMMENT = "comment"
const val TYPE_EXIF = "exif"
const val TYPE_ICC_PROFILE = "icc_profile"
const val TYPE_IPTC = "iptc"
const val TYPE_JFIF = "jfif"
const val TYPE_JPEG_ADOBE = "jpeg_adobe"
const val TYPE_JPEG_COMMENT = "jpeg_comment"
const val TYPE_JPEG_DUCKY = "jpeg_ducky"
const val TYPE_PHOTOSHOP_IRB = "photoshop_irb"
const val TYPE_XMP = "xmp"

View file

@ -54,9 +54,11 @@ object MultiPage {
// do not use `MediaFormat.KEY_TRACK_ID` as it is actually not unique between tracks
// e.g. there could be both a video track and an image track with KEY_TRACK_ID == 1
format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { track[KEY_IS_DEFAULT] = it != 0 }
format.getSafeInt(MediaFormat.KEY_WIDTH) { track[KEY_WIDTH] = it }
format.getSafeInt(MediaFormat.KEY_HEIGHT) { track[KEY_HEIGHT] = it }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { track[KEY_IS_DEFAULT] = it != 0 }
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
format.getSafeInt(MediaFormat.KEY_ROTATION) { track[KEY_ROTATION_DEGREES] = it }
}

View file

@ -1,17 +1,21 @@
package deckers.thibault.aves.metadata
import deckers.thibault.aves.metadata.Metadata.TYPE_COMMENT
import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF
import deckers.thibault.aves.metadata.Metadata.TYPE_ICC_PROFILE
import deckers.thibault.aves.metadata.Metadata.TYPE_IPTC
import deckers.thibault.aves.metadata.Metadata.TYPE_JFIF
import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_ADOBE
import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_COMMENT
import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_DUCKY
import deckers.thibault.aves.metadata.Metadata.TYPE_PHOTOSHOP_IRB
import deckers.thibault.aves.metadata.Metadata.TYPE_XMP
import deckers.thibault.aves.model.FieldMap
import pixy.meta.meta.Metadata
import pixy.meta.meta.MetadataEntry
import pixy.meta.meta.MetadataType
import pixy.meta.meta.iptc.IPTC
import pixy.meta.meta.iptc.IPTCDataSet
import pixy.meta.meta.iptc.IPTCRecord
import pixy.meta.meta.jpeg.JPGMeta
import pixy.meta.meta.xmp.XMP
import pixy.meta.string.XMLUtils
@ -50,9 +54,46 @@ object PixyMetaHelper {
return metadataMap
}
fun getIptc(input: InputStream): List<FieldMap>? {
val iptc = Metadata.readMetadata(input)[MetadataType.IPTC] as IPTC? ?: return null
val iptcDataList = ArrayList<FieldMap>()
iptc.dataSets.forEach { dataSetEntry ->
val tag = dataSetEntry.key
val dataSets = dataSetEntry.value
iptcDataList.add(
hashMapOf(
"record" to tag.recordNumber,
"tag" to tag.tag,
"values" to dataSets.map { it.data }.toMutableList(),
)
)
}
return iptcDataList
}
fun setIptc(
input: InputStream,
output: OutputStream,
iptcDataList: List<FieldMap>?,
) {
val iptc = iptcDataList?.flatMap {
val record = it["record"] as Int
val tag = it["tag"] as Int
val values = it["values"] as List<*>
values.map { data -> IPTCDataSet(IPTCRecord.fromRecordNumber(record), tag, data as ByteArray) }
} ?: ArrayList<IPTCDataSet>()
Metadata.insertIPTC(input, output, iptc)
}
fun getXmp(input: InputStream): XMP? = Metadata.readMetadata(input)[MetadataType.XMP] as XMP?
fun setXmp(input: InputStream, output: OutputStream, xmpString: String, extendedXmpString: String?) {
fun setXmp(
input: InputStream,
output: OutputStream,
xmpString: String?,
extendedXmpString: String?
) {
if (extendedXmpString != null) {
JPGMeta.insertXMP(input, output, xmpString, extendedXmpString)
} else {
@ -70,12 +111,12 @@ object PixyMetaHelper {
}
private fun toMetadataType(typeString: String): MetadataType? = when (typeString) {
TYPE_COMMENT -> MetadataType.COMMENT
TYPE_EXIF -> MetadataType.EXIF
TYPE_ICC_PROFILE -> MetadataType.ICC_PROFILE
TYPE_IPTC -> MetadataType.IPTC
TYPE_JFIF -> MetadataType.JPG_JFIF
TYPE_JPEG_ADOBE -> MetadataType.JPG_ADOBE
TYPE_JPEG_COMMENT -> MetadataType.COMMENT
TYPE_JPEG_DUCKY -> MetadataType.JPG_DUCKY
TYPE_PHOTOSHOP_IRB -> MetadataType.PHOTOSHOP_IRB
TYPE_XMP -> MetadataType.XMP

View file

@ -23,7 +23,7 @@ object XMP {
private const val GAUDIO_SCHEMA_NS = "http://ns.google.com/photos/1.0/audio/"
const val GCAMERA_SCHEMA_NS = "http://ns.google.com/photos/1.0/camera/"
private const val GDEPTH_SCHEMA_NS = "http://ns.google.com/photos/1.0/depthmap/"
const val GIMAGE_SCHEMA_NS = "http://ns.google.com/photos/1.0/image/"
private const val GIMAGE_SCHEMA_NS = "http://ns.google.com/photos/1.0/image/"
const val CONTAINER_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/"
private const val CONTAINER_ITEM_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/item/"

View file

@ -5,6 +5,7 @@ import android.content.Context
import android.graphics.BitmapFactory
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
@ -135,10 +136,12 @@ class SourceEntry {
try {
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) { width = it }
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) { height = it }
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { sourceRotationDegrees = it }
retriever.getSafeLong(MediaMetadataRetriever.METADATA_KEY_DURATION) { durationMillis = it }
retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { sourceDateTakenMillis = it }
retriever.getSafeString(MediaMetadataRetriever.METADATA_KEY_TITLE) { title = it }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { sourceRotationDegrees = it }
}
} catch (e: Exception) {
// ignore
} finally {

View file

@ -27,6 +27,7 @@ import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.NameConflictStrategy
import deckers.thibault.aves.utils.*
import deckers.thibault.aves.utils.MimeTypes.canEditExif
import deckers.thibault.aves.utils.MimeTypes.canEditIptc
import deckers.thibault.aves.utils.MimeTypes.canEditXmp
import deckers.thibault.aves.utils.MimeTypes.canRemoveMetadata
import deckers.thibault.aves.utils.MimeTypes.extensionFor
@ -460,6 +461,94 @@ abstract class ImageProvider {
return true
}
private fun editIptc(
context: Context,
path: String,
uri: Uri,
mimeType: String,
callback: ImageOpCallback,
trailerDiff: Int = 0,
iptc: List<FieldMap>?,
): Boolean {
if (!canEditIptc(mimeType)) {
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
return false
}
val originalFileSize = File(path).length()
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
var videoBytes: ByteArray? = null
val editableFile = File.createTempFile("aves", null).apply {
deleteOnExit()
try {
outputStream().use { output ->
if (videoSize != null) {
// handle motion photo and embedded video separately
val imageSize = (originalFileSize - videoSize).toInt()
videoBytes = ByteArray(videoSize)
StorageUtils.openInputStream(context, uri)?.let { input ->
val imageBytes = ByteArray(imageSize)
input.read(imageBytes, 0, imageSize)
input.read(videoBytes, 0, videoSize)
// copy only the image to a temporary file for editing
// video will be appended after metadata modification
ByteArrayInputStream(imageBytes).use { imageInput ->
imageInput.copyTo(output)
}
}
} else {
// copy original file to a temporary file for editing
StorageUtils.openInputStream(context, uri)?.use { imageInput ->
imageInput.copyTo(output)
}
}
}
} catch (e: Exception) {
callback.onFailure(e)
return false
}
}
try {
editableFile.outputStream().use { output ->
// reopen input to read from start
StorageUtils.openInputStream(context, uri)?.use { input ->
when {
iptc != null ->
PixyMetaHelper.setIptc(input, output, iptc)
canRemoveMetadata(mimeType) ->
PixyMetaHelper.removeMetadata(input, output, setOf(Metadata.TYPE_IPTC))
else -> {
Log.w(LOG_TAG, "setting empty IPTC for mimeType=$mimeType")
PixyMetaHelper.setIptc(input, output, null)
}
}
}
}
if (videoBytes != null) {
// append trailer video, if any
editableFile.appendBytes(videoBytes!!)
}
// 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)) {
return false
}
} catch (e: IOException) {
callback.onFailure(e)
return false
}
return true
}
// provide `editCoreXmp` to modify existing core XMP,
// or provide `coreXmp` and `extendedXmp` to set them
private fun editXmp(
context: Context,
path: String,
@ -467,7 +556,9 @@ abstract class ImageProvider {
mimeType: String,
callback: ImageOpCallback,
trailerDiff: Int = 0,
edit: (xmp: String) -> String,
coreXmp: String? = null,
extendedXmp: String? = null,
editCoreXmp: ((xmp: String) -> String)? = null,
): Boolean {
if (!canEditXmp(mimeType)) {
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
@ -479,18 +570,34 @@ abstract class ImageProvider {
val editableFile = File.createTempFile("aves", null).apply {
deleteOnExit()
try {
val xmp = StorageUtils.openInputStream(context, uri)?.use { input -> PixyMetaHelper.getXmp(input) }
if (xmp == null) {
callback.onFailure(Exception("failed to find XMP for path=$path, uri=$uri"))
return false
var editedXmpString = coreXmp
var editedExtendedXmp = extendedXmp
if (editCoreXmp != null) {
val pixyXmp = StorageUtils.openInputStream(context, uri)?.use { input -> PixyMetaHelper.getXmp(input) }
if (pixyXmp != null) {
editedXmpString = editCoreXmp(pixyXmp.xmpDocString())
if (pixyXmp.hasExtendedXmp()) {
editedExtendedXmp = pixyXmp.extendedXmpDocString()
}
}
}
outputStream().use { output ->
// reopen input to read from start
StorageUtils.openInputStream(context, uri)?.use { input ->
val editedXmpString = edit(xmp.xmpDocString())
val extendedXmpString = if (xmp.hasExtendedXmp()) xmp.extendedXmpDocString() else null
PixyMetaHelper.setXmp(input, output, editedXmpString, extendedXmpString)
if (editedXmpString != null) {
if (editedExtendedXmp != null && mimeType != MimeTypes.JPEG) {
Log.w(LOG_TAG, "extended XMP is not supported by mimeType=$mimeType")
PixyMetaHelper.setXmp(input, output, editedXmpString, null)
} else {
PixyMetaHelper.setXmp(input, output, editedXmpString, editedExtendedXmp)
}
} else if (canRemoveMetadata(mimeType)) {
PixyMetaHelper.removeMetadata(input, output, setOf(Metadata.TYPE_XMP))
} else {
Log.w(LOG_TAG, "setting empty XMP for mimeType=$mimeType")
PixyMetaHelper.setXmp(input, output, null, null)
}
}
}
} catch (e: Exception) {
@ -538,7 +645,7 @@ abstract class ImageProvider {
"We need to edit XMP to adjust trailer video offset by $diff bytes."
)
val newTrailerOffset = trailerOffset + diff
return editXmp(context, path, uri, mimeType, callback, trailerDiff = diff) { xmp ->
return editXmp(context, path, uri, mimeType, callback, trailerDiff = diff, editCoreXmp = { xmp ->
xmp.replace(
// GCamera motion photo
"${XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$trailerOffset\"",
@ -548,7 +655,7 @@ abstract class ImageProvider {
"${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}=\"$trailerOffset\"",
"${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}=\"$newTrailerOffset\"",
)
}
})
}
fun editOrientation(
@ -679,6 +786,65 @@ abstract class ImageProvider {
}
}
fun setIptc(
context: Context,
path: String,
uri: Uri,
mimeType: String,
postEditScan: Boolean,
callback: ImageOpCallback,
iptc: List<FieldMap>? = null,
) {
val newFields = HashMap<String, Any?>()
val success = editIptc(
context = context,
path = path,
uri = uri,
mimeType = mimeType,
callback = callback,
iptc = iptc,
)
if (success) {
if (postEditScan) {
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
} else {
callback.onSuccess(HashMap())
}
} else {
callback.onFailure(Exception("failed to set IPTC"))
}
}
fun setXmp(
context: Context,
path: String,
uri: Uri,
mimeType: String,
callback: ImageOpCallback,
coreXmp: String? = null,
extendedXmp: String? = null,
) {
val newFields = HashMap<String, Any?>()
val success = editXmp(
context = context,
path = path,
uri = uri,
mimeType = mimeType,
callback = callback,
coreXmp = coreXmp,
extendedXmp = extendedXmp,
)
if (success) {
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
} else {
callback.onFailure(Exception("failed to set XMP"))
}
}
fun removeMetadataTypes(
context: Context,
path: String,

View file

@ -45,15 +45,15 @@ class MediaStoreImageProvider : ImageProvider() {
fetchFrom(context, isModified, handleNewEntry, VIDEO_CONTENT_URI, VIDEO_PROJECTION)
}
// the provided URI can point to the wrong media collection,
// e.g. a GIF image with the URI `content://media/external/video/media/[ID]`
// so the effective entry URI may not match the provided URI
override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) {
var found = false
val fetched = arrayListOf<FieldMap>()
val id = uri.tryParseId()
val onSuccess = fun(entry: FieldMap) {
entry["uri"] = uri.toString()
fetched.add(entry)
}
val alwaysValid = { _: Int, _: Int -> true }
val alwaysValid: NewEntryChecker = fun(_: Int, _: Int): Boolean = true
val onSuccess: NewEntryHandler = fun(entry: FieldMap) { fetched.add(entry) }
if (id != null) {
if (!found && (sourceMimeType == null || isImage(sourceMimeType))) {
val contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, id)
@ -83,7 +83,7 @@ class MediaStoreImageProvider : ImageProvider() {
}
fun checkObsoleteContentIds(context: Context, knownContentIds: List<Int>): List<Int> {
val foundContentIds = ArrayList<Int>()
val foundContentIds = HashSet<Int>()
fun check(context: Context, contentUri: Uri) {
val projection = arrayOf(MediaStore.MediaColumns._ID)
try {

View file

@ -23,7 +23,7 @@ object FlutterUtils {
}
lateinit var flutterLoader: FlutterLoader
FlutterUtils.runOnUiThread {
runOnUiThread {
// initialization must happen on the main thread
flutterLoader = FlutterInjector.instance().flutterLoader().apply {
startInitialization(context)

View file

@ -110,7 +110,16 @@ object MimeTypes {
}
// as of latest PixyMeta
fun canEditXmp(mimeType: String) = canReadWithPixyMeta(mimeType)
fun canEditIptc(mimeType: String) = when (mimeType) {
JPEG, TIFF -> true
else -> false
}
// as of latest PixyMeta
fun canEditXmp(mimeType: String) = when (mimeType) {
JPEG, TIFF, PNG, GIF -> true
else -> false
}
// as of latest PixyMeta
fun canRemoveMetadata(mimeType: String) = when (mimeType) {

View file

@ -142,39 +142,6 @@ object PermissionManager {
}
}
fun getRestrictedDirectories(context: Context): List<Map<String, String>> {
val dirs = ArrayList<Map<String, String>>()
val sdkInt = Build.VERSION.SDK_INT
if (sdkInt >= 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(
"volumePath" to it,
"relativeDir" to "",
)
})
dirs.addAll(volumePaths.map {
hashMapOf(
"volumePath" to it,
"relativeDir" to Environment.DIRECTORY_DOWNLOADS,
)
})
} else if (sdkInt == Build.VERSION_CODES.KITKAT || sdkInt == Build.VERSION_CODES.KITKAT_WATCH) {
// no SD card volume access on KitKat
val primaryVolume = StorageUtils.getPrimaryVolumePath(context)
val nonPrimaryVolumes = StorageUtils.getVolumePaths(context).filter { it != primaryVolume }
dirs.addAll(nonPrimaryVolumes.map {
hashMapOf(
"volumePath" to it,
"relativeDir" to "",
)
})
}
return dirs
}
fun canInsertByMediaStore(directories: List<FieldMap>): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
directories.all {
@ -195,12 +162,14 @@ object PermissionManager {
} ?: false
}
// returns paths matching URIs granted by the user
// returns paths matching directory URIs granted by the user
fun getGrantedDirs(context: Context): Set<String> {
val grantedDirs = HashSet<String>()
for (uriPermission in context.contentResolver.persistedUriPermissions) {
val dirPath = StorageUtils.convertTreeUriToDirPath(context, uriPermission.uri)
dirPath?.let { grantedDirs.add(it) }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
for (uriPermission in context.contentResolver.persistedUriPermissions) {
val dirPath = StorageUtils.convertTreeUriToDirPath(context, uriPermission.uri)
dirPath?.let { grantedDirs.add(it) }
}
}
return grantedDirs
}
@ -208,13 +177,53 @@ object PermissionManager {
// returns paths accessible to the app (granted by the user or by default)
private fun getAccessibleDirs(context: Context): Set<String> {
val accessibleDirs = HashSet(getGrantedDirs(context))
// from Android R, we no longer have access permission by default on primary volume
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
// until API 18 / Android 4.3 / Jelly Bean MR2, removable storage is accessible by default like primary storage
// from API 19 / Android 4.4 / KitKat, removable storage requires access permission, at the file level
// from API 21 / Android 5.0 / Lollipop, removable storage requires access permission, but directory access grant is possible
// from API 30 / Android 11 / R, any storage requires access permission
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.JELLY_BEAN_MR2) {
accessibleDirs.addAll(StorageUtils.getVolumePaths(context))
} else if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
accessibleDirs.add(StorageUtils.getPrimaryVolumePath(context))
}
return accessibleDirs
}
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(
"volumePath" to it,
"relativeDir" to "",
)
})
dirs.addAll(volumePaths.map {
hashMapOf(
"volumePath" to it,
"relativeDir" to Environment.DIRECTORY_DOWNLOADS,
)
})
} else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT
|| Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT_WATCH) {
// removable storage requires access permission, at the file level
// without directory access, we consider the whole volume restricted
val primaryVolume = StorageUtils.getPrimaryVolumePath(context)
val nonPrimaryVolumes = StorageUtils.getVolumePaths(context).filter { it != primaryVolume }
dirs.addAll(nonPrimaryVolumes.map {
hashMapOf(
"volumePath" to it,
"relativeDir" to "",
)
})
}
return dirs
}
// As of Android R, `MediaStore.getDocumentUri` fails if any of the persisted
// URI permissions we hold points to a folder that no longer exists,
// so we should remove these obsolete URIs before proceeding.
@ -234,6 +243,7 @@ object PermissionManager {
}
}
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
private fun releaseUriPermission(context: Context, it: Uri) {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.releasePersistableUriPermission(it, flags)

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="search_shortcut_short_label">Recherche</string>
<string name="videos_shortcut_short_label">Vidéos</string>
<string name="analysis_channel_name">Analyse des images</string>
<string name="analysis_service_description">Analyse des images &amp; vidéos</string>
<string name="analysis_notification_default_title">Analyse des images</string>
<string name="analysis_notification_action_stop">Annuler</string>
</resources>

View file

@ -1,6 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '1.5.31'
ext.kotlin_version = '1.6.0'
repositories {
google()
mavenCentral()

View file

@ -2,4 +2,4 @@
<b>Navigation und Suche</b> ist ein wichtiger Bestandteil von <i>Aves</i>. Das Ziel besteht darin, dass Benutzer problemlos von Alben zu Fotos zu Tags zu Karten usw. wechseln können.
<i>Aves</i> lässt sich mit Android (von <b>API 20 bis 31</b>, d. h. von Lollipop bis S) mit Funktionen wie <b>App-Verknüpfungen</b> und <b>globaler Suche</b> integrieren. Es funktioniert auch als <b>Medienbetrachter und -auswahl</b>.
<i>Aves</i> lässt sich mit Android (von <b>API 19 bis 31</b>, d. h. von KitKat bis S) mit Funktionen wie <b>App-Verknüpfungen</b> und <b>globaler Suche</b> integrieren. Es funktioniert auch als <b>Medienbetrachter und -auswahl</b>.

View file

@ -1,10 +0,0 @@
In v1.5.6:
- fixed video playback ignoring hardware-accelerated codecs on recent devices
- partially fixed deleted files leaving ghost items on some devices
- you can now create shortcuts to a specific media item, not only collections
In v1.5.5:
- modify items in bulk (rotation, date, metadata removal)
- filter items by title
- enjoy the app in Russian
Note: the video thumbnails are modified. Clearing the app cache may be necessary.
Full changelog available on GitHub

View file

@ -2,4 +2,4 @@
<b>Navigation and search</b> is an important part of <i>Aves</i>. The goal is for users to easily flow from albums to photos to tags to maps, etc.
<i>Aves</i> integrates with Android (from <b>API 20 to 31</b>, i.e. from Lollipop to S) with features such as <b>app shortcuts</b> and <b>global search</b> handling. It also works as a <b>media viewer and picker</b>.
<i>Aves</i> integrates with Android (from <b>API 19 to 31</b>, i.e. from KitKat to S) with features such as <b>app shortcuts</b> and <b>global search</b> handling. It also works as a <b>media viewer and picker</b>.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -53,6 +53,8 @@
"@hideTooltip": {},
"removeTooltip": "Remove",
"@removeTooltip": {},
"resetButtonTooltip": "Reset",
"@resetButtonTooltip": {},
"doubleBackExitMessage": "Tap “back” again to exit.",
"@doubleBackExitMessage": {},
@ -145,6 +147,8 @@
"entryInfoActionEditDate": "Edit date & time",
"@entryInfoActionEditDate": {},
"entryInfoActionEditTags": "Edit tags",
"@entryInfoActionEditTags": {},
"entryInfoActionRemoveMetadata": "Remove metadata",
"@entryInfoActionRemoveMetadata": {},
@ -417,7 +421,7 @@
"removeEntryMetadataDialogMore": "More",
"@removeEntryMetadataDialogMore": {},
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP is required to play the video inside a motion photo. Are you sure you want to remove it?",
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP is required to play the video inside a motion photo.\n\nAre you sure you want to remove it?",
"@removeEntryMetadataMotionPhotoXmpWarningDialogMessage": {},
"videoSpeedDialogLabel": "Playback speed",
@ -501,6 +505,17 @@
"@aboutCreditsWorldAtlas2": {},
"aboutCreditsTranslators": "Translators:",
"@aboutCreditsTranslators": {},
"aboutCreditsTranslatorLine": "{language}: {names}",
"@aboutCreditsTranslatorLine": {
"placeholders": {
"language": {
"type": "String"
},
"names": {
"type": "String"
}
}
},
"aboutLicenses": "Open-Source Licenses",
"@aboutLicenses": {},
@ -646,6 +661,8 @@
"@drawerCollectionImages": {},
"drawerCollectionVideos": "Videos",
"@drawerCollectionVideos": {},
"drawerCollectionAnimated": "Animated",
"@drawerCollectionAnimated": {},
"drawerCollectionMotionPhotos": "Motion photos",
"@drawerCollectionMotionPhotos": {},
"drawerCollectionPanoramas": "Panoramas",
@ -791,20 +808,10 @@
"settingsSectionViewer": "Viewer",
"@settingsSectionViewer": {},
"settingsViewerShowOverlayOnOpening": "Show overlay on opening",
"@settingsViewerShowOverlayOnOpening": {},
"settingsViewerShowMinimap": "Show minimap",
"@settingsViewerShowMinimap": {},
"settingsViewerShowInformation": "Show information",
"@settingsViewerShowInformation": {},
"settingsViewerShowInformationSubtitle": "Show title, date, location, etc.",
"@settingsViewerShowInformationSubtitle": {},
"settingsViewerShowShootingDetails": "Show shooting details",
"@settingsViewerShowShootingDetails": {},
"settingsViewerEnableOverlayBlurEffect": "Overlay blur effect",
"@settingsViewerEnableOverlayBlurEffect": {},
"settingsViewerUseCutout": "Use cutout area",
"@settingsViewerUseCutout": {},
"settingsViewerMaximumBrightness": "Maximum brightness",
"@settingsViewerMaximumBrightness": {},
"settingsImageBackground": "Image background",
"@settingsImageBackground": {},
@ -821,6 +828,23 @@
"settingsViewerQuickActionEmpty": "No buttons",
"@settingsViewerQuickActionEmpty": {},
"settingsViewerOverlayTile": "Overlay",
"@settingsViewerOverlayTile": {},
"settingsViewerOverlayTitle": "Overlay",
"@settingsViewerOverlayTitle": {},
"settingsViewerShowOverlayOnOpening": "Show on opening",
"@settingsViewerShowOverlayOnOpening": {},
"settingsViewerShowMinimap": "Show minimap",
"@settingsViewerShowMinimap": {},
"settingsViewerShowInformation": "Show information",
"@settingsViewerShowInformation": {},
"settingsViewerShowInformationSubtitle": "Show title, date, location, etc.",
"@settingsViewerShowInformationSubtitle": {},
"settingsViewerShowShootingDetails": "Show shooting details",
"@settingsViewerShowShootingDetails": {},
"settingsViewerEnableOverlayBlurEffect": "Blur effect",
"@settingsViewerEnableOverlayBlurEffect": {},
"settingsVideoPageTitle": "Video Settings",
"@settingsVideoPageTitle": {},
"settingsSectionVideo": "Video",
@ -880,8 +904,11 @@
"settingsSaveSearchHistory": "Save search history",
"@settingsSaveSearchHistory": {},
"settingsHiddenFiltersTile": "Hidden filters",
"@settingsHiddenFiltersTile": {},
"settingsHiddenItemsTile": "Hidden items",
"@settingsHiddenItemsTile": {},
"settingsHiddenItemsTitle": "Hidden Items",
"@settingsHiddenItemsTitle": {},
"settingsHiddenFiltersTitle": "Hidden Filters",
"@settingsHiddenFiltersTitle": {},
"settingsHiddenFiltersBanner": "Photos and videos matching hidden filters will not appear in your collection.",
@ -889,14 +916,10 @@
"settingsHiddenFiltersEmpty": "No hidden filters",
"@settingsHiddenFiltersEmpty": {},
"settingsHiddenPathsTile": "Hidden paths",
"@settingsHiddenPathsTile": {},
"settingsHiddenPathsTitle": "Hidden Paths",
"@settingsHiddenPathsTitle": {},
"settingsHiddenPathsBanner": "Photos and videos in these folders, or any of their subfolders, will not appear in your collection.",
"@settingsHiddenPathsBanner": {},
"settingsHiddenPathsEmpty": "No hidden paths",
"@settingsHiddenPathsEmpty": {},
"addPathTooltip": "Add path",
"@addPathTooltip": {},
@ -1026,11 +1049,31 @@
"viewerInfoSearchSuggestionRights": "Rights",
"@viewerInfoSearchSuggestionRights": {},
"tagEditorPageTitle": "Edit Tags",
"@tagEditorPageTitle": {},
"tagEditorPageNewTagFieldLabel": "New tag",
"@tagEditorPageNewTagFieldLabel": {},
"tagEditorPageAddTagTooltip": "Add tag",
"@tagEditorPageAddTagTooltip": {},
"tagEditorSectionRecent": "Recent",
"@tagEditorSectionRecent": {},
"panoramaEnableSensorControl": "Enable sensor control",
"@panoramaEnableSensorControl": {},
"panoramaDisableSensorControl": "Disable sensor control",
"@panoramaDisableSensorControl": {},
"sourceViewerPageTitle": "Source",
"@sourceViewerPageTitle": {}
"@sourceViewerPageTitle": {},
"filePickerShowHiddenFiles": "Show hidden files",
"@filePickerShowHiddenFiles": {},
"filePickerDoNotShowHiddenFiles": "Dont show hidden files",
"@filePickerDoNotShowHiddenFiles": {},
"filePickerOpenFrom": "Open from",
"@filePickerOpenFrom": {},
"filePickerNoItems": "No items",
"@filePickerNoItems": {},
"filePickerUseThisFolder": "Use this folder",
"@filePickerUseThisFolder": {}
}

523
lib/l10n/app_fr.arb Normal file
View file

@ -0,0 +1,523 @@
{
"appName": "Aves",
"welcomeMessage": "Bienvenue",
"welcomeOptional": "Option",
"welcomeTermsToggle": "Jaccepte les conditions dutilisation",
"itemCount": "{count, plural, =1{1 élément} other{{count} éléments}}",
"timeSeconds": "{seconds, plural, =1{1 seconde} other{{seconds} secondes}}",
"timeMinutes": "{minutes, plural, =1{1 minute} other{{minutes} minutes}}",
"applyButtonLabel": "ENREGISTRER",
"deleteButtonLabel": "SUPPRIMER",
"nextButtonLabel": "SUIVANT",
"showButtonLabel": "AFFICHER",
"hideButtonLabel": "MASQUER",
"continueButtonLabel": "CONTINUER",
"changeTooltip": "Modifier",
"clearTooltip": "Effacer",
"previousTooltip": "Précédent",
"nextTooltip": "Suivant",
"showTooltip": "Afficher",
"hideTooltip": "Masquer",
"removeTooltip": "Supprimer",
"resetButtonTooltip": "Réinitialiser",
"doubleBackExitMessage": "Pressez «\u00A0retour\u00A0» à nouveau pour quitter.",
"sourceStateLoading": "Chargement",
"sourceStateCataloguing": "Classification",
"sourceStateLocatingCountries": "Identification des pays",
"sourceStateLocatingPlaces": "Identification des lieux",
"chipActionDelete": "Supprimer",
"chipActionGoToAlbumPage": "Afficher dans Albums",
"chipActionGoToCountryPage": "Afficher dans Pays",
"chipActionGoToTagPage": "Afficher dans Libellés",
"chipActionHide": "Masquer",
"chipActionPin": "Épingler",
"chipActionUnpin": "Retirer",
"chipActionRename": "Renommer",
"chipActionSetCover": "Modifier la couverture",
"chipActionCreateAlbum": "Créer un album",
"entryActionCopyToClipboard": "Copier dans presse-papier",
"entryActionDelete": "Supprimer",
"entryActionExport": "Exporter",
"entryActionInfo": "Détails",
"entryActionRename": "Renommer",
"entryActionRotateCCW": "Pivoter à gauche",
"entryActionRotateCW": "Pivoter à droite",
"entryActionFlip": "Retourner horizontalement",
"entryActionPrint": "Imprimer",
"entryActionShare": "Partager",
"entryActionViewSource": "Voir le code",
"entryActionViewMotionPhotoVideo": "Ouvrir le clip vidéo",
"entryActionEdit": "Modifier avec…",
"entryActionOpen": "Ouvrir avec…",
"entryActionSetAs": "Utiliser comme…",
"entryActionOpenMap": "Localiser avec…",
"entryActionRotateScreen": "Pivoter lécran",
"entryActionAddFavourite": "Ajouter aux favoris",
"entryActionRemoveFavourite": "Retirer des favoris",
"videoActionCaptureFrame": "Capturer limage",
"videoActionPause": "Pause",
"videoActionPlay": "Lire",
"videoActionReplay10": "Reculer de 10 secondes",
"videoActionSkip10": "Avancer de 10 secondes",
"videoActionSelectStreams": "Choisir les pistes",
"videoActionSetSpeed": "Vitesse de lecture",
"videoActionSettings": "Préférences",
"entryInfoActionEditDate": "Modifier la date",
"entryInfoActionEditTags": "Modifier les libellés",
"entryInfoActionRemoveMetadata": "Retirer les métadonnées",
"filterFavouriteLabel": "Favori",
"filterLocationEmptyLabel": "Sans lieu",
"filterTagEmptyLabel": "Sans libellé",
"filterTypeAnimatedLabel": "Animation",
"filterTypeMotionPhotoLabel": "Photo animée",
"filterTypePanoramaLabel": "Panorama",
"filterTypeRawLabel": "Raw",
"filterTypeSphericalVideoLabel": "Vidéo à 360°",
"filterTypeGeotiffLabel": "GeoTIFF",
"filterMimeImageLabel": "Image",
"filterMimeVideoLabel": "Vidéo",
"coordinateFormatDms": "DMS",
"coordinateFormatDecimal": "Degrés décimaux",
"coordinateDms": "{coordinate} {direction}",
"coordinateDmsNorth": "N",
"coordinateDmsSouth": "S",
"coordinateDmsEast": "E",
"coordinateDmsWest": "O",
"unitSystemMetric": "SI",
"unitSystemImperial": "anglo-saxonnes",
"videoLoopModeNever": "Jamais",
"videoLoopModeShortOnly": "Courtes vidéos seulement",
"videoLoopModeAlways": "Toujours",
"mapStyleGoogleNormal": "Google Maps",
"mapStyleGoogleHybrid": "Google Maps (Satellite)",
"mapStyleGoogleTerrain": "Google Maps (Relief)",
"mapStyleOsmHot": "OSM Humanitaire",
"mapStyleStamenToner": "Stamen Toner",
"mapStyleStamenWatercolor": "Stamen Watercolor",
"nameConflictStrategyRename": "Renommer",
"nameConflictStrategyReplace": "Remplacer",
"nameConflictStrategySkip": "Ignorer",
"keepScreenOnNever": "Jamais",
"keepScreenOnViewerOnly": "Visionneuse seulement",
"keepScreenOnAlways": "Toujours",
"accessibilityAnimationsRemove": "Empêchez certains effets de lécran",
"accessibilityAnimationsKeep": "Conserver les effets de lécran",
"albumTierNew": "Nouveaux",
"albumTierPinned": "Épinglés",
"albumTierSpecial": "Standards",
"albumTierApps": "Apps",
"albumTierRegular": "Autres",
"storageVolumeDescriptionFallbackPrimary": "Stockage interne",
"storageVolumeDescriptionFallbackNonPrimary": "Carte SD",
"rootDirectoryDescription": "dossier racine",
"otherDirectoryDescription": "dossier «\u00A0{name}\u00A0»",
"storageAccessDialogTitle": "Accès au dossier",
"storageAccessDialogMessage": "Veuillez sélectionner le {directory} de «\u00A0{volume}\u00A0» à lécran suivant, pour que lapp puisse modifier son contenu.",
"restrictedAccessDialogTitle": "Accès restreint",
"restrictedAccessDialogMessage": "Cette app ne peut pas modifier les fichiers du {directory} de «\u00A0{volume}\u00A0».\n\nVeuillez utiliser une app pré-installée pour déplacer les fichiers vers un autre dossier.",
"notEnoughSpaceDialogTitle": "Espace insuffisant",
"notEnoughSpaceDialogMessage": "Cette opération nécessite {neededSize} despace disponible sur «\u00A0{volume}\u00A0», mais il ne reste que {freeSize}.",
"unsupportedTypeDialogTitle": "Formats non supportés",
"unsupportedTypeDialogMessage": "{count, plural, =1{Cette opération nest pas disponible pour les fichiers au format suivant : {types}.} other{Cette opération nest pas disponible pour les fichiers aux formats suivants : {types}.}}",
"nameConflictDialogSingleSourceMessage": "Certains fichiers dans le dossier de destination ont le même nom.",
"nameConflictDialogMultipleSourceMessage": "Certains fichiers ont le même nom.",
"addShortcutDialogLabel": "Nom du raccourci",
"addShortcutButtonLabel": "AJOUTER",
"noMatchingAppDialogTitle": "App indisponible",
"noMatchingAppDialogMessage": "Aucune app ne peut effectuer cette opération.",
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Voulez-vous vraiment supprimer cet élément ?} other{Voulez-vous vraiment supprimer ces {count} éléments ?}}",
"videoResumeDialogMessage": "Voulez-vous reprendre la lecture à {time} ?",
"videoStartOverButtonLabel": "RECOMMENCER",
"videoResumeButtonLabel": "REPRENDRE",
"setCoverDialogTitle": "Modifier la couverture",
"setCoverDialogLatest": "dernier élément",
"setCoverDialogCustom": "personnalisé",
"hideFilterConfirmationDialogMessage": "Les images et vidéos correspondantes napparaîtront plus dans votre collection. Vous pouvez les montrer à nouveau via les réglages de «\u00A0Confidentialité\u00A0».\n\nVoulez-vous vraiment les masquer ?",
"newAlbumDialogTitle": "Nouvel Album",
"newAlbumDialogNameLabel": "Nom de lalbum",
"newAlbumDialogNameLabelAlreadyExistsHelper": "Le dossier existe déjà",
"newAlbumDialogStorageLabel": "Volume de stockage :",
"renameAlbumDialogLabel": "Nouveau nom",
"renameAlbumDialogLabelAlreadyExistsHelper": "Le dossier existe déjà",
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Voulez-vous vraiment supprimer cet album et son élément ?} other{Voulez-vous vraiment supprimer cet album et ses {count} éléments ?}}",
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Voulez-vous vraiment supprimer ces albums et leur élément ?} other{Voulez-vous vraiment supprimer ces albums et leurs {count} éléments ?}}",
"exportEntryDialogFormat": "Format :",
"renameEntryDialogLabel": "Nouveau nom",
"editEntryDateDialogTitle": "Date & Heure",
"editEntryDateDialogSet": "Régler",
"editEntryDateDialogShift": "Décaler",
"editEntryDateDialogExtractFromTitle": "Extraire du titre",
"editEntryDateDialogClear": "Effacer",
"editEntryDateDialogFieldSelection": "Champs affectés",
"editEntryDateDialogHours": "Heures",
"editEntryDateDialogMinutes": "Minutes",
"removeEntryMetadataDialogTitle": "Retrait de métadonnées",
"removeEntryMetadataDialogMore": "Voir plus",
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "Les métadonnées XMP sont nécessaires pour jouer la vidéo dune photo animée.\n\nVoulez-vous vraiment les supprimer ?",
"videoSpeedDialogLabel": "Vitesse de lecture",
"videoStreamSelectionDialogVideo": "Vidéo",
"videoStreamSelectionDialogAudio": "Audio",
"videoStreamSelectionDialogText": "Sous-titres",
"videoStreamSelectionDialogOff": "Désactivé",
"videoStreamSelectionDialogTrack": "Piste",
"videoStreamSelectionDialogNoSelection": "Il ny a pas dautre piste.",
"genericSuccessFeedback": "Succès !",
"genericFailureFeedback": "Échec",
"menuActionSort": "Trier",
"menuActionGroup": "Grouper",
"menuActionSelect": "Sélectionner",
"menuActionSelectAll": "Tout sélectionner",
"menuActionSelectNone": "Tout désélectionner",
"menuActionMap": "Carte",
"menuActionStats": "Statistiques",
"aboutPageTitle": "À propos",
"aboutLinkSources": "Sources",
"aboutLinkLicense": "Licence",
"aboutLinkPolicy": "Politique de confidentialité",
"aboutUpdate": "Nouvelle Version",
"aboutUpdateLinks1": "Une nouvelle version dAves est disponible sur",
"aboutUpdateLinks2": "et",
"aboutUpdateLinks3": ".",
"aboutUpdateGitHub": "GitHub",
"aboutUpdateGooglePlay": "Google Play",
"aboutBug": "Rapports derreur",
"aboutBugSaveLogInstruction": "Sauvegarder les logs de lapp vers un fichier",
"aboutBugSaveLogButton": "Sauvegarder",
"aboutBugCopyInfoInstruction": "Copier les informations denvironnement",
"aboutBugCopyInfoButton": "Copier",
"aboutBugReportInstruction": "Créer une «\u00A0issue\u00A0» sur GitHub en attachant les logs et informations denvironnement",
"aboutBugReportButton": "Créer",
"aboutCredits": "Remerciements",
"aboutCreditsWorldAtlas1": "Cette app utilise un fichier TopoJSON de ",
"aboutCreditsWorldAtlas2": "sous licence ISC.",
"aboutCreditsTranslators": "Traducteurs :",
"aboutCreditsTranslatorLine": "{language} : {names}",
"aboutLicenses": "Licences open-source",
"aboutLicensesBanner": "Cette app utilise ces librairies et packages open-source.",
"aboutLicensesAndroidLibraries": "Librairies Android",
"aboutLicensesFlutterPlugins": "Plugins Flutter",
"aboutLicensesFlutterPackages": "Packages Flutter",
"aboutLicensesDartPackages": "Packages Dart",
"aboutLicensesShowAllButtonLabel": "Afficher toutes les licences",
"policyPageTitle": "Politique de confidentialité",
"collectionPageTitle": "Collection",
"collectionPickPageTitle": "Sélection",
"collectionSelectionPageTitle": "{count, plural, =0{Sélection} =1{1 élément} other{{count} éléments}}",
"collectionActionShowTitleSearch": "Filtrer les titres",
"collectionActionHideTitleSearch": "Masquer le filtre",
"collectionActionAddShortcut": "Créer un raccourci",
"collectionActionCopy": "Copier vers lalbum",
"collectionActionMove": "Déplacer vers lalbum",
"collectionActionRescan": "Réanalyser",
"collectionActionEdit": "Modifier",
"collectionSearchTitlesHintText": "Recherche de titres",
"collectionSortTitle": "Trier",
"collectionSortDate": "par date",
"collectionSortSize": "par taille",
"collectionSortName": "alphabétiquement",
"collectionGroupTitle": "Grouper",
"collectionGroupAlbum": "par album",
"collectionGroupMonth": "par mois",
"collectionGroupDay": "par jour",
"collectionGroupNone": "ne pas grouper",
"sectionUnknown": "Inconnu",
"dateToday": "Aujourdhui",
"dateYesterday": "Hier",
"dateThisMonth": "Ce mois-ci",
"collectionDeleteFailureFeedback": "{count, plural, =1{Échec de la suppression d1 élément} other{Échec de la suppression de {count} éléments}}",
"collectionCopyFailureFeedback": "{count, plural, =1{Échec de la copie d1 élément} other{Échec de la copie de {count} éléments}}",
"collectionMoveFailureFeedback": "{count, plural, =1{Échec du déplacement d1 élément} other{Échec du déplacement de {count} éléments}}",
"collectionEditFailureFeedback": "{count, plural, =1{Échec de la modification d1 élément} other{Échec de la modification de {count} éléments}}",
"collectionExportFailureFeedback": "{count, plural, =1{Échec de lexport d1 page} other{Échec de lexport de {count} pages}}",
"collectionCopySuccessFeedback": "{count, plural, =1{1 élément copié} other{{count} éléments copiés}}",
"collectionMoveSuccessFeedback": "{count, plural, =1{1 élément déplacé} other{{count} éléments déplacés}}",
"collectionEditSuccessFeedback": "{count, plural, =1{1 élément modifié} other{{count} éléments modifiés}}",
"collectionEmptyFavourites": "Aucun favori",
"collectionEmptyVideos": "Aucune vidéo",
"collectionEmptyImages": "Aucune image",
"collectionSelectSectionTooltip": "Sélectionner la section",
"collectionDeselectSectionTooltip": "Désélectionner la section",
"drawerCollectionAll": "Toute la collection",
"drawerCollectionFavourites": "Favoris",
"drawerCollectionImages": "Images",
"drawerCollectionVideos": "Vidéos",
"drawerCollectionAnimated": "Animations",
"drawerCollectionMotionPhotos": "Photos animées",
"drawerCollectionPanoramas": "Panoramas",
"drawerCollectionRaws": "Photos Raw",
"drawerCollectionSphericalVideos": "Vidéos à 360°",
"chipSortTitle": "Trier",
"chipSortDate": "par date",
"chipSortName": "par nom",
"chipSortCount": "par nombre déléments",
"albumGroupTitle": "Grouper",
"albumGroupTier": "par importance",
"albumGroupVolume": "par volume de stockage",
"albumGroupNone": "ne pas grouper",
"albumPickPageTitleCopy": "Copie",
"albumPickPageTitleExport": "Export",
"albumPickPageTitleMove": "Déplacement",
"albumPickPageTitlePick": "Sélection",
"albumCamera": "Appareil photo",
"albumDownload": "Téléchargements",
"albumScreenshots": "Captures décran",
"albumScreenRecordings": "Enregistrements décran",
"albumVideoCaptures": "Captures de vidéo",
"albumPageTitle": "Albums",
"albumEmpty": "Aucun album",
"createAlbumTooltip": "Créer un album",
"createAlbumButtonLabel": "CRÉER",
"newFilterBanner": "nouveau",
"countryPageTitle": "Pays",
"countryEmpty": "Aucun pays",
"tagPageTitle": "Libellés",
"tagEmpty": "Aucun libellé",
"searchCollectionFieldHint": "Recherche",
"searchSectionRecent": "Historique",
"searchSectionAlbums": "Albums",
"searchSectionCountries": "Pays",
"searchSectionPlaces": "Lieux",
"searchSectionTags": "Libellés",
"settingsPageTitle": "Réglages",
"settingsSystemDefault": "Système",
"settingsDefault": "Par défaut",
"settingsActionExport": "Exporter",
"settingsActionImport": "Importer",
"settingsSectionNavigation": "Navigation",
"settingsHome": "Page daccueil",
"settingsKeepScreenOnTile": "Maintenir lécran allumé",
"settingsKeepScreenOnTitle": "Allumage de lécran",
"settingsDoubleBackExit": "Presser «\u00A0retour\u00A0» 2 fois pour quitter",
"settingsNavigationDrawerTile": "Menu de navigation",
"settingsNavigationDrawerEditorTitle": "Menu de navigation",
"settingsNavigationDrawerBanner": "Maintenez votre doigt appuyé pour déplacer et réorganiser les éléments de menu.",
"settingsNavigationDrawerTabTypes": "Types",
"settingsNavigationDrawerTabAlbums": "Albums",
"settingsNavigationDrawerTabPages": "Pages",
"settingsNavigationDrawerAddAlbum": "Ajouter un album",
"settingsSectionThumbnails": "Vignettes",
"settingsThumbnailShowLocationIcon": "Afficher licône de lieu",
"settingsThumbnailShowMotionPhotoIcon": "Afficher licône de photo animée",
"settingsThumbnailShowRawIcon": "Afficher licône de photo raw",
"settingsThumbnailShowVideoDuration": "Afficher la durée de la vidéo",
"settingsCollectionQuickActionsTile": "Actions rapides",
"settingsCollectionQuickActionEditorTitle": "Actions rapides",
"settingsCollectionQuickActionTabBrowsing": "Navigation",
"settingsCollectionQuickActionTabSelecting": "Sélection",
"settingsCollectionBrowsingQuickActionEditorBanner": "Maintenez votre doigt appuyé pour déplacer les boutons et choisir les actions affichées lors de la navigation.",
"settingsCollectionSelectionQuickActionEditorBanner": "Maintenez votre doigt appuyé pour déplacer les boutons et choisir les actions affichées lors de la sélection déléments.",
"settingsSectionViewer": "Visionneuse",
"settingsViewerUseCutout": "Utiliser la zone dencoche",
"settingsViewerMaximumBrightness": "Luminosité maximale",
"settingsImageBackground": "Arrière-plan de limage",
"settingsViewerQuickActionsTile": "Actions rapides",
"settingsViewerQuickActionEditorTitle": "Actions rapides",
"settingsViewerQuickActionEditorBanner": "Maintenez votre doigt appuyé pour déplacer les boutons et choisir les actions affichées sur la visionneuse.",
"settingsViewerQuickActionEditorDisplayedButtons": "Boutons affichés",
"settingsViewerQuickActionEditorAvailableButtons": "Boutons disponibles",
"settingsViewerQuickActionEmpty": "Aucun bouton",
"settingsViewerOverlayTile": "Incrustations",
"settingsViewerOverlayTitle": "Incrustations",
"settingsViewerShowOverlayOnOpening": "Afficher à louverture",
"settingsViewerShowMinimap": "Afficher la mini-carte",
"settingsViewerShowInformation": "Afficher les détails",
"settingsViewerShowInformationSubtitle": "Afficher les titre, date, lieu, etc.",
"settingsViewerShowShootingDetails": "Afficher les détails de prise de vue",
"settingsViewerEnableOverlayBlurEffect": "Effets de flou",
"settingsVideoPageTitle": "Réglages vidéo",
"settingsSectionVideo": "Vidéo",
"settingsVideoShowVideos": "Afficher les vidéos",
"settingsVideoEnableHardwareAcceleration": "Accélération matérielle",
"settingsVideoEnableAutoPlay": "Lecture automatique",
"settingsVideoLoopModeTile": "Lecture répétée",
"settingsVideoLoopModeTitle": "Lecture répétée",
"settingsVideoQuickActionsTile": "Actions rapides pour les vidéos",
"settingsVideoQuickActionEditorTitle": "Actions rapides",
"settingsSubtitleThemeTile": "Sous-titres",
"settingsSubtitleThemeTitle": "Sous-titres",
"settingsSubtitleThemeSample": "Ceci est un exemple.",
"settingsSubtitleThemeTextAlignmentTile": "Alignement du texte",
"settingsSubtitleThemeTextAlignmentTitle": "Alignement du texte",
"settingsSubtitleThemeTextSize": "Taille du texte",
"settingsSubtitleThemeShowOutline": "Afficher les contours et ombres",
"settingsSubtitleThemeTextColor": "Couleur du texte",
"settingsSubtitleThemeTextOpacity": "Transparence du texte",
"settingsSubtitleThemeBackgroundColor": "Couleur darrière-plan",
"settingsSubtitleThemeBackgroundOpacity": "Transparence de larrière-plan",
"settingsSubtitleThemeTextAlignmentLeft": "gauche",
"settingsSubtitleThemeTextAlignmentCenter": "centré",
"settingsSubtitleThemeTextAlignmentRight": "droite",
"settingsSectionPrivacy": "Confidentialité",
"settingsAllowInstalledAppAccess": "Autoriser laccès à linventaire des apps",
"settingsAllowInstalledAppAccessSubtitle": "Pour un affichage amélioré des albums",
"settingsAllowErrorReporting": "Autoriser lenvoi de rapports derreur",
"settingsSaveSearchHistory": "Maintenir un historique des recherches",
"settingsHiddenItemsTile": "Éléments masqués",
"settingsHiddenItemsTitle": "Éléments masqués",
"settingsHiddenFiltersTitle": "Filtres masqués",
"settingsHiddenFiltersBanner": "Les images et vidéos correspondantes aux filtres masqués napparaîtront pas dans votre collection.",
"settingsHiddenFiltersEmpty": "Aucun filtre masqué",
"settingsHiddenPathsTitle": "Chemins masqués",
"settingsHiddenPathsBanner": "Les images et vidéos dans ces dossiers, ou leurs sous-dossiers, napparaîtront pas dans votre collection.",
"addPathTooltip": "Ajouter un chemin",
"settingsStorageAccessTile": "Accès au stockage",
"settingsStorageAccessTitle": "Accès au stockage",
"settingsStorageAccessBanner": "Une autorisation daccès au stockage est nécessaire pour modifier le contenu de certains dossiers. Voici la liste des autorisations couramment en vigueur.",
"settingsStorageAccessEmpty": "Aucune autorisation daccès",
"settingsStorageAccessRevokeTooltip": "Retirer",
"settingsSectionAccessibility": "Accessibilité",
"settingsRemoveAnimationsTile": "Suppression des animations",
"settingsRemoveAnimationsTitle": "Suppression des animations",
"settingsTimeToTakeActionTile": "Délai pour effectuer une action",
"settingsTimeToTakeActionTitle": "Délai pour effectuer une action",
"settingsSectionLanguage": "Langue & Formats",
"settingsLanguage": "Langue",
"settingsCoordinateFormatTile": "Format de coordonnées",
"settingsCoordinateFormatTitle": "Format de coordonnées",
"settingsUnitSystemTile": "Unités",
"settingsUnitSystemTitle": "Unités",
"statsPageTitle": "Statistiques",
"statsWithGps": "{count, plural, =1{1 élément localisé} other{{count} éléments localisés}}",
"statsTopCountries": "Top pays",
"statsTopPlaces": "Top lieux",
"statsTopTags": "Top libellés",
"viewerOpenPanoramaButtonLabel": "OUVRIR LE PANORAMA",
"viewerErrorUnknown": "Zut !",
"viewerErrorDoesNotExist": "Le fichier nexiste plus.",
"viewerInfoPageTitle": "Détails",
"viewerInfoBackToViewerTooltip": "Retour à la visionneuse",
"viewerInfoUnknown": "inconnu",
"viewerInfoLabelTitle": "Titre",
"viewerInfoLabelDate": "Date",
"viewerInfoLabelResolution": "Résolution",
"viewerInfoLabelSize": "Taille",
"viewerInfoLabelUri": "URI",
"viewerInfoLabelPath": "Chemin",
"viewerInfoLabelDuration": "Durée",
"viewerInfoLabelOwner": "Propriétaire",
"viewerInfoLabelCoordinates": "Coordonnées",
"viewerInfoLabelAddress": "Adresse",
"mapStyleTitle": "Type de carte",
"mapStyleTooltip": "Sélectionner le type de carte",
"mapZoomInTooltip": "Zoomer",
"mapZoomOutTooltip": "Dézoomer",
"mapPointNorthUpTooltip": "Placer le nord en haut",
"mapAttributionOsmHot": "Données © les contributeurs d[OpenStreetMap](https://www.openstreetmap.org/copyright) • Fond de carte par [HOT](https://www.hotosm.org/) • Hébergé par [OSM France](https://openstreetmap.fr/)",
"mapAttributionStamen": "Données © les contributeurs d[OpenStreetMap](https://www.openstreetmap.org/copyright) • Fond de carte par [Stamen Design](http://stamen.com), [CC BY 3.0](http://creativecommons.org/licenses/by/3.0)",
"openMapPageTooltip": "Ouvrir la page Carte",
"mapEmptyRegion": "Aucune image dans cette région",
"viewerInfoOpenEmbeddedFailureFeedback": "Échec de lextraction des données",
"viewerInfoOpenLinkText": "Ouvrir",
"viewerInfoViewXmlLinkText": "Afficher le XML",
"viewerInfoSearchFieldLabel": "Recherche de métadonnées",
"viewerInfoSearchEmpty": "Aucune clé correspondante",
"viewerInfoSearchSuggestionDate": "Date & heure",
"viewerInfoSearchSuggestionDescription": "Description",
"viewerInfoSearchSuggestionDimensions": "Dimensions",
"viewerInfoSearchSuggestionResolution": "Résolution",
"viewerInfoSearchSuggestionRights": "Droits",
"tagEditorPageTitle": "Modifier les libellés",
"tagEditorPageNewTagFieldLabel": "Nouveau libellé",
"tagEditorPageAddTagTooltip": "Ajouter le libellé",
"tagEditorSectionRecent": "Ajouts récents",
"panoramaEnableSensorControl": "Activer le contrôle par capteurs",
"panoramaDisableSensorControl": "Désactiver le contrôle par capteurs",
"sourceViewerPageTitle": "Code source",
"@sourceViewerPageTitle": {},
"filePickerShowHiddenFiles": "Afficher les fichiers masqués",
"filePickerDoNotShowHiddenFiles": "Ne pas afficher les fichiers masqués",
"filePickerOpenFrom": "Ouvrir à partir de",
"filePickerNoItems": "Aucun élément",
"filePickerUseThisFolder": "Utiliser ce dossier"
}

View file

@ -22,6 +22,7 @@
"showTooltip": "보기",
"hideTooltip": "숨기기",
"removeTooltip": "제거",
"resetButtonTooltip": "복원",
"doubleBackExitMessage": "종료하려면 한번 더 누르세요.",
@ -71,6 +72,7 @@
"videoActionSettings": "설정",
"entryInfoActionEditDate": "날짜와 시간 수정",
"entryInfoActionEditTags": "태그 수정",
"entryInfoActionRemoveMetadata": "메타데이터 삭제",
"filterFavouriteLabel": "즐겨찾기",
@ -186,7 +188,7 @@
"removeEntryMetadataDialogTitle": "메타데이터 삭제",
"removeEntryMetadataDialogMore": "더 보기",
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP가 있어야 모션 포토에 포함되는 동영상을 재생할 수 있습니다. 삭제하시겠습니까?",
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP가 있어야 모션 포토에 포함되는 동영상을 재생할 수 있습니다.\n\n삭제하시겠습니까?",
"videoSpeedDialogLabel": "재생 배속",
@ -232,6 +234,7 @@
"aboutCreditsWorldAtlas1": "이 앱은",
"aboutCreditsWorldAtlas2": "의 TopoJSON 파일(ISC 라이선스)을 이용합니다.",
"aboutCreditsTranslators": "번역가:",
"aboutCreditsTranslatorLine": "{language}: {names}",
"aboutLicenses": "오픈 소스 라이선스",
"aboutLicensesBanner": "이 앱은 다음의 오픈 소스 패키지와 라이브러리를 이용합니다.",
@ -292,6 +295,7 @@
"drawerCollectionFavourites": "즐겨찾기",
"drawerCollectionImages": "사진",
"drawerCollectionVideos": "동영상",
"drawerCollectionAnimated": "애니메이션",
"drawerCollectionMotionPhotos": "모션 포토",
"drawerCollectionPanoramas": "파노라마",
"drawerCollectionRaws": "Raw 이미지",
@ -372,13 +376,8 @@
"settingsCollectionSelectionQuickActionEditorBanner": "버튼을 길게 누른 후 이동하여 항목 선택할 때 표시될 버튼을 선택하세요.",
"settingsSectionViewer": "뷰어",
"settingsViewerShowOverlayOnOpening": "열릴 때 오버레이 표시",
"settingsViewerShowMinimap": "미니맵 표시",
"settingsViewerShowInformation": "상세 정보 표시",
"settingsViewerShowInformationSubtitle": "제목, 날짜, 장소 등 표시",
"settingsViewerShowShootingDetails": "촬영 정보 표시",
"settingsViewerEnableOverlayBlurEffect": "오버레이 흐림 효과",
"settingsViewerUseCutout": "컷아웃 영역 사용",
"settingsViewerMaximumBrightness": "최대 밝기",
"settingsImageBackground": "이미지 배경",
"settingsViewerQuickActionsTile": "빠른 작업",
@ -388,6 +387,15 @@
"settingsViewerQuickActionEditorAvailableButtons": "추가 가능한 버튼",
"settingsViewerQuickActionEmpty": "버튼이 없습니다",
"settingsViewerOverlayTile": "오버레이",
"settingsViewerOverlayTitle": "오버레이",
"settingsViewerShowOverlayOnOpening": "열릴 때 표시",
"settingsViewerShowMinimap": "미니맵 표시",
"settingsViewerShowInformation": "상세 정보 표시",
"settingsViewerShowInformationSubtitle": "제목, 날짜, 장소 등 표시",
"settingsViewerShowShootingDetails": "촬영 정보 표시",
"settingsViewerEnableOverlayBlurEffect": "흐림 효과",
"settingsVideoPageTitle": "동영상 설정",
"settingsSectionVideo": "동영상",
"settingsVideoShowVideos": "미디어에 동영상 표시",
@ -419,15 +427,15 @@
"settingsAllowErrorReporting": "오류 보고서 보내기",
"settingsSaveSearchHistory": "검색기록",
"settingsHiddenFiltersTile": "숨겨진 필터",
"settingsHiddenItemsTile": "숨겨진 항목",
"settingsHiddenItemsTitle": "숨겨진 항목",
"settingsHiddenFiltersTitle": "숨겨진 필터",
"settingsHiddenFiltersBanner": "이 필터에 맞는 사진과 동영상이 숨겨지고 있으며 이 앱에서 보여지지 않을 것입니다.",
"settingsHiddenFiltersEmpty": "숨겨진 필터가 없습니다",
"settingsHiddenPathsTile": "숨겨진 경로",
"settingsHiddenPathsTitle": "숨겨진 경로",
"settingsHiddenPathsBanner": "이 경로에 있는 사진과 동영상이 숨겨지고 있으며 이 앱에서 보여지지 않을 것입니다.",
"settingsHiddenPathsEmpty": "숨겨진 경로가 없습니다",
"addPathTooltip": "경로 추가",
"settingsStorageAccessTile": "저장공간 접근",
@ -496,8 +504,19 @@
"viewerInfoSearchSuggestionResolution": "해상도",
"viewerInfoSearchSuggestionRights": "권리",
"tagEditorPageTitle": "태그 수정",
"tagEditorPageNewTagFieldLabel": "새 태그",
"tagEditorPageAddTagTooltip": "태그 추가",
"tagEditorSectionRecent": "최근 이용기록",
"panoramaEnableSensorControl": "센서 제어 활성화",
"panoramaDisableSensorControl": "센서 제어 비활성화",
"sourceViewerPageTitle": "소스 코드"
"sourceViewerPageTitle": "소스 코드",
"filePickerShowHiddenFiles": "숨겨진 파일 표시",
"filePickerDoNotShowHiddenFiles": "숨겨진 파일 표시 안함",
"filePickerOpenFrom": "다음에서 열기:",
"filePickerNoItems": "항목 없음",
"filePickerUseThisFolder": "이 폴더 사용"
}

View file

@ -186,7 +186,7 @@
"removeEntryMetadataDialogTitle": "Удаление метаданных",
"removeEntryMetadataDialogMore": "Дополнительно",
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "Для воспроизведения видео внутри этой живой фотографии требуется XMP профиль. Вы уверены, что хотите удалить его?",
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "Для воспроизведения видео внутри этой живой фотографии требуется XMP профиль.\n\nВы уверены, что хотите удалить его?",
"videoSpeedDialogLabel": "Скорость воспроизведения",
@ -232,6 +232,7 @@
"aboutCreditsWorldAtlas1": "Это приложение использует файл TopoJSON из",
"aboutCreditsWorldAtlas2": "под лицензией ISC.",
"aboutCreditsTranslators": "Переводчики:",
"aboutCreditsTranslatorLine": "{language}: {names}",
"aboutLicenses": "Лицензии с открытым исходным кодом",
"aboutLicensesBanner": "Это приложение использует следующие пакеты и библиотеки с открытым исходным кодом.",
@ -292,6 +293,7 @@
"drawerCollectionFavourites": "Избранное",
"drawerCollectionImages": "Изображения",
"drawerCollectionVideos": "Видео",
"drawerCollectionAnimated": "GIF",
"drawerCollectionMotionPhotos": "Живые фото",
"drawerCollectionPanoramas": "Панорамы",
"drawerCollectionRaws": "RAW",
@ -372,12 +374,6 @@
"settingsCollectionSelectionQuickActionEditorBanner": "Нажмите и удерживайте, чтобы переместить кнопки и выбрать, какие действия будут отображаться при выборе элементов.",
"settingsSectionViewer": "Просмотрщик",
"settingsViewerShowOverlayOnOpening": "Показывать наложение при открытии",
"settingsViewerShowMinimap": "Показать миникарту",
"settingsViewerShowInformation": "Показывать информацию",
"settingsViewerShowInformationSubtitle": "Показать название, дату, местоположение и т.д.",
"settingsViewerShowShootingDetails": "Показать детали съёмки",
"settingsViewerEnableOverlayBlurEffect": "Наложение эффекта размытия",
"settingsViewerUseCutout": "Использовать область выреза",
"settingsImageBackground": "Фон изображения",
@ -388,6 +384,15 @@
"settingsViewerQuickActionEditorAvailableButtons": "Доступные кнопки",
"settingsViewerQuickActionEmpty": "Нет кнопок",
"settingsViewerOverlayTile": "Наложение",
"settingsViewerOverlayTitle": "Наложение",
"settingsViewerShowOverlayOnOpening": "Показывать наложение при открытии",
"settingsViewerShowMinimap": "Показать миникарту",
"settingsViewerShowInformation": "Показывать информацию",
"settingsViewerShowInformationSubtitle": "Показать название, дату, местоположение и т.д.",
"settingsViewerShowShootingDetails": "Показать детали съёмки",
"settingsViewerEnableOverlayBlurEffect": "Наложение эффекта размытия",
"settingsVideoPageTitle": "Настройки видео",
"settingsSectionVideo": "Видео",
"settingsVideoShowVideos": "Показывать видео",
@ -419,15 +424,15 @@
"settingsAllowErrorReporting": "Разрешить анонимную отправку логов",
"settingsSaveSearchHistory": "Сохранять историю поиска",
"settingsHiddenFiltersTile": "Скрытые фильтры",
"settingsHiddenItemsTile": "Скрытые объекты",
"settingsHiddenItemsTitle": "Скрытые объекты",
"settingsHiddenFiltersTitle": "Скрытые фильтры",
"settingsHiddenFiltersBanner": "Фотографии и видео, соответствующие скрытым фильтрам, не появятся в вашей коллекции.",
"settingsHiddenFiltersEmpty": "Нет скрытых фильтров",
"settingsHiddenPathsTile": "Скрытые каталоги",
"settingsHiddenPathsTitle": "Скрытые каталоги",
"settingsHiddenPathsBanner": "Фотографии и видео в этих каталогах или любых их вложенных каталогах не будут отображаться в вашей коллекции.",
"settingsHiddenPathsEmpty": "Нет скрытых каталогов",
"addPathTooltip": "Добавить каталог",
"settingsStorageAccessTile": "Доступ к хранилищу",
@ -496,8 +501,16 @@
"viewerInfoSearchSuggestionResolution": "Разрешение",
"viewerInfoSearchSuggestionRights": "Права",
"tagEditorSectionRecent": "Недавние",
"panoramaEnableSensorControl": "Включить сенсорное управление",
"panoramaDisableSensorControl": "Отключить сенсорное управление",
"sourceViewerPageTitle": "Источник"
"sourceViewerPageTitle": "Источник",
"filePickerShowHiddenFiles": "Показывать скрытые файлы",
"filePickerDoNotShowHiddenFiles": "Не показывать скрытые файлы",
"filePickerOpenFrom": "Открыть",
"filePickerNoItems": "Ничего нет.",
"filePickerUseThisFolder": "Использовать эту папку"
}

View file

@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart';
enum EntryInfoAction {
// general
editDate,
editTags,
removeMetadata,
// motion photo
viewMotionPhotoVideo,
@ -13,6 +14,7 @@ enum EntryInfoAction {
class EntryInfoActions {
static const all = [
EntryInfoAction.editDate,
EntryInfoAction.editTags,
EntryInfoAction.removeMetadata,
EntryInfoAction.viewMotionPhotoVideo,
];
@ -24,6 +26,8 @@ extension ExtraEntryInfoAction on EntryInfoAction {
// general
case EntryInfoAction.editDate:
return context.l10n.entryInfoActionEditDate;
case EntryInfoAction.editTags:
return context.l10n.entryInfoActionEditTags;
case EntryInfoAction.removeMetadata:
return context.l10n.entryInfoActionRemoveMetadata;
// motion photo
@ -41,6 +45,8 @@ extension ExtraEntryInfoAction on EntryInfoAction {
// general
case EntryInfoAction.editDate:
return AIcons.date;
case EntryInfoAction.editTags:
return AIcons.addTag;
case EntryInfoAction.removeMetadata:
return AIcons.clear;
// motion photo

View file

@ -26,6 +26,7 @@ enum EntrySetAction {
rotateCW,
flip,
editDate,
editTags,
removeMetadata,
}
@ -104,6 +105,8 @@ extension ExtraEntrySetAction on EntrySetAction {
return context.l10n.entryActionFlip;
case EntrySetAction.editDate:
return context.l10n.entryInfoActionEditDate;
case EntrySetAction.editTags:
return context.l10n.entryInfoActionEditTags;
case EntrySetAction.removeMetadata:
return context.l10n.entryInfoActionRemoveMetadata;
}
@ -158,6 +161,8 @@ extension ExtraEntrySetAction on EntrySetAction {
return AIcons.flip;
case EntrySetAction.editDate:
return AIcons.date;
case EntrySetAction.editTags:
return AIcons.addTag;
case EntrySetAction.removeMetadata:
return AIcons.clear;
}

View file

@ -0,0 +1,18 @@
import 'package:flutter/foundation.dart';
@immutable
class ActionEvent<T> {
final T action;
const ActionEvent(this.action);
}
@immutable
class ActionStartedEvent<T> extends ActionEvent<T> {
const ActionStartedEvent(T action) : super(action);
}
@immutable
class ActionEndedEvent<T> extends ActionEvent<T> {
const ActionEndedEvent(T action) : super(action);
}

View file

@ -1,3 +1,4 @@
import 'package:aves/model/device.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
@ -17,6 +18,8 @@ abstract class AvesAvailability {
Future<bool> get canLocatePlaces;
Future<bool> get canUseGoogleMaps;
Future<bool> get isNewVersionAvailable;
}
@ -59,6 +62,9 @@ class LiveAvesAvailability implements AvesAvailability {
@override
Future<bool> get canLocatePlaces => Future.wait<bool>([isConnected, hasPlayServices]).then((results) => results.every((result) => result));
@override
Future<bool> get canUseGoogleMaps async => device.canRenderGoogleMaps && await hasPlayServices;
@override
Future<bool> get isNewVersionAvailable async {
if (_isNewVersionAvailable != null) return SynchronousFuture(_isNewVersionAvailable!);

43
lib/model/device.dart Normal file
View file

@ -0,0 +1,43 @@
import 'package:aves/services/common/services.dart';
import 'package:package_info_plus/package_info_plus.dart';
final Device device = Device._private();
class Device {
late final String _userAgent;
late final bool _canGrantDirectoryAccess, _canPinShortcut, _canPrint, _canRenderFlagEmojis, _canRenderGoogleMaps;
late final bool _hasFilePicker, _showPinShortcutFeedback;
String get userAgent => _userAgent;
bool get canGrantDirectoryAccess => _canGrantDirectoryAccess;
bool get canPinShortcut => _canPinShortcut;
bool get canPrint => _canPrint;
bool get canRenderFlagEmojis => _canRenderFlagEmojis;
bool get canRenderGoogleMaps => _canRenderGoogleMaps;
// TODO TLAD toggle settings > import/export, about > bug report > save
bool get hasFilePicker => _hasFilePicker;
bool get showPinShortcutFeedback => _showPinShortcutFeedback;
Device._private();
Future<void> init() async {
final packageInfo = await PackageInfo.fromPlatform();
_userAgent = '${packageInfo.packageName}/${packageInfo.version}';
final capabilities = await deviceService.getCapabilities();
_canGrantDirectoryAccess = capabilities['canGrantDirectoryAccess'] ?? false;
_canPinShortcut = capabilities['canPinShortcut'] ?? false;
_canPrint = capabilities['canPrint'] ?? false;
_canRenderFlagEmojis = capabilities['canRenderFlagEmojis'] ?? false;
_canRenderGoogleMaps = capabilities['canRenderGoogleMaps'] ?? false;
_hasFilePicker = capabilities['hasFilePicker'] ?? false;
_showPinShortcutFeedback = capabilities['showPinShortcutFeedback'] ?? false;
}
}

View file

@ -24,6 +24,8 @@ import 'package:country_code/country_code.dart';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
enum EntryDataType { basic, catalog, address, references }
class AvesEntry {
String uri;
String? _path, _directory, _filename, _extension;
@ -235,6 +237,10 @@ class AvesEntry {
bool get canEdit => path != null;
bool get canEditDate => canEdit && canEditExif;
bool get canEditTags => canEdit && canEditXmp;
bool get canRotateAndFlip => canEdit && canEditExif;
// as of androidx.exifinterface:exifinterface:1.3.3
@ -250,6 +256,30 @@ class AvesEntry {
}
}
// as of latest PixyMeta
bool get canEditIptc {
switch (mimeType.toLowerCase()) {
case MimeTypes.jpeg:
case MimeTypes.tiff:
return true;
default:
return false;
}
}
// as of latest PixyMeta
bool get canEditXmp {
switch (mimeType.toLowerCase()) {
case MimeTypes.gif:
case MimeTypes.jpeg:
case MimeTypes.png:
case MimeTypes.tiff:
return true;
default:
return false;
}
}
// as of latest PixyMeta
bool get canRemoveMetadata {
switch (mimeType.toLowerCase()) {
@ -394,11 +424,11 @@ class AvesEntry {
LatLng? get latLng => hasGps ? LatLng(_catalogMetadata!.latitude!, _catalogMetadata!.longitude!) : null;
List<String>? _xmpSubjects;
Set<String>? _tags;
List<String> get xmpSubjects {
_xmpSubjects ??= _catalogMetadata?.xmpSubjects?.split(';').where((tag) => tag.isNotEmpty).toList() ?? [];
return _xmpSubjects!;
Set<String> get tags {
_tags ??= _catalogMetadata?.xmpSubjects?.split(';').where((tag) => tag.isNotEmpty).toSet() ?? {};
return _tags!;
}
String? _bestTitle;
@ -408,13 +438,15 @@ class AvesEntry {
return _bestTitle;
}
CatalogMetadata? get catalogMetadata => _catalogMetadata;
int? get catalogDateMillis => _catalogDateMillis;
set catalogDateMillis(int? dateMillis) {
_catalogDateMillis = dateMillis;
_bestDate = null;
}
CatalogMetadata? get catalogMetadata => _catalogMetadata;
set catalogMetadata(CatalogMetadata? newMetadata) {
final oldDateModifiedSecs = dateModifiedSecs;
final oldRotationDegrees = rotationDegrees;
@ -423,8 +455,8 @@ class AvesEntry {
catalogDateMillis = newMetadata?.dateMillis;
_catalogMetadata = newMetadata;
_bestTitle = null;
_xmpSubjects = null;
metadataChangeNotifier.notifyListeners();
_tags = null;
metadataChangeNotifier.notify();
_onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
}
@ -434,7 +466,7 @@ class AvesEntry {
addressDetails = null;
}
Future<void> catalog({required bool background, required bool persist, required bool force}) async {
Future<void> catalog({required bool background, required bool force, required bool persist}) async {
if (isCatalogued && !force) return;
if (isSvg) {
// vector image sizing is not essential, so we should not spend time for it during loading
@ -466,7 +498,7 @@ class AvesEntry {
set addressDetails(AddressDetails? newAddress) {
_addressDetails = newAddress;
addressChangeNotifier.notifyListeners();
addressChangeNotifier.notify();
}
Future<void> locate({required bool background, required bool force, required Locale geocoderLocale}) async {
@ -590,61 +622,83 @@ class AvesEntry {
}
await _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
metadataChangeNotifier.notifyListeners();
metadataChangeNotifier.notify();
}
Future<void> refresh({required bool background, required bool persist, required bool force, required Locale geocoderLocale}) async {
_catalogMetadata = null;
_addressDetails = null;
Future<void> refresh({
required bool background,
required bool persist,
required Set<EntryDataType> dataTypes,
required Locale geocoderLocale,
}) async {
// clear derived fields
_bestDate = null;
_bestTitle = null;
_xmpSubjects = null;
_tags = null;
if (persist) {
await metadataDb.removeIds({contentId!}, metadataOnly: true);
await metadataDb.removeIds({contentId!}, dataTypes: dataTypes);
}
final updated = await mediaFileService.getEntry(uri, mimeType);
if (updated != null) {
await _applyNewFields(updated.toMap(), persist: persist);
await catalog(background: background, persist: persist, force: force);
await locate(background: background, force: force, geocoderLocale: geocoderLocale);
final updatedEntry = await mediaFileService.getEntry(uri, mimeType);
if (updatedEntry != null) {
await _applyNewFields(updatedEntry.toMap(), persist: persist);
}
await catalog(background: background, force: dataTypes.contains(EntryDataType.catalog), persist: persist);
await locate(background: background, force: dataTypes.contains(EntryDataType.address), geocoderLocale: geocoderLocale);
}
Future<bool> rotate({required bool clockwise, required bool persist}) async {
Future<Set<EntryDataType>> rotate({required bool clockwise, required bool persist}) async {
final newFields = await metadataEditService.rotate(this, clockwise: clockwise);
if (newFields.isEmpty) return false;
if (newFields.isEmpty) return {};
await _applyNewFields(newFields, persist: persist);
return true;
return {
EntryDataType.basic,
EntryDataType.catalog,
};
}
Future<bool> flip({required bool persist}) async {
Future<Set<EntryDataType>> flip({required bool persist}) async {
final newFields = await metadataEditService.flip(this);
if (newFields.isEmpty) return false;
if (newFields.isEmpty) return {};
await _applyNewFields(newFields, persist: persist);
return true;
return {
EntryDataType.basic,
EntryDataType.catalog,
};
}
Future<bool> editDate(DateModifier modifier) async {
Future<Set<EntryDataType>> editDate(DateModifier modifier) async {
if (modifier.action == DateEditAction.extractFromTitle) {
final _title = bestTitle;
if (_title == null) return false;
if (_title == null) return {};
final date = parseUnknownDateFormat(_title);
if (date == null) {
await reportService.recordError('failed to parse date from title=$_title', null);
return false;
return {};
}
modifier = DateModifier(DateEditAction.set, modifier.fields, dateTime: date);
}
final newFields = await metadataEditService.editDate(this, modifier);
return newFields.isNotEmpty;
return newFields.isEmpty
? {}
: {
EntryDataType.basic,
EntryDataType.catalog,
};
}
Future<bool> removeMetadata(Set<MetadataType> types) async {
Future<Set<EntryDataType>> removeMetadata(Set<MetadataType> types) async {
final newFields = await metadataEditService.removeTypes(this, types);
return newFields.isNotEmpty;
return newFields.isEmpty
? {}
: {
EntryDataType.basic,
EntryDataType.catalog,
EntryDataType.address,
};
}
Future<bool> delete() {
@ -665,7 +719,7 @@ class AvesEntry {
Future<void> _onVisualFieldChanged(int? oldDateModifiedSecs, int oldRotationDegrees, bool oldIsFlipped) async {
if (oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) {
await EntryCache.evict(uri, mimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
imageChangeNotifier.notifyListeners();
imageChangeNotifier.notify();
}
}

View file

@ -9,7 +9,7 @@ import 'package:aves/model/entry_cache.dart';
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
extension ExtraAvesEntry on AvesEntry {
extension ExtraAvesEntryImages on AvesEntry {
bool isThumbnailReady({double extent = 0}) => _isReady(_getThumbnailProviderKey(extent));
ThumbnailProvider getThumbnail({double extent = 0}) {

View file

@ -0,0 +1,237 @@
import 'dart:convert';
import 'package:aves/model/entry.dart';
import 'package:aves/ref/iptc.dart';
import 'package:aves/services/common/services.dart';
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:intl/intl.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:xml/xml.dart';
extension ExtraAvesEntryXmpIptc on AvesEntry {
static const dcNamespace = 'http://purl.org/dc/elements/1.1/';
static const rdfNamespace = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#';
static const xNamespace = 'adobe:ns:meta/';
static const xmpNamespace = 'http://ns.adobe.com/xap/1.0/';
static const xmpNoteNamespace = 'http://ns.adobe.com/xmp/note/';
static const xmlnsPrefix = 'xmlns';
static final nsDefaultPrefixes = {
dcNamespace: 'dc',
rdfNamespace: 'rdf',
xNamespace: 'x',
xmpNamespace: 'xmp',
xmpNoteNamespace: 'xmpNote',
};
// elements
static const xXmpmeta = 'xmpmeta';
static const rdfRoot = 'RDF';
static const rdfDescription = 'Description';
static const dcSubject = 'subject';
// attributes
static const xXmptk = 'xmptk';
static const rdfAbout = 'about';
static const xmpMetadataDate = 'MetadataDate';
static const xmpModifyDate = 'ModifyDate';
static const xmpNoteHasExtendedXMP = 'HasExtendedXMP';
static String prefixOf(String ns) => nsDefaultPrefixes[ns] ?? '';
Future<Set<EntryDataType>> editTags(Set<String> tags) async {
final xmp = await metadataFetchService.getXmp(this);
final extendedXmpString = xmp?.extendedXmpString;
XmlDocument? xmpDoc;
if (xmp != null) {
final xmpString = xmp.xmpString;
if (xmpString != null) {
xmpDoc = XmlDocument.parse(xmpString);
}
}
if (xmpDoc == null) {
final toolkit = 'Aves v${(await PackageInfo.fromPlatform()).version}';
final builder = XmlBuilder();
builder.namespace(xNamespace, prefixOf(xNamespace));
builder.element(xXmpmeta, namespace: xNamespace, namespaces: {
xNamespace: prefixOf(xNamespace),
}, attributes: {
'${prefixOf(xNamespace)}:$xXmptk': toolkit,
});
xmpDoc = builder.buildDocument();
}
final root = xmpDoc.rootElement;
XmlNode? rdf = root.getElement(rdfRoot, namespace: rdfNamespace);
if (rdf == null) {
final builder = XmlBuilder();
builder.namespace(rdfNamespace, prefixOf(rdfNamespace));
builder.element(rdfRoot, namespace: rdfNamespace, namespaces: {
rdfNamespace: prefixOf(rdfNamespace),
});
// get element because doc fragment cannot be used to edit
root.children.add(builder.buildFragment());
rdf = root.getElement(rdfRoot, namespace: rdfNamespace)!;
}
XmlNode? description = rdf.getElement(rdfDescription, namespace: rdfNamespace);
if (description == null) {
final builder = XmlBuilder();
builder.namespace(rdfNamespace, prefixOf(rdfNamespace));
builder.element(rdfDescription, namespace: rdfNamespace, attributes: {
'${prefixOf(rdfNamespace)}:$rdfAbout': '',
});
rdf.children.add(builder.buildFragment());
// get element because doc fragment cannot be used to edit
description = rdf.getElement(rdfDescription, namespace: rdfNamespace)!;
}
_setNamespaces(description, {
dcNamespace: prefixOf(dcNamespace),
xmpNamespace: prefixOf(xmpNamespace),
});
_setStringBag(description, dcSubject, tags, namespace: dcNamespace);
if (_isMeaningfulXmp(rdf)) {
final modifyDate = DateTime.now();
description.setAttribute('${prefixOf(xmpNamespace)}:$xmpMetadataDate', _toXmpDate(modifyDate));
description.setAttribute('${prefixOf(xmpNamespace)}:$xmpModifyDate', _toXmpDate(modifyDate));
} else {
// clear XMP if there are no attributes or elements worth preserving
xmpDoc = null;
}
final editedXmp = AvesXmp(
xmpString: xmpDoc?.toXmlString(),
extendedXmpString: extendedXmpString,
);
if (canEditIptc) {
final iptc = await metadataFetchService.getIptc(this);
if (iptc != null) {
await _setIptcKeywords(iptc, tags);
}
}
final newFields = await metadataEditService.setXmp(this, editedXmp);
return newFields.isEmpty ? {} : {EntryDataType.catalog};
}
Future<void> _setIptcKeywords(List<Map<String, dynamic>> iptc, Set<String> tags) async {
iptc.removeWhere((v) => v['record'] == IPTC.applicationRecord && v['tag'] == IPTC.keywordsTag);
iptc.add({
'record': IPTC.applicationRecord,
'tag': IPTC.keywordsTag,
'values': tags.map((v) => utf8.encode(v)).toList(),
});
await metadataEditService.setIptc(this, iptc, postEditScan: false);
}
int _meaningfulChildrenCount(XmlNode node) => node.children.where((v) => v.nodeType != XmlNodeType.TEXT || v.text.trim().isNotEmpty).length;
bool _isMeaningfulXmp(XmlNode rdf) {
if (_meaningfulChildrenCount(rdf) > 1) return true;
final description = rdf.getElement(rdfDescription, namespace: rdfNamespace);
if (description == null) return true;
if (_meaningfulChildrenCount(description) > 0) return true;
final hasMeaningfulAttributes = description.attributes.any((v) {
switch (v.name.local) {
case rdfAbout:
return v.value.isNotEmpty;
case xmpMetadataDate:
case xmpModifyDate:
return false;
default:
switch (v.name.prefix) {
case xmlnsPrefix:
return false;
default:
// if the attribute got defined with the prefix as part of the name,
// the prefix is not recognized as such, so we check the full name
return !v.name.qualified.startsWith(xmlnsPrefix);
}
}
});
return hasMeaningfulAttributes;
}
// return time zone designator, formatted as `Z` or `+hh:mm` or `-hh:mm`
// as of intl v0.17.0, formatting time zone offset is not implemented
String _xmpTimeZoneDesignator(DateTime date) {
final offsetMinutes = date.timeZoneOffset.inMinutes;
final abs = offsetMinutes.abs();
final h = abs ~/ Duration.minutesPerHour;
final m = abs % Duration.minutesPerHour;
return '${offsetMinutes.isNegative ? '-' : '+'}${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}';
}
String _toXmpDate(DateTime date) => '${DateFormat('yyyy-MM-ddTHH:mm:ss').format(date)}${_xmpTimeZoneDesignator(date)}';
void _setNamespaces(XmlNode node, Map<String, String> namespaces) => namespaces.forEach((uri, prefix) => node.setAttribute('$xmlnsPrefix:$prefix', uri));
void _setStringBag(XmlNode node, String name, Set<String> values, {required String namespace}) {
// remove existing
node.findElements(name, namespace: namespace).toSet().forEach(node.children.remove);
if (values.isNotEmpty) {
// add new bag
final rootBuilder = XmlBuilder();
rootBuilder.namespace(namespace, prefixOf(namespace));
rootBuilder.element(name, namespace: namespace);
node.children.add(rootBuilder.buildFragment());
final bagBuilder = XmlBuilder();
bagBuilder.namespace(rdfNamespace, prefixOf(rdfNamespace));
bagBuilder.element('Bag', namespace: rdfNamespace, nest: () {
values.forEach((v) {
bagBuilder.element('li', namespace: rdfNamespace, nest: v);
});
});
node.children.last.children.add(bagBuilder.buildFragment());
}
}
}
@immutable
class AvesXmp extends Equatable {
final String? xmpString;
final String? extendedXmpString;
@override
List<Object?> get props => [xmpString, extendedXmpString];
const AvesXmp({
required this.xmpString,
this.extendedXmpString,
});
static AvesXmp? fromList(List<String> xmpStrings) {
switch (xmpStrings.length) {
case 0:
return null;
case 1:
return AvesXmp(xmpString: xmpStrings.single);
default:
final byExtending = groupBy<String, bool>(xmpStrings, (v) => v.contains(':HasExtendedXMP='));
final extending = byExtending[true] ?? [];
final extension = byExtending[false] ?? [];
if (extending.length == 1 && extension.length == 1) {
return AvesXmp(
xmpString: extending.single,
extendedXmpString: extension.single,
);
}
// take the first XMP and ignore the rest when the file is weirdly constructed
debugPrint('warning: entry has ${xmpStrings.length} XMP directories, xmpStrings=$xmpStrings');
return AvesXmp(xmpString: xmpStrings.firstOrNull);
}
}
}

View file

@ -66,7 +66,9 @@ class AlbumFilter extends CollectionFilter {
return PaletteGenerator.fromImageProvider(
AppIconImage(packageName: packageName, size: 24),
).then((palette) async {
final color = palette.dominantColor?.color ?? (await super.color(context));
// `dominantColor` is most representative but can have low contrast with a dark background
// `vibrantColor` is usually representative and has good contrast with a dark background
final color = palette.vibrantColor?.color ?? (await super.color(context));
_appColors[album] = color;
return color;
});

View file

@ -31,6 +31,8 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
];
static CollectionFilter? fromJson(String jsonString) {
if (jsonString.isEmpty) return null;
try {
final jsonMap = jsonDecode(jsonString);
if (jsonMap is Map<String, dynamic>) {

View file

@ -1,3 +1,4 @@
import 'package:aves/model/device.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
@ -58,15 +59,17 @@ class LocationFilter extends CollectionFilter {
@override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) {
final flag = countryCodeToFlag(_countryCode);
// as of Flutter v1.22.3, emoji shadows are rendered as colorful duplicates,
// not filled with the shadow color as expected, so we remove them
if (flag != null) {
return Text(
flag,
style: TextStyle(fontSize: size, shadows: const []),
textScaleFactor: 1.0,
);
if (_countryCode != null && device.canRenderFlagEmojis) {
final flag = countryCodeToFlag(_countryCode);
// as of Flutter v1.22.3, emoji shadows are rendered as colorful duplicates,
// not filled with the shadow color as expected, so we remove them
if (flag != null) {
return Text(
flag,
style: TextStyle(fontSize: size, shadows: const []),
textScaleFactor: 1.0,
);
}
}
return Icon(_location.isEmpty ? AIcons.locationOff : AIcons.location, size: size);
}

View file

@ -14,9 +14,9 @@ class TagFilter extends CollectionFilter {
TagFilter(this.tag) {
if (tag.isEmpty) {
_test = (entry) => entry.xmpSubjects.isEmpty;
_test = (entry) => entry.tags.isEmpty;
} else {
_test = (entry) => entry.xmpSubjects.contains(tag);
_test = (entry) => entry.tags.contains(tag);
}
}

View file

@ -13,6 +13,8 @@ enum DateEditAction {
}
enum MetadataType {
// JPEG COM marker or GIF comment
comment,
// Exif: https://en.wikipedia.org/wiki/Exif
exif,
// ICC profile: https://en.wikipedia.org/wiki/ICC_profile
@ -23,8 +25,6 @@ enum MetadataType {
jfif,
// JPEG APP14 / Adobe: https://www.exiftool.org/TagNames/JPEG.html#Adobe
jpegAdobe,
// JPEG COM marker
jpegComment,
// JPEG APP12 / Ducky: https://www.exiftool.org/TagNames/APP12.html#Ducky
jpegDucky,
// Photoshop IRB: https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/
@ -42,6 +42,7 @@ class MetadataTypes {
static const common = {
MetadataType.exif,
MetadataType.xmp,
MetadataType.comment,
MetadataType.iccProfile,
MetadataType.iptc,
MetadataType.photoshopIrb,
@ -50,7 +51,6 @@ class MetadataTypes {
static const jpeg = {
MetadataType.jfif,
MetadataType.jpegAdobe,
MetadataType.jpegComment,
MetadataType.jpegDucky,
};
}
@ -59,6 +59,8 @@ extension ExtraMetadataType on MetadataType {
// match `ExifInterface` directory names
String getText() {
switch (this) {
case MetadataType.comment:
return 'Comment';
case MetadataType.exif:
return 'Exif';
case MetadataType.iccProfile:
@ -69,8 +71,6 @@ extension ExtraMetadataType on MetadataType {
return 'JFIF';
case MetadataType.jpegAdobe:
return 'Adobe JPEG';
case MetadataType.jpegComment:
return 'JpegComment';
case MetadataType.jpegDucky:
return 'Ducky';
case MetadataType.photoshopIrb:

View file

@ -1,20 +1,23 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:intl/intl.dart';
class OverlayMetadata {
final String? aperture, exposureTime, focalLength, iso;
@immutable
class OverlayMetadata extends Equatable {
final double? aperture, focalLength;
final String? exposureTime;
final int? iso;
static final apertureFormat = NumberFormat('0.0', 'en_US');
static final focalLengthFormat = NumberFormat('0.#', 'en_US');
@override
List<Object?> get props => [aperture, exposureTime, focalLength, iso];
OverlayMetadata({
double? aperture,
bool get isEmpty => aperture == null && exposureTime == null && focalLength == null && iso == null;
const OverlayMetadata({
this.aperture,
this.exposureTime,
double? focalLength,
int? iso,
}) : aperture = aperture != null ? 'ƒ/${apertureFormat.format(aperture)}' : null,
focalLength = focalLength != null ? '${focalLengthFormat.format(focalLength)} mm' : null,
iso = iso != null ? 'ISO$iso' : null;
this.focalLength,
this.iso,
});
factory OverlayMetadata.fromMap(Map map) {
return OverlayMetadata(
@ -24,9 +27,4 @@ class OverlayMetadata {
iso: map['iso'] as int?,
);
}
bool get isEmpty => aperture == null && exposureTime == null && focalLength == null && iso == null;
@override
String toString() => '$runtimeType#${shortHash(this)}{aperture=$aperture, exposureTime=$exposureTime, focalLength=$focalLength, iso=$iso}';
}

View file

@ -20,7 +20,7 @@ abstract class MetadataDb {
Future<void> reset();
Future<void> removeIds(Set<int> contentIds, {required bool metadataOnly});
Future<void> removeIds(Set<int> contentIds, {Set<EntryDataType>? dataTypes});
// entries
@ -187,20 +187,28 @@ class SqfliteMetadataDb implements MetadataDb {
}
@override
Future<void> removeIds(Set<int> contentIds, {required bool metadataOnly}) async {
Future<void> removeIds(Set<int> contentIds, {Set<EntryDataType>? dataTypes}) async {
if (contentIds.isEmpty) return;
final _dataTypes = dataTypes ?? EntryDataType.values.toSet();
final db = await _database;
// using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead
final batch = db.batch();
const where = 'contentId = ?';
contentIds.forEach((id) {
final whereArgs = [id];
batch.delete(entryTable, where: where, whereArgs: whereArgs);
batch.delete(dateTakenTable, where: where, whereArgs: whereArgs);
batch.delete(metadataTable, where: where, whereArgs: whereArgs);
batch.delete(addressTable, where: where, whereArgs: whereArgs);
if (!metadataOnly) {
if (_dataTypes.contains(EntryDataType.basic)) {
batch.delete(entryTable, where: where, whereArgs: whereArgs);
}
if (_dataTypes.contains(EntryDataType.catalog)) {
batch.delete(dateTakenTable, where: where, whereArgs: whereArgs);
batch.delete(metadataTable, where: where, whereArgs: whereArgs);
}
if (_dataTypes.contains(EntryDataType.address)) {
batch.delete(addressTable, where: where, whereArgs: whereArgs);
}
if (_dataTypes.contains(EntryDataType.references)) {
batch.delete(favouriteTable, where: where, whereArgs: whereArgs);
batch.delete(coverTable, where: where, whereArgs: whereArgs);
batch.delete(videoPlaybackTable, where: where, whereArgs: whereArgs);

View file

@ -4,6 +4,13 @@ import 'package:aves/utils/change_notifier.dart';
import 'package:flutter/foundation.dart';
class Query extends ChangeNotifier {
Query({required String? initialValue}) {
if (initialValue != null && initialValue.isNotEmpty) {
_enabled = true;
queryNotifier.value = initialValue;
}
}
bool _enabled = false;
bool get enabled => _enabled;

View file

@ -2,6 +2,7 @@ import 'package:aves/utils/geo_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:intl/intl.dart';
import 'package:latlong2/latlong.dart';
import 'enums.dart';
@ -16,24 +17,36 @@ extension ExtraCoordinateFormat on CoordinateFormat {
}
}
static const _separator = ', ';
String format(AppLocalizations l10n, LatLng latLng, {bool minuteSecondPadding = false, int dmsSecondDecimals = 2}) {
switch (this) {
case CoordinateFormat.dms:
return toDMS(l10n, latLng, minuteSecondPadding: minuteSecondPadding, secondDecimals: dmsSecondDecimals).join(', ');
return toDMS(l10n, latLng, minuteSecondPadding: minuteSecondPadding, secondDecimals: dmsSecondDecimals).join(_separator);
case CoordinateFormat.decimal:
return [latLng.latitude, latLng.longitude].map((n) => n.toStringAsFixed(6)).join(', ');
return _toDecimal(l10n, latLng).join(_separator);
}
}
// returns coordinates formatted as DMS, e.g. ['41° 24 12.2″ N', '2° 10 26.5″ E']
static List<String> toDMS(AppLocalizations l10n, LatLng latLng, {bool minuteSecondPadding = false, int secondDecimals = 2}) {
final locale = l10n.localeName;
final lat = latLng.latitude;
final lng = latLng.longitude;
final latSexa = GeoUtils.decimal2sexagesimal(lat, minuteSecondPadding, secondDecimals);
final lngSexa = GeoUtils.decimal2sexagesimal(lng, minuteSecondPadding, secondDecimals);
final latSexa = GeoUtils.decimal2sexagesimal(lat, minuteSecondPadding, secondDecimals, locale);
final lngSexa = GeoUtils.decimal2sexagesimal(lng, minuteSecondPadding, secondDecimals, locale);
return [
l10n.coordinateDms(latSexa, lat < 0 ? l10n.coordinateDmsSouth : l10n.coordinateDmsNorth),
l10n.coordinateDms(lngSexa, lng < 0 ? l10n.coordinateDmsWest : l10n.coordinateDmsEast),
];
}
static List<String> _toDecimal(AppLocalizations l10n, LatLng latLng) {
final locale = l10n.localeName;
final formatter = NumberFormat('0.000000°', locale);
return [
formatter.format(latLng.latitude),
formatter.format(latLng.longitude),
];
}
}

View file

@ -65,6 +65,7 @@ class SettingsDefaults {
static const showOverlayShootingDetails = false;
static const enableOverlayBlurEffect = true; // `enableOverlayBlurEffect` has a contextual default value
static const viewerUseCutout = true;
static const viewerMaxBrightness = false;
// video
static const videoQuickActions = [
@ -98,4 +99,7 @@ class SettingsDefaults {
// accessibility
static const accessibilityAnimations = AccessibilityAnimations.system;
static const timeToTakeAction = AccessibilityTimeout.appDefault; // `timeToTakeAction` has a contextual default value
// file picker
static const filePickerShowHiddenFiles = false;
}

View file

@ -81,6 +81,7 @@ class Settings extends ChangeNotifier {
static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details';
static const enableOverlayBlurEffectKey = 'enable_overlay_blur_effect';
static const viewerUseCutoutKey = 'viewer_use_cutout';
static const viewerMaxBrightnessKey = 'viewer_max_brightness';
// video
static const videoQuickActionsKey = 'video_quick_actions';
@ -116,6 +117,9 @@ class Settings extends ChangeNotifier {
// version
static const lastVersionCheckDateKey = 'last_version_check_date';
// file picker
static const filePickerShowHiddenFilesKey = 'file_picker_show_hidden_files';
// platform settings
// cf Android `Settings.System.ACCELEROMETER_ROTATION`
static const platformAccelerometerRotationKey = 'accelerometer_rotation';
@ -152,8 +156,8 @@ class Settings extends ChangeNotifier {
enableOverlayBlurEffect = performanceClass >= 29;
// availability
final hasPlayServices = await availability.hasPlayServices;
if (hasPlayServices) {
final canUseGoogleMaps = await availability.canUseGoogleMaps;
if (canUseGoogleMaps) {
infoMapStyle = EntryMapStyle.googleNormal;
} else {
final styles = EntryMapStyle.values.whereNot((v) => v.isGoogleMaps).toList();
@ -352,6 +356,10 @@ class Settings extends ChangeNotifier {
set viewerUseCutout(bool newValue) => setAndNotify(viewerUseCutoutKey, newValue);
bool get viewerMaxBrightness => getBoolOrDefault(viewerMaxBrightnessKey, SettingsDefaults.viewerMaxBrightness);
set viewerMaxBrightness(bool newValue) => setAndNotify(viewerMaxBrightnessKey, newValue);
// video
List<VideoAction> get videoQuickActions => getEnumListOrDefault(videoQuickActionsKey, SettingsDefaults.videoQuickActions, VideoAction.values);
@ -446,6 +454,12 @@ class Settings extends ChangeNotifier {
set lastVersionCheckDate(DateTime newValue) => setAndNotify(lastVersionCheckDateKey, newValue.millisecondsSinceEpoch);
// file picker
bool get filePickerShowHiddenFiles => getBoolOrDefault(filePickerShowHiddenFilesKey, SettingsDefaults.filePickerShowHiddenFiles);
set filePickerShowHiddenFiles(bool newValue) => setAndNotify(filePickerShowHiddenFilesKey, newValue);
// convenience methods
// ignore: avoid_positional_boolean_parameters
@ -587,10 +601,12 @@ class Settings extends ChangeNotifier {
case showOverlayShootingDetailsKey:
case enableOverlayBlurEffectKey:
case viewerUseCutoutKey:
case viewerMaxBrightnessKey:
case enableVideoHardwareAccelerationKey:
case enableVideoAutoPlayKey:
case subtitleShowOutlineKey:
case saveSearchHistoryKey:
case filePickerShowHiddenFilesKey:
if (value is bool) {
_prefs!.setBool(key, value);
} else {

View file

@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:collection';
import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/filters/album.dart';
@ -10,6 +11,7 @@ import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/query.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/events.dart';
import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/section_keys.dart';
import 'package:aves/model/source/tag.dart';
@ -49,7 +51,12 @@ class CollectionLens with ChangeNotifier {
final sourceEvents = source.eventBus;
_subscriptions.add(sourceEvents.on<EntryAddedEvent>().listen((e) => _onEntryAdded(e.entries)));
_subscriptions.add(sourceEvents.on<EntryRemovedEvent>().listen((e) => _onEntryRemoved(e.entries)));
_subscriptions.add(sourceEvents.on<EntryMovedEvent>().listen((e) => _refresh()));
_subscriptions.add(sourceEvents.on<EntryMovedEvent>().listen((e) {
if (e.type == MoveType.move) {
// refreshing copied items is already handled via `EntryAddedEvent`s
_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()));

View file

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/covers.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/favourites.dart';
@ -11,6 +12,7 @@ import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/analysis_controller.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/model/source/events.dart';
import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/tag.dart';
import 'package:aves/services/analysis_service.dart';
@ -104,7 +106,9 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
_rawEntries.removeWhere((entry) => newContentIds.contains(entry.contentId));
}
entries.forEach((entry) => entry.catalogDateMillis = _savedDates[entry.contentId]);
entries.where((entry) => entry.catalogDateMillis == null).forEach((entry) {
entry.catalogDateMillis = _savedDates[entry.contentId];
});
_entryById.addAll(newIdMapEntries);
_rawEntries.addAll(entries);
@ -183,8 +187,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
return;
}
await _moveEntry(entry, newFields, persist: persist);
entry.metadataChangeNotifier.notifyListeners();
eventBus.fire(EntryMovedEvent({entry}));
entry.metadataChangeNotifier.notify();
eventBus.fire(EntryMovedEvent(MoveType.move, {entry}));
completer.complete(true);
},
);
@ -245,6 +249,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
title: newFields['title'] as String?,
dateModifiedSecs: newFields['dateModifiedSecs'] as int?,
));
} else {
debugPrint('failed to find source entry with uri=$sourceUri');
}
});
await metadataDb.saveEntries(movedEntries);
@ -273,7 +279,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
}
invalidateAlbumFilterSummary(directories: fromAlbums);
_invalidate(movedEntries);
eventBus.fire(EntryMovedEvent(movedEntries));
eventBus.fire(EntryMovedEvent(copy ? MoveType.copy : MoveType.move, movedEntries));
}
bool get initialized => false;
@ -284,8 +290,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
Future<Set<String>> refreshUris(Set<String> changedUris, {AnalysisController? analysisController});
Future<void> refreshEntry(AvesEntry entry) async {
await entry.refresh(background: false, persist: true, force: true, geocoderLocale: settings.appliedLocale);
Future<void> refreshEntry(AvesEntry entry, Set<EntryDataType> dataTypes) async {
await entry.refresh(background: false, persist: true, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale);
updateDerivedFilters({entry});
eventBus.fire(EntryRefreshedEvent({entry}));
}
@ -381,46 +387,3 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
}
}
}
@immutable
class EntryAddedEvent {
final Set<AvesEntry>? entries;
const EntryAddedEvent([this.entries]);
}
@immutable
class EntryRemovedEvent {
final Set<AvesEntry> entries;
const EntryRemovedEvent(this.entries);
}
@immutable
class EntryMovedEvent {
final Set<AvesEntry> entries;
const EntryMovedEvent(this.entries);
}
@immutable
class EntryRefreshedEvent {
final Set<AvesEntry> entries;
const EntryRefreshedEvent(this.entries);
}
@immutable
class FilterVisibilityChangedEvent {
final Set<CollectionFilter> filters;
final bool visible;
const FilterVisibilityChangedEvent(this.filters, this.visible);
}
@immutable
class ProgressEvent {
final int done, total;
const ProgressEvent({required this.done, required this.total});
}

View file

@ -0,0 +1,48 @@
import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:flutter/foundation.dart';
@immutable
class EntryAddedEvent {
final Set<AvesEntry>? entries;
const EntryAddedEvent([this.entries]);
}
@immutable
class EntryRemovedEvent {
final Set<AvesEntry> entries;
const EntryRemovedEvent(this.entries);
}
@immutable
class EntryMovedEvent {
final MoveType type;
final Set<AvesEntry> entries;
const EntryMovedEvent(this.type, this.entries);
}
@immutable
class EntryRefreshedEvent {
final Set<AvesEntry> entries;
const EntryRefreshedEvent(this.entries);
}
@immutable
class FilterVisibilityChangedEvent {
final Set<CollectionFilter> filters;
final bool visible;
const FilterVisibilityChangedEvent(this.filters, this.visible);
}
@immutable
class ProgressEvent {
final int done, total;
const ProgressEvent({required this.done, required this.total});
}

View file

@ -67,7 +67,7 @@ class MediaStoreSource extends CollectionSource {
// clean up obsolete entries
debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries');
await metadataDb.removeIds(obsoleteContentIds, metadataOnly: false);
await metadataDb.removeIds(obsoleteContentIds);
// verify paths because some apps move files without updating their `last modified date`
debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete paths');

View file

@ -38,7 +38,7 @@ mixin TagMixin on SourceBase {
var stopCheckCount = 0;
final newMetadata = <CatalogMetadata>{};
for (final entry in todo) {
await entry.catalog(background: true, persist: true, force: force);
await entry.catalog(background: true, force: force, persist: true);
if (entry.isCatalogued) {
newMetadata.add(entry.catalogMetadata!);
if (newMetadata.length >= commitCountThreshold) {
@ -63,7 +63,7 @@ mixin TagMixin on SourceBase {
}
void updateTags() {
final updatedTags = visibleEntries.expand((entry) => entry.xmpSubjects).toSet().toList()..sort(compareAsciiUpperCase);
final updatedTags = visibleEntries.expand((entry) => entry.tags).toSet().toList()..sort(compareAsciiUpperCase);
if (!listEquals(updatedTags, sortedTags)) {
sortedTags = List.unmodifiable(updatedTags);
invalidateTagFilterSummary();
@ -85,7 +85,7 @@ mixin TagMixin on SourceBase {
_filterEntryCountMap.clear();
_filterRecentEntryMap.clear();
} else {
tags = entries.where((entry) => entry.isCatalogued).expand((entry) => entry.xmpSubjects).toSet();
tags = entries.where((entry) => entry.isCatalogued).expand((entry) => entry.tags).toSet();
tags.forEach(_filterEntryCountMap.remove);
}
eventBus.fire(TagSummaryInvalidatedEvent(tags));

View file

@ -81,7 +81,9 @@ class VideoMetadataFormatter {
static Future<CatalogMetadata?> getCatalogMetadata(AvesEntry entry) async {
final mediaInfo = await getVideoMetadata(entry);
bool isDefined(dynamic value) => value is String && value != '0';
// only consider values with at least 8 characters (yyyymmdd),
// ignoring unset values like `0`, as well as year values like `2021`
bool isDefined(dynamic value) => value is String && value.length >= 8;
var dateString = mediaInfo[Keys.date];
if (!isDefined(dateString)) {
@ -112,6 +114,7 @@ class VideoMetadataFormatter {
// `DateTime` does not recognize:
// - `UTC 2021-05-30 19:14:21`
// - `2021`
final match = _anotherDatePattern.firstMatch(dateString);
if (match != null) {
@ -371,7 +374,7 @@ class VideoMetadataFormatter {
static String _formatFilesize(String value) {
final size = int.tryParse(value);
return size != null ? formatFilesize(size) : value;
return size != null ? formatFileSize('en_US', size) : value;
}
static String _formatLanguage(String value) {
@ -396,20 +399,10 @@ class VideoMetadataFormatter {
if (parsed == null) return size;
size = parsed;
}
const divider = 1000;
if (size < divider) return '$size $unit';
if (size < divider * divider && size % divider == 0) {
return '${(size / divider).toStringAsFixed(0)} K$unit';
}
if (size < divider * divider) {
return '${(size / divider).toStringAsFixed(round)} K$unit';
}
if (size < divider * divider * divider && size % divider == 0) {
return '${(size / (divider * divider)).toStringAsFixed(0)} M$unit';
}
if (size < divider * divider) return '${(size / divider).toStringAsFixed(round)} K$unit';
return '${(size / divider / divider).toStringAsFixed(round)} M$unit';
}
}

6
lib/ref/iptc.dart Normal file
View file

@ -0,0 +1,6 @@
class IPTC {
static const int applicationRecord = 2;
// ApplicationRecord tags
static const int keywordsTag = 25;
}

View file

@ -8,8 +8,8 @@ import 'package:aves/model/source/media_store_source.dart';
import 'package:aves/model/source/source_state.dart';
import 'package:aves/services/common/services.dart';
import 'package:fijkplayer/fijkplayer.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class AnalysisService {

View file

@ -6,7 +6,6 @@ import 'package:aves/services/common/services.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart';
@ -29,8 +28,6 @@ abstract class AndroidAppService {
Future<bool> shareSingle(String uri, String mimeType);
Future<bool> canPinToHomeScreen();
Future<void> pinToHomeScreen(String label, AvesEntry? coverEntry, {Set<CollectionFilter>? filters, String? uri});
}
@ -174,25 +171,6 @@ class PlatformAndroidAppService implements AndroidAppService {
// app shortcuts
// this ability will not change over the lifetime of the app
bool? _canPin;
@override
Future<bool> canPinToHomeScreen() async {
if (_canPin != null) return SynchronousFuture(_canPin!);
try {
final result = await platform.invokeMethod('canPin');
if (result != null) {
_canPin = result;
return result;
}
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return false;
}
@override
Future<void> pinToHomeScreen(String label, AvesEntry? coverEntry, {Set<CollectionFilter>? filters, String? uri}) async {
Uint8List? iconBytes;
@ -209,7 +187,7 @@ class PlatformAndroidAppService implements AndroidAppService {
);
}
try {
await platform.invokeMethod('pin', <String, dynamic>{
await platform.invokeMethod('pinShortcut', <String, dynamic>{
'label': label,
'iconBytes': iconBytes,
'filters': filters?.map((filter) => filter.toJson()).toList(),

View file

@ -1,7 +1,7 @@
import 'dart:convert';
import 'dart:typed_data';
// cf flutter/foundation `consolidateHttpClientResponseBytes`
// adapted from Flutter `_OutputBuffer` in `/foundation/consolidate_response.dart`
class OutputBuffer extends ByteConversionSinkBase {
List<List<int>>? _chunks = <List<int>>[];
int _contentLength = 0;
@ -21,8 +21,8 @@ class OutputBuffer extends ByteConversionSinkBase {
return;
}
_bytes = Uint8List(_contentLength);
var offset = 0;
for (final chunk in _chunks!) {
int offset = 0;
for (final List<int> chunk in _chunks!) {
_bytes!.setRange(offset, offset + chunk.length, chunk);
offset += chunk.length;
}

View file

@ -2,6 +2,8 @@ import 'package:aves/services/common/services.dart';
import 'package:flutter/services.dart';
abstract class DeviceService {
Future<Map<String, dynamic>> getCapabilities();
Future<String?> getDefaultTimeZone();
Future<int> getPerformanceClass();
@ -10,6 +12,17 @@ abstract class DeviceService {
class PlatformDeviceService implements DeviceService {
static const platform = MethodChannel('deckers.thibault/aves/device');
@override
Future<Map<String, dynamic>> getCapabilities() async {
try {
final result = await platform.invokeMethod('getCapabilities');
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
@override
Future<String?> getDefaultTimeZone() async {
try {

View file

@ -1,11 +1,13 @@
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
// names should match possible values on platform
enum NameConflictStrategy { rename, replace, skip }
extension ExtraNameConflictStrategy on NameConflictStrategy {
String toPlatform() => toString().substring('NameConflictStrategy.'.length);
// TODO TLAD [dart 2.15] replace `describeEnum()` by `enum.name`
String toPlatform() => describeEnum(this);
String getName(BuildContext context) {
switch (this) {

View file

@ -159,7 +159,7 @@ class PlatformMediaFileService implements MediaFileService {
int? pageId,
int? expectedContentLength,
BytesReceivedCallback? onBytesReceived,
}) {
}) async {
try {
final completer = Completer<Uint8List>.sync();
final sink = OutputBuffer();
@ -191,11 +191,12 @@ class PlatformMediaFileService implements MediaFileService {
},
cancelOnError: true,
);
return completer.future;
// `await` here, so that `completeError` will be caught below
return await completer.future;
} on PlatformException catch (e, stack) {
reportService.recordError(e, stack);
await reportService.recordError(e, stack);
}
return Future.sync(() => Uint8List(0));
return Uint8List(0);
}
@override

View file

@ -1,6 +1,7 @@
import 'dart:async';
import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_xmp_iptc.dart';
import 'package:aves/model/metadata/date_modifier.dart';
import 'package:aves/model/metadata/enums.dart';
import 'package:aves/services/common/services.dart';
@ -13,6 +14,10 @@ abstract class MetadataEditService {
Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier);
Future<Map<String, dynamic>> setIptc(AvesEntry entry, List<Map<String, dynamic>>? iptc, {required bool postEditScan});
Future<Map<String, dynamic>> setXmp(AvesEntry entry, AvesXmp? xmp);
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types);
}
@ -85,6 +90,40 @@ class PlatformMetadataEditService implements MetadataEditService {
return {};
}
@override
Future<Map<String, dynamic>> setIptc(AvesEntry entry, List<Map<String, dynamic>>? iptc, {required bool postEditScan}) async {
try {
final result = await platform.invokeMethod('setIptc', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
'iptc': iptc,
'postEditScan': postEditScan,
});
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>> setXmp(AvesEntry entry, AvesXmp? xmp) async {
try {
final result = await platform.invokeMethod('setXmp', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
'xmp': xmp?.xmpString,
'extendedXmp': xmp?.extendedXmpString,
});
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>> removeTypes(AvesEntry entry, Set<MetadataType> types) async {
try {
@ -116,6 +155,8 @@ class PlatformMetadataEditService implements MetadataEditService {
String _toPlatformMetadataType(MetadataType type) {
switch (type) {
case MetadataType.comment:
return 'comment';
case MetadataType.exif:
return 'exif';
case MetadataType.iccProfile:
@ -126,8 +167,6 @@ class PlatformMetadataEditService implements MetadataEditService {
return 'jfif';
case MetadataType.jpegAdobe:
return 'jpeg_adobe';
case MetadataType.jpegComment:
return 'jpeg_comment';
case MetadataType.jpegDucky:
return 'jpeg_ducky';
case MetadataType.photoshopIrb:

View file

@ -1,4 +1,5 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_xmp_iptc.dart';
import 'package:aves/model/metadata/catalog.dart';
import 'package:aves/model/metadata/overlay.dart';
import 'package:aves/model/multipage.dart';
@ -20,6 +21,10 @@ abstract class MetadataFetchService {
Future<PanoramaInfo?> getPanoramaInfo(AvesEntry entry);
Future<List<Map<String, dynamic>>?> getIptc(AvesEntry entry);
Future<AvesXmp?> getXmp(AvesEntry entry);
Future<bool> hasContentResolverProp(String prop);
Future<String?> getContentResolverProp(AvesEntry entry, String prop);
@ -151,6 +156,39 @@ class PlatformMetadataFetchService implements MetadataFetchService {
return null;
}
@override
Future<List<Map<String, dynamic>>?> getIptc(AvesEntry entry) async {
try {
final result = await platform.invokeMethod('getIptc', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
});
if (result != null) return (result as List).cast<Map>().map((fields) => fields.cast<String, dynamic>()).toList();
} on PlatformException catch (e, stack) {
if (!entry.isMissingAtPath) {
await reportService.recordError(e, stack);
}
}
return null;
}
@override
Future<AvesXmp?> getXmp(AvesEntry entry) async {
try {
final result = await platform.invokeMethod('getXmp', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
});
if (result != null) return AvesXmp.fromList((result as List).cast<String>());
} on PlatformException catch (e, stack) {
if (!entry.isMissingAtPath) {
await reportService.recordError(e, stack);
}
}
return null;
}
final Map<String, bool> _contentResolverProps = {};
@override

View file

@ -37,8 +37,6 @@ abstract class StorageService {
Future<bool?> createFile(String name, String mimeType, Uint8List bytes);
Future<Uint8List> openFile(String mimeType);
Future<String?> selectDirectory();
}
class PlatformStorageService implements StorageService {
@ -174,7 +172,8 @@ class PlatformStorageService implements StorageService {
},
cancelOnError: true,
);
return completer.future;
// `await` here, so that `completeError` will be caught below
return await completer.future;
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
@ -198,7 +197,8 @@ class PlatformStorageService implements StorageService {
},
cancelOnError: true,
);
return completer.future;
// `await` here, so that `completeError` will be caught below
return await completer.future;
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
@ -222,7 +222,8 @@ class PlatformStorageService implements StorageService {
},
cancelOnError: true,
);
return completer.future;
// `await` here, so that `completeError` will be caught below
return await completer.future;
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
@ -249,31 +250,11 @@ class PlatformStorageService implements StorageService {
},
cancelOnError: true,
);
return completer.future;
// `await` here, so that `completeError` will be caught below
return await completer.future;
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return Uint8List(0);
}
@override
Future<String?> selectDirectory() async {
try {
final completer = Completer<String?>();
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'selectDirectory',
}).listen(
(data) => completer.complete(data as String?),
onError: completer.completeError,
onDone: () {
if (!completer.isCompleted) completer.complete(null);
},
cancelOnError: true,
);
return completer.future;
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return null;
}
}

View file

@ -46,6 +46,7 @@ class Durations {
// info animations
static const mapStyleSwitchAnimation = Duration(milliseconds: 300);
static const xmpStructArrayCardTransition = Duration(milliseconds: 300);
static const tagEditorTransition = Duration(milliseconds: 200);
// settings animations
static const quickActionListAnimation = Duration(milliseconds: 200);

View file

@ -14,11 +14,13 @@ class AIcons {
static const IconData date = Icons.calendar_today_outlined;
static const IconData disc = Icons.fiber_manual_record;
static const IconData error = Icons.error_outline;
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 language = Icons.translate_outlined;
static const IconData location = Icons.place_outlined;
static const IconData locationOff = Icons.location_off_outlined;
static const IconData mainStorage = Icons.smartphone_outlined;
static const IconData privacy = MdiIcons.shieldAccountOutline;
static const IconData raw = Icons.raw_on_outlined;
static const IconData shooting = Icons.camera_outlined;
@ -33,6 +35,7 @@ class AIcons {
// actions
static const IconData add = Icons.add_circle_outline;
static const IconData addShortcut = Icons.add_to_home_screen_outlined;
static const IconData addTag = MdiIcons.tagPlusOutline;
static const IconData replay10 = Icons.replay_10_outlined;
static const IconData skip10 = Icons.forward_10_outlined;
static const IconData captureFrame = Icons.screenshot_outlined;
@ -66,6 +69,7 @@ class AIcons {
static const IconData print = Icons.print_outlined;
static const IconData refresh = Icons.refresh_outlined;
static const IconData rename = Icons.title_outlined;
static const IconData reset = Icons.restart_alt_outlined;
static const IconData rotateLeft = Icons.rotate_left_outlined;
static const IconData rotateRight = Icons.rotate_right_outlined;
static const IconData rotateScreen = Icons.screen_rotation_outlined;

View file

@ -32,10 +32,10 @@ class AndroidFileUtils {
downloadPath = pContext.join(primaryStorage, 'Download');
moviesPath = pContext.join(primaryStorage, 'Movies');
picturesPath = pContext.join(primaryStorage, 'Pictures');
avesVideoCapturesPath = pContext.join(dcimPath, 'Videocaptures');
avesVideoCapturesPath = pContext.join(dcimPath, 'Video Captures');
videoCapturesPaths = {
// from Samsung
pContext.join(dcimPath, 'Video Captures'),
pContext.join(dcimPath, 'Videocaptures'),
// from Aves
avesVideoCapturesPath,
};

View file

@ -1,26 +1,9 @@
import 'package:flutter/foundation.dart';
// reimplemented ChangeNotifier so that it can be used anywhere, not just as a mixin
class AChangeNotifier implements Listenable {
ObserverList<VoidCallback>? _listeners = ObserverList<VoidCallback>();
@override
void addListener(VoidCallback listener) => _listeners!.add(listener);
@override
void removeListener(VoidCallback listener) => _listeners!.remove(listener);
void dispose() => _listeners = null;
void notifyListeners() {
if (_listeners == null) return;
final localListeners = List<VoidCallback>.from(_listeners!);
for (final listener in localListeners) {
try {
if (_listeners!.contains(listener)) listener();
} catch (error, stack) {
debugPrint('$runtimeType failed to notify listeners with error=$error\n$stack');
}
}
// `ChangeNotifier` wrapper so that it can be used anywhere, not just as a mixin
class AChangeNotifier extends ChangeNotifier {
void notify() {
// why is this protected?
super.notifyListeners();
}
}

View file

@ -132,6 +132,11 @@ class Constants {
license: 'Apache 2.0',
sourceUrl: 'https://github.com/DavBfr/dart_pdf',
),
Dependency(
name: 'Screen Brightness',
license: 'MIT',
sourceUrl: 'https://github.com/aaassseee/screen_brightness',
),
Dependency(
name: 'Shared Preferences',
license: 'BSD 3-Clause',

View file

@ -1,38 +1,16 @@
String formatFilesize(int size, {int round = 2}) {
var divider = 1024;
import 'package:intl/intl.dart';
if (size < divider) return '$size B';
const _kiloDivider = 1024;
const _megaDivider = _kiloDivider * _kiloDivider;
const _gigaDivider = _megaDivider * _kiloDivider;
const _teraDivider = _gigaDivider * _kiloDivider;
if (size < divider * divider && size % divider == 0) {
return '${(size / divider).toStringAsFixed(0)} KB';
}
if (size < divider * divider) {
return '${(size / divider).toStringAsFixed(round)} KB';
}
String formatFileSize(String locale, int size, {int round = 2}) {
if (size < _kiloDivider) return '$size B';
if (size < divider * divider * divider && size % divider == 0) {
return '${(size / (divider * divider)).toStringAsFixed(0)} MB';
}
if (size < divider * divider * divider) {
return '${(size / divider / divider).toStringAsFixed(round)} MB';
}
if (size < divider * divider * divider * divider && size % divider == 0) {
return '${(size / (divider * divider * divider)).toStringAsFixed(0)} GB';
}
if (size < divider * divider * divider * divider) {
return '${(size / divider / divider / divider).toStringAsFixed(round)} GB';
}
if (size < divider * divider * divider * divider * divider && size % divider == 0) {
return '${(size / divider / divider / divider / divider).toStringAsFixed(0)} TB';
}
if (size < divider * divider * divider * divider * divider) {
return '${(size / divider / divider / divider / divider).toStringAsFixed(round)} TB';
}
if (size < divider * divider * divider * divider * divider * divider && size % divider == 0) {
return '${(size / divider / divider / divider / divider / divider).toStringAsFixed(0)} PB';
}
return '${(size / divider / divider / divider / divider / divider).toStringAsFixed(round)} PB';
final formatter = NumberFormat('0${round > 0 ? '.${'0' * round}' : ''}', locale);
if (size < _megaDivider) return '${formatter.format(size / _kiloDivider)} KB';
if (size < _gigaDivider) return '${formatter.format(size / _megaDivider)} MB';
if (size < _teraDivider) return '${formatter.format(size / _gigaDivider)} GB';
return '${formatter.format(size / _teraDivider)} TB';
}

View file

@ -1,33 +1,23 @@
import 'dart:math';
import 'package:aves/utils/math_utils.dart';
import 'package:intl/intl.dart';
import 'package:latlong2/latlong.dart';
class GeoUtils {
static String decimal2sexagesimal(final double degDecimal, final bool minuteSecondPadding, final int secondDecimals) {
List<int> _split(final double value) {
// NumberFormat is necessary to create digit after comma if the value
// has no decimal point (only necessary for browser)
final tmp = NumberFormat('0.0#####').format(roundToPrecision(value, decimals: 10)).split('.');
return <int>[
int.parse(tmp[0]).abs(),
int.parse(tmp[1]),
];
}
final deg = _split(degDecimal)[0];
final minDecimal = (degDecimal.abs() - deg) * 60;
final min = _split(minDecimal)[0];
static String decimal2sexagesimal(
double degDecimal,
bool minuteSecondPadding,
int secondDecimals,
String locale,
) {
final degAbs = degDecimal.abs();
final deg = degAbs.toInt();
final minDecimal = (degAbs - deg) * 60;
final min = minDecimal.toInt();
final sec = (minDecimal - min) * 60;
final secRounded = roundToPrecision(sec, decimals: secondDecimals);
var minText = '$min';
var secText = secRounded.toStringAsFixed(secondDecimals);
if (minuteSecondPadding) {
minText = minText.padLeft(2, '0');
secText = secText.padLeft(secondDecimals > 0 ? 3 + secondDecimals : 2, '0');
}
var minText = NumberFormat('0' * (minuteSecondPadding ? 2 : 1), locale).format(min);
var secText = NumberFormat('${'0' * (minuteSecondPadding ? 2 : 1)}${secondDecimals > 0 ? '.${'0' * secondDecimals}' : ''}', locale).format(sec);
return '$deg° $minText $secText';
}

View file

@ -8,12 +8,13 @@ import 'package:flutter/material.dart';
class AboutCredits extends StatelessWidget {
const AboutCredits({Key? key}) : super(key: key);
static const translations = [
'Русский: D3ZOXY',
];
static const translators = {
'Русский': 'D3ZOXY',
};
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
@ -23,13 +24,13 @@ class AboutCredits extends StatelessWidget {
constraints: const BoxConstraints(minHeight: 48),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: Text(context.l10n.aboutCredits, style: Constants.titleTextStyle),
child: Text(l10n.aboutCredits, style: Constants.titleTextStyle),
),
),
Text.rich(
TextSpan(
children: [
TextSpan(text: context.l10n.aboutCreditsWorldAtlas1),
TextSpan(text: l10n.aboutCreditsWorldAtlas1),
const WidgetSpan(
child: LinkChip(
text: 'World Atlas',
@ -38,17 +39,19 @@ class AboutCredits extends StatelessWidget {
),
alignment: PlaceholderAlignment.middle,
),
TextSpan(text: context.l10n.aboutCreditsWorldAtlas2),
TextSpan(text: l10n.aboutCreditsWorldAtlas2),
],
),
),
const SizedBox(height: 16),
Text(context.l10n.aboutCreditsTranslators),
...translations.map(
(line) => Padding(
padding: const EdgeInsetsDirectional.only(start: 8, top: 8),
child: Text(line),
),
Text(l10n.aboutCreditsTranslators),
...translators.entries.map(
(kv) {
return Padding(
padding: const EdgeInsetsDirectional.only(start: 8, top: 8),
child: Text(l10n.aboutCreditsTranslatorLine(kv.key, kv.value)),
);
},
),
const SizedBox(height: 16),
],

View file

@ -1,7 +1,9 @@
import 'dart:async';
import 'dart:ui';
import 'package:aves/app_flavor.dart';
import 'package:aves/app_mode.dart';
import 'package:aves/model/device.dart';
import 'package:aves/model/settings/accessibility_animations.dart';
import 'package:aves/model/settings/screen_on.dart';
import 'package:aves/model/settings/settings.dart';
@ -161,6 +163,7 @@ class _AvesAppState extends State<AvesApp> {
isRotationLocked: await windowService.isRotationLocked(),
areAnimationsRemoved: await AccessibilityService.areAnimationsRemoved(),
);
await device.init();
FijkLog.setLevel(FijkLogLevel.Warn);
// keep screen on

View file

@ -10,7 +10,6 @@ import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/collection/entry_set_action_delegate.dart';
@ -46,7 +45,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
final List<StreamSubscription> _subscriptions = [];
final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate();
late AnimationController _browseToSelectAnimation;
late Future<bool> _canAddShortcutsLoader;
final ValueNotifier<bool> _isSelectingNotifier = ValueNotifier(false);
final FocusNode _queryBarFocusNode = FocusNode();
late final Listenable _queryFocusRequestNotifier;
@ -69,7 +67,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
vsync: this,
);
_isSelectingNotifier.addListener(_onActivityChange);
_canAddShortcutsLoader = androidAppService.canPinToHomeScreen();
_registerWidget(widget);
WidgetsBinding.instance!.addPostFrameCallback((_) => _onFilterChanged());
}
@ -104,53 +101,46 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
@override
Widget build(BuildContext context) {
final appMode = context.watch<ValueNotifier<AppMode>>().value;
return FutureBuilder<bool>(
future: _canAddShortcutsLoader,
builder: (context, snapshot) {
final canAddShortcuts = snapshot.data ?? false;
return Selector<Selection<AvesEntry>, Tuple2<bool, int>>(
selector: (context, selection) => Tuple2(selection.isSelecting, selection.selectedItems.length),
builder: (context, s, child) {
final isSelecting = s.item1;
final selectedItemCount = s.item2;
_isSelectingNotifier.value = isSelecting;
return AnimatedBuilder(
animation: collection.filterChangeNotifier,
builder: (context, child) {
final removableFilters = appMode != AppMode.pickInternal;
return Selector<Query, bool>(
selector: (context, query) => query.enabled,
builder: (context, queryEnabled, child) {
return SliverAppBar(
leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null,
title: _buildAppBarTitle(isSelecting),
actions: _buildActions(
isSelecting: isSelecting,
selectedItemCount: selectedItemCount,
supportShortcuts: canAddShortcuts,
),
bottom: PreferredSize(
preferredSize: Size.fromHeight(appBarBottomHeight),
child: Column(
children: [
if (showFilterBar)
FilterBar(
filters: collection.filters.where((v) => !(v is QueryFilter && v.live)).toSet(),
removable: removableFilters,
onTap: removableFilters ? collection.removeFilter : null,
),
if (queryEnabled)
EntryQueryBar(
queryNotifier: context.select<Query, ValueNotifier<String>>((query) => query.queryNotifier),
focusNode: _queryBarFocusNode,
)
],
),
),
titleSpacing: 0,
floating: true,
);
},
return Selector<Selection<AvesEntry>, Tuple2<bool, int>>(
selector: (context, selection) => Tuple2(selection.isSelecting, selection.selectedItems.length),
builder: (context, s, child) {
final isSelecting = s.item1;
final selectedItemCount = s.item2;
_isSelectingNotifier.value = isSelecting;
return AnimatedBuilder(
animation: collection.filterChangeNotifier,
builder: (context, child) {
final removableFilters = appMode != AppMode.pickInternal;
return Selector<Query, bool>(
selector: (context, query) => query.enabled,
builder: (context, queryEnabled, child) {
return SliverAppBar(
leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null,
title: _buildAppBarTitle(isSelecting),
actions: _buildActions(
isSelecting: isSelecting,
selectedItemCount: selectedItemCount,
),
bottom: PreferredSize(
preferredSize: Size.fromHeight(appBarBottomHeight),
child: Column(
children: [
if (showFilterBar)
FilterBar(
filters: collection.filters.where((v) => !(v is QueryFilter && v.live)).toSet(),
removable: removableFilters,
onTap: removableFilters ? collection.removeFilter : null,
),
if (queryEnabled)
EntryQueryBar(
queryNotifier: context.select<Query, ValueNotifier<String>>((query) => query.queryNotifier),
focusNode: _queryBarFocusNode,
)
],
),
),
titleSpacing: 0,
floating: true,
);
},
);
@ -214,14 +204,12 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
List<Widget> _buildActions({
required bool isSelecting,
required int selectedItemCount,
required bool supportShortcuts,
}) {
final appMode = context.watch<ValueNotifier<AppMode>>().value;
bool isVisible(EntrySetAction action) => _actionDelegate.isVisible(
action,
appMode: appMode,
isSelecting: isSelecting,
supportShortcuts: supportShortcuts,
sortFactor: collection.sortFactor,
itemCount: collection.entryCount,
selectedItemCount: selectedItemCount,
@ -269,6 +257,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
_buildRotateAndFlipMenuItems(context, canApply: canApply),
...[
EntrySetAction.editDate,
EntrySetAction.editTags,
EntrySetAction.removeMetadata,
].map((action) => _toMenuItem(action, enabled: canApply(action))),
],
@ -295,7 +284,8 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
}
// key is expected by test driver (e.g. 'menu-sort', 'menu-group', 'menu-map')
Key _getActionKey(EntrySetAction action) => Key('menu-${action.toString().substring('EntrySetAction.'.length)}');
// TODO TLAD [dart 2.15] replace `describeEnum()` by `enum.name`
Key _getActionKey(EntrySetAction action) => Key('menu-${describeEnum(action)}');
Widget _toActionButton(EntrySetAction action, {required bool enabled}) {
final onPressed = enabled ? () => _onActionSelected(action) : null;
@ -439,6 +429,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
case EntrySetAction.rotateCW:
case EntrySetAction.flip:
case EntrySetAction.editDate:
case EntrySetAction.editTags:
case EntrySetAction.removeMetadata:
_actionDelegate.onActionSelected(context, action);
break;

View file

@ -1,4 +1,5 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/query.dart';
import 'package:aves/model/selection.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/collection/collection_grid.dart';
@ -8,6 +9,7 @@ import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/common/providers/query_provider.dart';
import 'package:aves/widgets/common/providers/selection_provider.dart';
import 'package:aves/widgets/drawer/app_drawer.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -37,10 +39,12 @@ class _CollectionPageState extends State<CollectionPage> {
@override
Widget build(BuildContext context) {
final liveFilter = collection.filters.firstWhereOrNull((v) => v is QueryFilter && v.live) as QueryFilter?;
return MediaQueryDataProvider(
child: Scaffold(
body: SelectionProvider<AvesEntry>(
child: QueryProvider(
initialQuery: liveFilter?.query,
child: Builder(
builder: (context) => WillPopScope(
onWillPop: () {

View file

@ -3,6 +3,7 @@ import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/utils/file_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/grid/draggable_thumb_label.dart';
import 'package:aves/widgets/common/grid/section_layout.dart';
import 'package:flutter/material.dart';
@ -48,7 +49,7 @@ class CollectionDraggableThumbLabel extends StatelessWidget {
];
case EntrySortFactor.size:
return [
if (entry.sizeBytes != null) formatFilesize(entry.sizeBytes!, round: 0),
if (entry.sizeBytes != null) formatFileSize(context.l10n.localeName, entry.sizeBytes!, round: 0),
];
}
},

View file

@ -4,7 +4,9 @@ import 'dart:io';
import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/entry_set_actions.dart';
import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/device.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_xmp_iptc.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/highlight.dart';
@ -43,7 +45,6 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
EntrySetAction action, {
required AppMode appMode,
required bool isSelecting,
required bool supportShortcuts,
required EntrySortFactor sortFactor,
required int itemCount,
required int selectedItemCount,
@ -66,7 +67,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
case EntrySetAction.toggleTitleSearch:
return !isSelecting;
case EntrySetAction.addShortcut:
return appMode == AppMode.main && !isSelecting && supportShortcuts;
return appMode == AppMode.main && !isSelecting && device.canPinShortcut;
// browsing or selecting
case EntrySetAction.map:
case EntrySetAction.stats:
@ -81,6 +82,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
case EntrySetAction.rotateCW:
case EntrySetAction.flip:
case EntrySetAction.editDate:
case EntrySetAction.editTags:
case EntrySetAction.removeMetadata:
return appMode == AppMode.main && isSelecting;
}
@ -122,6 +124,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
case EntrySetAction.rotateCW:
case EntrySetAction.flip:
case EntrySetAction.editDate:
case EntrySetAction.editTags:
case EntrySetAction.removeMetadata:
return hasSelection;
}
@ -181,6 +184,9 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
case EntrySetAction.editDate:
_editDate(context);
break;
case EntrySetAction.editTags:
_editTags(context);
break;
case EntrySetAction.removeMetadata:
_removeMetadata(context);
break;
@ -399,7 +405,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
BuildContext context,
Selection<AvesEntry> selection,
Set<AvesEntry> todoItems,
Future<bool> Function(AvesEntry entry) op,
Future<Set<EntryDataType>> Function(AvesEntry entry) op,
) async {
final selectionDirs = todoItems.map((e) => e.directory).whereNotNull().toSet();
final todoCount = todoItems.length;
@ -411,8 +417,8 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
showOpReport<ImageOpEvent>(
context: context,
opStream: Stream.fromIterable(todoItems).asyncMap((entry) async {
final success = await op(entry);
return ImageOpEvent(success: success, uri: entry.uri);
final dataTypes = await op(entry);
return ImageOpEvent(success: dataTypes.isNotEmpty, uri: entry.uri);
}).asBroadcastStream(),
itemCount: todoCount,
onDone: (processed) async {
@ -470,6 +476,8 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
);
if (confirmed == null || !confirmed) return null;
// wait for the dialog to hide as applying the change may block the UI
await Future.delayed(Durations.dialogTransitionAnimation);
return supported;
}
@ -497,7 +505,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
final selection = context.read<Selection<AvesEntry>>();
final selectedItems = _getExpandedSelectedItems(selection);
final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditExif);
final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditDate);
if (todoItems == null || todoItems.isEmpty) return;
final modifier = await selectDateModifier(context, todoItems);
@ -506,6 +514,28 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
await _edit(context, selection, todoItems, (entry) => entry.editDate(modifier));
}
Future<void> _editTags(BuildContext context) async {
final selection = context.read<Selection<AvesEntry>>();
final selectedItems = _getExpandedSelectedItems(selection);
final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditTags);
if (todoItems == null || todoItems.isEmpty) return;
final newTagsByEntry = await selectTags(context, todoItems);
if (newTagsByEntry == null) return;
// only process modified items
todoItems.removeWhere((entry) {
final newTags = newTagsByEntry[entry] ?? entry.tags;
final currentTags = entry.tags;
return newTags.length == currentTags.length && newTags.every(currentTags.contains);
});
if (todoItems.isEmpty) return;
await _edit(context, selection, todoItems, (entry) => entry.editTags(newTagsByEntry[entry]!));
}
Future<void> _removeMetadata(BuildContext context) async {
final selection = context.read<Selection<AvesEntry>>();
final selectedItems = _getExpandedSelectedItems(selection);
@ -596,6 +626,9 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
final name = result.item2;
if (name.isEmpty) return;
unawaited(androidAppService.pinToHomeScreen(name, coverEntry, filters: filters));
await androidAppService.pinToHomeScreen(name, coverEntry, filters: filters);
if (!device.showPinShortcutFeedback) {
showFeedback(context, context.l10n.genericSuccessFeedback);
}
}
}

View file

@ -65,8 +65,8 @@ class MonthSectionHeader<T> extends StatelessWidget {
if (date == null) return l10n.sectionUnknown;
if (date.isThisMonth) return l10n.dateThisMonth;
final locale = l10n.localeName;
if (date.isThisYear) return DateFormat.MMMM(locale).format(date);
return DateFormat.yMMMM(locale).format(date);
final localized = date.isThisYear? DateFormat.MMMM(locale).format(date) : DateFormat.yMMMM(locale).format(date);
return '${localized.substring(0, 1).toUpperCase()}${localized.substring(1)}';
}
@override

View file

@ -42,8 +42,6 @@ class _EntryQueryBarState extends State<EntryQueryBar> {
super.dispose();
}
// TODO TLAD focus on text field when enabled (`autofocus` is unusable)
// TODO TLAD lose focus on navigation to viewer?
void _registerWidget(EntryQueryBar widget) {
widget.queryNotifier.addListener(_onQueryChanged);
}

View file

@ -4,8 +4,9 @@ import 'package:aves/model/metadata/enums.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/edit_entry_date_dialog.dart';
import 'package:aves/widgets/dialogs/remove_entry_metadata_dialog.dart';
import 'package:aves/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart';
import 'package:aves/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart';
import 'package:aves/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart';
import 'package:flutter/material.dart';
mixin EntryEditorMixin {
@ -21,6 +22,23 @@ mixin EntryEditorMixin {
return modifier;
}
Future<Map<AvesEntry, Set<String>>?> selectTags(BuildContext context, Set<AvesEntry> entries) async {
if (entries.isEmpty) return null;
final tagsByEntry = Map.fromEntries(entries.map((v) => MapEntry(v, v.tags.toSet())));
await Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: TagEditorPage.routeName),
builder: (context) => TagEditorPage(
tagsByEntry: tagsByEntry,
),
),
);
return tagsByEntry;
}
Future<Set<MetadataType>?> selectMetadataToRemove(BuildContext context, Set<AvesEntry> entries) async {
if (entries.isEmpty) return null;

View file

@ -75,13 +75,15 @@ mixin SizeAwareMixin {
await showDialog(
context: context,
builder: (context) {
final neededSize = formatFilesize(needed);
final freeSize = formatFilesize(free);
final l10n = context.l10n;
final locale = l10n.localeName;
final neededSize = formatFileSize(locale, needed);
final freeSize = formatFileSize(locale, free);
final volume = destinationVolume.getDescription(context);
return AvesDialog(
context: context,
title: context.l10n.notEnoughSpaceDialogTitle,
content: Text(context.l10n.notEnoughSpaceDialogMessage(neededSize, freeSize, volume)),
title: l10n.notEnoughSpaceDialogTitle,
content: Text(l10n.notEnoughSpaceDialogMessage(neededSize, freeSize, volume)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),

View file

@ -1,5 +1,6 @@
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/model/source/events.dart';
import 'package:aves/model/source/source_state.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';

View file

@ -2,7 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:highlight/highlight.dart' show highlight, Node;
// TODO TLAD use the TextSpan getter instead of this modified `HighlightView` when this is fixed: https://github.com/git-touch/highlight/issues/6
// adapted from package `flutter_highlight` v0.7.0 `HighlightView`
// TODO TLAD use the TextSpan getter when this is fixed: https://github.com/git-touch/highlight/issues/6
/// Highlight Flutter Widget
class AvesHighlightView extends StatelessWidget {

View file

@ -4,7 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
/*
This is derived from `draggable_scrollbar` package v0.0.4:
adapted from package `draggable_scrollbar` v0.0.4:
- removed default thumb builders
- allow any `ScrollView` as child
- allow any `Widget` as label content

View file

@ -0,0 +1,395 @@
import 'dart:math' as math;
import 'package:flutter/gestures.dart';
import 'package:vector_math/vector_math_64.dart';
// adapted from Flutter `ScaleGestureRecognizer` in `/gestures/scale.dart`
// ignore_for_file: curly_braces_in_flow_control_structures, deprecated_member_use, unnecessary_null_comparison
/// The possible states of a [ScaleGestureRecognizer].
enum _ScaleState {
/// The recognizer is ready to start recognizing a gesture.
ready,
/// The sequence of pointer events seen thus far is consistent with a scale
/// gesture but the gesture has not been accepted definitively.
possible,
/// The sequence of pointer events seen thus far has been accepted
/// definitively as a scale gesture.
accepted,
/// The sequence of pointer events seen thus far has been accepted
/// definitively as a scale gesture and the pointers established a focal point
/// and initial scale.
started,
}
////////////////////////////////////////////////////////////////////////////////
bool _isFlingGesture(Velocity velocity) {
assert(velocity != null);
final double speedSquared = velocity.pixelsPerSecond.distanceSquared;
return speedSquared > kMinFlingVelocity * kMinFlingVelocity;
}
/// Defines a line between two pointers on screen.
///
/// [_LineBetweenPointers] is an abstraction of a line between two pointers in
/// contact with the screen. Used to track the rotation of a scale gesture.
class _LineBetweenPointers {
/// Creates a [_LineBetweenPointers]. None of the [pointerStartLocation], [pointerStartId]
/// [pointerEndLocation] and [pointerEndId] must be null. [pointerStartId] and [pointerEndId]
/// should be different.
_LineBetweenPointers({
this.pointerStartLocation = Offset.zero,
this.pointerStartId = 0,
this.pointerEndLocation = Offset.zero,
this.pointerEndId = 1,
}) : assert(pointerStartLocation != null && pointerEndLocation != null),
assert(pointerStartId != null && pointerEndId != null),
assert(pointerStartId != pointerEndId);
// The location and the id of the pointer that marks the start of the line.
final Offset pointerStartLocation;
final int pointerStartId;
// The location and the id of the pointer that marks the end of the line.
final Offset pointerEndLocation;
final int pointerEndId;
}
/// Recognizes a scale gesture.
///
/// [ScaleGestureRecognizer] tracks the pointers in contact with the screen and
/// calculates their focal point, indicated scale, and rotation. When a focal
/// pointer is established, the recognizer calls [onStart]. As the focal point,
/// scale, rotation change, the recognizer calls [onUpdate]. When the pointers
/// are no longer in contact with the screen, the recognizer calls [onEnd].
class EagerScaleGestureRecognizer extends OneSequenceGestureRecognizer {
/// Create a gesture recognizer for interactions intended for scaling content.
///
/// {@macro flutter.gestures.GestureRecognizer.supportedDevices}
EagerScaleGestureRecognizer({
Object? debugOwner,
@Deprecated(
'Migrate to supportedDevices. '
'This feature was deprecated after v2.3.0-1.0.pre.',
)
PointerDeviceKind? kind,
Set<PointerDeviceKind>? supportedDevices,
this.dragStartBehavior = DragStartBehavior.down,
}) : assert(dragStartBehavior != null),
super(
debugOwner: debugOwner,
kind: kind,
supportedDevices: supportedDevices,
);
/// Determines what point is used as the starting point in all calculations
/// involving this gesture.
///
/// When set to [DragStartBehavior.down], the scale is calculated starting
/// from the position where the pointer first contacted the screen.
///
/// When set to [DragStartBehavior.start], the scale is calculated starting
/// from the position where the scale gesture began. The scale gesture may
/// begin after the time that the pointer first contacted the screen if there
/// are multiple listeners competing for the gesture. In that case, the
/// gesture arena waits to determine whether or not the gesture is a scale
/// gesture before giving the gesture to this GestureRecognizer. This happens
/// in the case of nested GestureDetectors, for example.
///
/// Defaults to [DragStartBehavior.down].
///
/// See also:
///
/// * https://flutter.dev/docs/development/ui/advanced/gestures#gesture-disambiguation,
/// which provides more information about the gesture arena.
DragStartBehavior dragStartBehavior;
/// The pointers in contact with the screen have established a focal point and
/// initial scale of 1.0.
///
/// This won't be called until the gesture arena has determined that this
/// GestureRecognizer has won the gesture.
///
/// See also:
///
/// * https://flutter.dev/docs/development/ui/advanced/gestures#gesture-disambiguation,
/// which provides more information about the gesture arena.
GestureScaleStartCallback? onStart;
/// The pointers in contact with the screen have indicated a new focal point
/// and/or scale.
GestureScaleUpdateCallback? onUpdate;
/// The pointers are no longer in contact with the screen.
GestureScaleEndCallback? onEnd;
_ScaleState _state = _ScaleState.ready;
Matrix4? _lastTransform;
late Offset _initialFocalPoint;
late Offset _currentFocalPoint;
late double _initialSpan;
late double _currentSpan;
late double _initialHorizontalSpan;
late double _currentHorizontalSpan;
late double _initialVerticalSpan;
late double _currentVerticalSpan;
_LineBetweenPointers? _initialLine;
_LineBetweenPointers? _currentLine;
late Map<int, Offset> _pointerLocations;
late List<int> _pointerQueue; // A queue to sort pointers in order of entrance
final Map<int, VelocityTracker> _velocityTrackers = <int, VelocityTracker>{};
double get _scaleFactor => _initialSpan > 0.0 ? _currentSpan / _initialSpan : 1.0;
double get _horizontalScaleFactor => _initialHorizontalSpan > 0.0 ? _currentHorizontalSpan / _initialHorizontalSpan : 1.0;
double get _verticalScaleFactor => _initialVerticalSpan > 0.0 ? _currentVerticalSpan / _initialVerticalSpan : 1.0;
double _computeRotationFactor() {
if (_initialLine == null || _currentLine == null) {
return 0.0;
}
final double fx = _initialLine!.pointerStartLocation.dx;
final double fy = _initialLine!.pointerStartLocation.dy;
final double sx = _initialLine!.pointerEndLocation.dx;
final double sy = _initialLine!.pointerEndLocation.dy;
final double nfx = _currentLine!.pointerStartLocation.dx;
final double nfy = _currentLine!.pointerStartLocation.dy;
final double nsx = _currentLine!.pointerEndLocation.dx;
final double nsy = _currentLine!.pointerEndLocation.dy;
final double angle1 = math.atan2(fy - sy, fx - sx);
final double angle2 = math.atan2(nfy - nsy, nfx - nsx);
return angle2 - angle1;
}
@override
void addAllowedPointer(PointerDownEvent event) {
super.addAllowedPointer(event);
_velocityTrackers[event.pointer] = VelocityTracker.withKind(event.kind);
if (_state == _ScaleState.ready) {
_state = _ScaleState.possible;
_initialSpan = 0.0;
_currentSpan = 0.0;
_initialHorizontalSpan = 0.0;
_currentHorizontalSpan = 0.0;
_initialVerticalSpan = 0.0;
_currentVerticalSpan = 0.0;
_pointerLocations = <int, Offset>{};
_pointerQueue = <int>[];
}
}
@override
void handleEvent(PointerEvent event) {
assert(_state != _ScaleState.ready);
bool didChangeConfiguration = false;
bool shouldStartIfAccepted = false;
if (event is PointerMoveEvent) {
final VelocityTracker tracker = _velocityTrackers[event.pointer]!;
if (!event.synthesized) tracker.addPosition(event.timeStamp, event.position);
_pointerLocations[event.pointer] = event.position;
shouldStartIfAccepted = true;
_lastTransform = event.transform;
} else if (event is PointerDownEvent) {
_pointerLocations[event.pointer] = event.position;
_pointerQueue.add(event.pointer);
didChangeConfiguration = true;
shouldStartIfAccepted = true;
_lastTransform = event.transform;
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
_pointerLocations.remove(event.pointer);
_pointerQueue.remove(event.pointer);
didChangeConfiguration = true;
_lastTransform = event.transform;
}
_updateLines();
_update();
if (!didChangeConfiguration || _reconfigure(event.pointer)) _advanceStateMachine(shouldStartIfAccepted, event.kind);
stopTrackingIfPointerNoLongerDown(event);
}
void _update() {
final int count = _pointerLocations.keys.length;
// Compute the focal point
Offset focalPoint = Offset.zero;
for (final int pointer in _pointerLocations.keys) focalPoint += _pointerLocations[pointer]!;
_currentFocalPoint = count > 0 ? focalPoint / count.toDouble() : Offset.zero;
// Span is the average deviation from focal point. Horizontal and vertical
// spans are the average deviations from the focal point's horizontal and
// vertical coordinates, respectively.
double totalDeviation = 0.0;
double totalHorizontalDeviation = 0.0;
double totalVerticalDeviation = 0.0;
for (final int pointer in _pointerLocations.keys) {
totalDeviation += (_currentFocalPoint - _pointerLocations[pointer]!).distance;
totalHorizontalDeviation += (_currentFocalPoint.dx - _pointerLocations[pointer]!.dx).abs();
totalVerticalDeviation += (_currentFocalPoint.dy - _pointerLocations[pointer]!.dy).abs();
}
_currentSpan = count > 0 ? totalDeviation / count : 0.0;
_currentHorizontalSpan = count > 0 ? totalHorizontalDeviation / count : 0.0;
_currentVerticalSpan = count > 0 ? totalVerticalDeviation / count : 0.0;
}
/// Updates [_initialLine] and [_currentLine] accordingly to the situation of
/// the registered pointers.
void _updateLines() {
final int count = _pointerLocations.keys.length;
assert(_pointerQueue.length >= count);
/// In case of just one pointer registered, reconfigure [_initialLine]
if (count < 2) {
_initialLine = _currentLine;
} else if (_initialLine != null && _initialLine!.pointerStartId == _pointerQueue[0] && _initialLine!.pointerEndId == _pointerQueue[1]) {
/// Rotation updated, set the [_currentLine]
_currentLine = _LineBetweenPointers(
pointerStartId: _pointerQueue[0],
pointerStartLocation: _pointerLocations[_pointerQueue[0]]!,
pointerEndId: _pointerQueue[1],
pointerEndLocation: _pointerLocations[_pointerQueue[1]]!,
);
} else {
/// A new rotation process is on the way, set the [_initialLine]
_initialLine = _LineBetweenPointers(
pointerStartId: _pointerQueue[0],
pointerStartLocation: _pointerLocations[_pointerQueue[0]]!,
pointerEndId: _pointerQueue[1],
pointerEndLocation: _pointerLocations[_pointerQueue[1]]!,
);
_currentLine = _initialLine;
}
}
bool _reconfigure(int pointer) {
_initialFocalPoint = _currentFocalPoint;
_initialSpan = _currentSpan;
_initialLine = _currentLine;
_initialHorizontalSpan = _currentHorizontalSpan;
_initialVerticalSpan = _currentVerticalSpan;
if (_state == _ScaleState.started) {
if (onEnd != null) {
final VelocityTracker tracker = _velocityTrackers[pointer]!;
Velocity velocity = tracker.getVelocity();
if (_isFlingGesture(velocity)) {
final Offset pixelsPerSecond = velocity.pixelsPerSecond;
if (pixelsPerSecond.distanceSquared > kMaxFlingVelocity * kMaxFlingVelocity) velocity = Velocity(pixelsPerSecond: (pixelsPerSecond / pixelsPerSecond.distance) * kMaxFlingVelocity);
invokeCallback<void>('onEnd', () => onEnd!(ScaleEndDetails(velocity: velocity, pointerCount: _pointerQueue.length)));
} else {
invokeCallback<void>('onEnd', () => onEnd!(ScaleEndDetails(velocity: Velocity.zero, pointerCount: _pointerQueue.length)));
}
}
_state = _ScaleState.accepted;
return false;
}
return true;
}
void _advanceStateMachine(bool shouldStartIfAccepted, PointerDeviceKind pointerDeviceKind) {
if (_state == _ScaleState.ready) _state = _ScaleState.possible;
// TLAD insert start
if (_pointerQueue.length == 2) {
resolve(GestureDisposition.accepted);
}
// TLAD insert end
if (_state == _ScaleState.possible) {
final double spanDelta = (_currentSpan - _initialSpan).abs();
final double focalPointDelta = (_currentFocalPoint - _initialFocalPoint).distance;
if (spanDelta > computeScaleSlop(pointerDeviceKind) || focalPointDelta > computePanSlop(pointerDeviceKind)) resolve(GestureDisposition.accepted);
} else if (_state.index >= _ScaleState.accepted.index) {
resolve(GestureDisposition.accepted);
}
if (_state == _ScaleState.accepted && shouldStartIfAccepted) {
_state = _ScaleState.started;
_dispatchOnStartCallbackIfNeeded();
}
if (_state == _ScaleState.started && onUpdate != null)
invokeCallback<void>('onUpdate', () {
onUpdate!(ScaleUpdateDetails(
scale: _scaleFactor,
horizontalScale: _horizontalScaleFactor,
verticalScale: _verticalScaleFactor,
focalPoint: _currentFocalPoint,
localFocalPoint: PointerEvent.transformPosition(_lastTransform, _currentFocalPoint),
rotation: _computeRotationFactor(),
pointerCount: _pointerQueue.length,
delta: _currentFocalPoint - _initialFocalPoint,
));
});
}
void _dispatchOnStartCallbackIfNeeded() {
assert(_state == _ScaleState.started);
if (onStart != null)
invokeCallback<void>('onStart', () {
onStart!(ScaleStartDetails(
focalPoint: _currentFocalPoint,
localFocalPoint: PointerEvent.transformPosition(_lastTransform, _currentFocalPoint),
pointerCount: _pointerQueue.length,
));
});
}
@override
void acceptGesture(int pointer) {
if (_state == _ScaleState.possible) {
_state = _ScaleState.started;
_dispatchOnStartCallbackIfNeeded();
if (dragStartBehavior == DragStartBehavior.start) {
_initialFocalPoint = _currentFocalPoint;
_initialSpan = _currentSpan;
_initialLine = _currentLine;
_initialHorizontalSpan = _currentHorizontalSpan;
_initialVerticalSpan = _currentVerticalSpan;
}
}
}
@override
void rejectGesture(int pointer) {
stopTrackingPointer(pointer);
}
@override
void didStopTrackingLastPointer(int pointer) {
switch (_state) {
case _ScaleState.possible:
resolve(GestureDisposition.rejected);
break;
case _ScaleState.ready:
assert(false); // We should have not seen a pointer yet
break;
case _ScaleState.accepted:
break;
case _ScaleState.started:
assert(false); // We should be in the accepted state when user is done
break;
}
_state = _ScaleState.ready;
}
@override
void dispose() {
_velocityTrackers.clear();
super.dispose();
}
@override
String get debugDescription => 'scale';
}

View file

@ -9,16 +9,20 @@ class ExpandableFilterRow extends StatelessWidget {
final String? title;
final Iterable<CollectionFilter> filters;
final ValueNotifier<String?> expandedNotifier;
final bool showGenericIcon;
final HeroType Function(CollectionFilter filter)? heroTypeBuilder;
final FilterCallback onTap;
final OffsetFilterCallback? onLongPress;
const ExpandableFilterRow({
Key? key,
this.title,
required this.filters,
required this.expandedNotifier,
this.showGenericIcon = true,
this.heroTypeBuilder,
required this.onTap,
required this.onLongPress,
}) : super(key: key);
static const double horizontalPadding = 8;
@ -109,8 +113,10 @@ class ExpandableFilterRow extends StatelessWidget {
// key `album-{path}` is expected by test driver
key: Key(filter.key),
filter: filter,
showGenericIcon: showGenericIcon,
heroType: heroTypeBuilder?.call(filter) ?? HeroType.onTap,
onTap: onTap,
onLongPress: onLongPress,
);
}
}

View file

@ -3,7 +3,7 @@ import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
// adapted from `RawImage`, `paintImage()` from `DecorationImagePainter`, etc.
// adapted from Flutter `RawImage`, `paintImage()` from `DecorationImagePainter`, etc.
// to transition between 2 different fits during hero animation:
// - BoxFit.cover at t=0
// - BoxFit.contain at t=1
@ -190,7 +190,8 @@ class _TransitionImagePainter extends CustomPainter {
Offset.zero & inputSize,
);
if (background != null) {
canvas.drawRect(destinationRect, Paint()..color = background!);
// deflate to avoid background artifact around opaque image
canvas.drawRect(destinationRect.deflate(1), Paint()..color = background!);
}
canvas.drawImageRect(image!, sourceRect, destinationRect, paint);
}

View file

@ -12,7 +12,7 @@ import 'package:provider/provider.dart';
// With the multiple `SliverGrid` solution, thumbnails at the beginning of each sections are built even though they are offscreen
// because of `RenderSliverMultiBoxAdaptor.addInitialChild` called by `RenderSliverGrid.performLayout` (line 547), as of Flutter v1.17.0.
// cf https://github.com/flutter/flutter/issues/49027
// adapted from `RenderSliverFixedExtentBoxAdaptor`
// adapted from Flutter `RenderSliverFixedExtentBoxAdaptor` in `/rendering/sliver_fixed_extent_list.dart`
class SectionedListSliver<T> extends StatelessWidget {
const SectionedListSliver({Key? key}) : super(key: key);

View file

@ -18,7 +18,7 @@ import 'package:provider/provider.dart';
typedef FilterCallback = void Function(CollectionFilter filter);
typedef OffsetFilterCallback = void Function(BuildContext context, CollectionFilter filter, Offset tapPosition);
enum HeroType { always, onTap }
enum HeroType { always, onTap, never }
@immutable
class AvesFilterDecoration {
@ -40,7 +40,7 @@ class AvesFilterChip extends StatefulWidget {
final bool removable, showGenericIcon, useFilterColor;
final AvesFilterDecoration? decoration;
final String? banner;
final Widget? details;
final Widget? leadingOverride, details;
final double padding, maxWidth;
final HeroType heroType;
final FilterCallback? onTap;
@ -64,6 +64,7 @@ class AvesFilterChip extends StatefulWidget {
this.useFilterColor = true,
this.decoration,
this.banner,
this.leadingOverride,
this.details,
this.padding = 6.0,
this.maxWidth = defaultMaxChipWidth,
@ -162,7 +163,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
final chipBackground = Theme.of(context).scaffoldBackgroundColor;
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
final iconSize = AvesFilterChip.iconSize * textScaleFactor;
final leading = filter.iconBuilder(context, iconSize, showGenericIcon: widget.showGenericIcon);
final leading = widget.leadingOverride ?? filter.iconBuilder(context, iconSize, showGenericIcon: widget.showGenericIcon);
final trailing = widget.removable ? Icon(AIcons.clear, size: iconSize) : null;
final decoration = widget.decoration;

View file

@ -18,13 +18,14 @@ class MagnifierController {
late ScaleStateChange _currentScaleState, previousScaleState;
MagnifierController({
Offset initialPosition = Offset.zero,
MagnifierState? initialState,
}) : super() {
initial = MagnifierState(
position: initialPosition,
scale: null,
source: ChangeSource.internal,
);
initial = initialState ??
const MagnifierState(
position: Offset.zero,
scale: null,
source: ChangeSource.internal,
);
previousState = initial;
_currentState = initial;
_setState(initial);

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