Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2021-09-29 11:55:01 +09:00
commit da4d79a6f8
223 changed files with 4106 additions and 1950 deletions

View file

@ -15,7 +15,7 @@ jobs:
- uses: subosito/flutter-action@v1 - uses: subosito/flutter-action@v1
with: with:
channel: stable channel: stable
flutter-version: '2.2.3' flutter-version: '2.5.1'
- name: Clone the repository. - name: Clone the repository.
uses: actions/checkout@v2 uses: actions/checkout@v2

View file

@ -17,7 +17,7 @@ jobs:
- uses: subosito/flutter-action@v1 - uses: subosito/flutter-action@v1
with: with:
channel: stable channel: stable
flutter-version: '2.2.3' flutter-version: '2.5.1'
# Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1): # Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1):
# https://issuetracker.google.com/issues/144111441 # https://issuetracker.google.com/issues/144111441
@ -50,8 +50,8 @@ jobs:
echo "${{ secrets.KEY_JKS }}" > release.keystore.asc echo "${{ secrets.KEY_JKS }}" > release.keystore.asc
gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE
rm release.keystore.asc rm release.keystore.asc
flutter build apk --bundle-sksl-path shaders_2.2.3.sksl.json flutter build apk --bundle-sksl-path shaders_2.5.1.sksl.json
flutter build appbundle --bundle-sksl-path shaders_2.2.3.sksl.json flutter build appbundle --bundle-sksl-path shaders_2.5.1.sksl.json
rm $AVES_STORE_FILE rm $AVES_STORE_FILE
env: env:
AVES_STORE_FILE: ${{ github.workspace }}/key.jks AVES_STORE_FILE: ${{ github.workspace }}/key.jks

View file

@ -3,6 +3,21 @@ All notable changes to this project will be documented in this file.
## [Unreleased] ## [Unreleased]
## [v1.5.2] - 2021-09-29
### Added
- Map: show items for bounds, open items in viewer, tap gesture to toggle fullscreen
- Info: remove metadata (Exif, XMP, etc.)
- Accessibility: support "time to take action" and "remove animations" settings
### Changed
- upgraded Flutter to stable v2.5.1
- faster collection loading when launching the app
- Collection: changed color & scale of thumbnail icons to match text
- Albums / Countries / Tags: changed layout, with label below cover
### Fixed
- album bookmarks & pins were reset when rescanning items
## [v1.5.1] - 2021-09-08 ## [v1.5.1] - 2021-09-08
### Added ### Added
- About: bug reporting instructions - About: bug reporting instructions
@ -71,7 +86,7 @@ All notable changes to this project will be documented in this file.
### Changed ### Changed
- improved SVG support with a different rendering engine - improved SVG support with a different rendering engine
- changed logo - changed logo
- upgraded flutter to stable v2.2.3 - upgraded Flutter to stable v2.2.3
- migrated to sound null safety - migrated to sound null safety
- viewer: parallax effect when scrolling - viewer: parallax effect when scrolling

View file

@ -120,10 +120,10 @@ dependencies {
implementation 'com.caverock:androidsvg-aar:1.4' implementation 'com.caverock:androidsvg-aar:1.4'
implementation 'com.commonsware.cwac:document:0.4.1' implementation 'com.commonsware.cwac:document:0.4.1'
implementation 'com.drewnoakes:metadata-extractor:2.16.0' implementation 'com.drewnoakes:metadata-extractor:2.16.0'
// https://jitpack.io/com/github/deckerst/Android-TiffBitmapFactory/**********/build.log // https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a' // forked, built by JitPack implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a' // forked, built by JitPack
// https://jitpack.io/com/github/deckerst/pixymeta-android/**********/build.log // https://jitpack.io/p/deckerst/pixymeta-android
implementation 'com.github.deckerst:pixymeta-android:e4e50da939' // forked, built by JitPack implementation 'com.github.deckerst:pixymeta-android:082ed1dafc' // forked, built by JitPack
implementation 'com.github.bumptech.glide:glide:4.12.0' implementation 'com.github.bumptech.glide:glide:4.12.0'
kapt 'androidx.annotation:annotation:1.2.0' kapt 'androidx.annotation:annotation:1.2.0'

View file

@ -52,18 +52,18 @@ class MainActivity : FlutterActivity() {
val messenger = flutterEngine!!.dartExecutor.binaryMessenger val messenger = flutterEngine!!.dartExecutor.binaryMessenger
// dart -> platform -> dart // dart -> platform -> dart
MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this))
MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this)) MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
MethodChannel(messenger, AppShortcutHandler.CHANNEL).setMethodCallHandler(AppShortcutHandler(this))
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this)) MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler()) MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler())
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this)) MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this))
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this)) MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
MethodChannel(messenger, GlobalSearchHandler.CHANNEL).setMethodCallHandler(GlobalSearchHandler(this)) MethodChannel(messenger, GlobalSearchHandler.CHANNEL).setMethodCallHandler(GlobalSearchHandler(this))
MethodChannel(messenger, MediaFileHandler.CHANNEL).setMethodCallHandler(MediaFileHandler(this))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this)) MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(MetadataHandler(this)) MethodChannel(messenger, MetadataEditHandler.CHANNEL).setMethodCallHandler(MetadataEditHandler(this))
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this)) MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
MethodChannel(messenger, TimeHandler.CHANNEL).setMethodCallHandler(TimeHandler())
MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(WindowHandler(this)) MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(WindowHandler(this))
// result streaming: dart -> platform ->->-> dart // result streaming: dart -> platform ->->-> dart

View file

@ -118,13 +118,13 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid
override fun getType(uri: Uri): String? = null override fun getType(uri: Uri): String? = null
override fun insert(uri: Uri, values: ContentValues?): Uri = override fun insert(uri: Uri, values: ContentValues?): Uri =
throw UnsupportedOperationException() throw UnsupportedOperationException("`insert` is not supported by this content provider")
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int = override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int =
throw UnsupportedOperationException() throw UnsupportedOperationException("`delete` is not supported by this content provider")
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>?): Int = override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>?): Int =
throw UnsupportedOperationException() throw UnsupportedOperationException("`update` is not supported by this content provider")
companion object { companion object {
private val LOG_TAG = LogUtils.createTag<SearchSuggestionsProvider>() private val LOG_TAG = LogUtils.createTag<SearchSuggestionsProvider>()

View file

@ -0,0 +1,79 @@
package deckers.thibault.aves.channel.calls
import android.app.Activity
import android.content.Context
import android.os.Build
import android.provider.Settings
import android.util.Log
import android.view.accessibility.AccessibilityManager
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.utils.LogUtils
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
class AccessibilityHandler(private val activity: Activity) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"areAnimationsRemoved" -> safe(call, result, ::areAnimationsRemoved)
"hasRecommendedTimeouts" -> safe(call, result, ::hasRecommendedTimeouts)
"getRecommendedTimeoutMillis" -> safe(call, result, ::getRecommendedTimeoutMillis)
else -> result.notImplemented()
}
}
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)
}
result.success(removed)
}
private fun hasRecommendedTimeouts(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
}
private fun getRecommendedTimeoutMillis(call: MethodCall, result: MethodChannel.Result) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
result.error("getRecommendedTimeoutMillis-sdk", "unsupported SDK version=${Build.VERSION.SDK_INT}", null)
return
}
val originalTimeoutMillis = call.argument<Int>("originalTimeoutMillis")
val content = call.argument<List<String>>("content")
if (originalTimeoutMillis == null || content == null) {
result.error("getRecommendedTimeoutMillis-args", "failed because of missing arguments", null)
return
}
var uiContentFlags = 0
content.forEach {
uiContentFlags = when (it) {
"controls" -> uiContentFlags or AccessibilityManager.FLAG_CONTENT_CONTROLS
"icons" -> uiContentFlags or AccessibilityManager.FLAG_CONTENT_ICONS
"text" -> uiContentFlags or AccessibilityManager.FLAG_CONTENT_TEXT
else -> {
result.error("getRecommendedTimeoutMillis-flag", "unsupported UI content flag=$it", null)
return
}
}
}
val am = activity.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager
if (am == null) {
result.error("getRecommendedTimeoutMillis-service", "failed to get accessibility manager", null)
return
}
val millis = am.getRecommendedTimeoutMillis(originalTimeoutMillis, uiContentFlags)
result.success(millis)
}
companion object {
private val LOG_TAG = LogUtils.createTag<AccessibilityHandler>()
const val CHANNEL = "deckers.thibault/aves/accessibility"
}
}

View file

@ -4,17 +4,25 @@ import android.content.*
import android.content.pm.ApplicationInfo import android.content.pm.ApplicationInfo
import android.content.res.Configuration import android.content.res.Configuration
import android.content.res.Resources import android.content.res.Resources
import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.MainActivity
import deckers.thibault.aves.R
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
@ -39,6 +47,8 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
"openMap" -> safe(call, result, ::openMap) "openMap" -> safe(call, result, ::openMap)
"setAs" -> safe(call, result, ::setAs) "setAs" -> safe(call, result, ::setAs)
"share" -> safe(call, result, ::share) "share" -> safe(call, result, ::share)
"canPin" -> safe(call, result, ::canPin)
"pin" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::pin) }
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
@ -92,7 +102,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
private suspend fun getAppIcon(call: MethodCall, result: MethodChannel.Result) { private suspend fun getAppIcon(call: MethodCall, result: MethodChannel.Result) {
val packageName = call.argument<String>("packageName") val packageName = call.argument<String>("packageName")
val sizeDip = call.argument<Double>("sizeDip") val sizeDip = call.argument<Number>("sizeDip")?.toDouble()
if (packageName == null || sizeDip == null) { if (packageName == null || sizeDip == null) {
result.error("getAppIcon-args", "failed because of missing arguments", null) result.error("getAppIcon-args", "failed because of missing arguments", null)
return return
@ -307,6 +317,64 @@ 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) {
val label = call.argument<String>("label")
val iconBytes = call.argument<ByteArray>("iconBytes")
val filters = call.argument<List<String>>("filters")
if (label == null || filters == null) {
result.error("pin-args", "failed because of missing arguments", null)
return
}
if (!isPinSupported()) {
result.error("pin-unsupported", "failed because the launcher does not support pinning shortcuts", null)
return
}
var icon: IconCompat? = null
if (iconBytes?.isNotEmpty() == true) {
var bitmap = BitmapFactory.decodeByteArray(iconBytes, 0, iconBytes.size)
bitmap = BitmapUtils.centerSquareCrop(context, bitmap, 256)
if (bitmap != null) {
// adaptive, so the bitmap is used as background and covers the whole icon
icon = IconCompat.createWithAdaptiveBitmap(bitmap)
}
}
if (icon == null) {
// shortcut adaptive icons are placed in `mipmap`, not `drawable`,
// so that foreground is rendered at the intended scale
val supportAdaptiveIcon = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
icon = IconCompat.createWithResource(context, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_collection else R.drawable.ic_shortcut_collection)
}
val intent = Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
.putExtra("page", "/collection")
.putExtra("filters", 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))
// multiple shortcuts sharing the same ID cannot be created with different labels or icons
// so we provide a unique ID for each one, and let the user manage duplicates (i.e. same filter set), if any
val shortcut = ShortcutInfoCompat.Builder(context, UUID.randomUUID().toString())
.setShortLabel(label)
.setIcon(icon)
.setIntent(intent)
.build()
ShortcutManagerCompat.requestPinShortcut(context, shortcut, null)
result.success(true)
}
companion object { companion object {
private val LOG_TAG = LogUtils.createTag<AppAdapterHandler>() private val LOG_TAG = LogUtils.createTag<AppAdapterHandler>()
const val CHANNEL = "deckers.thibault/aves/app" const val CHANNEL = "deckers.thibault/aves/app"

View file

@ -1,90 +0,0 @@
package deckers.thibault.aves.channel.calls
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.os.Build
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import deckers.thibault.aves.MainActivity
import deckers.thibault.aves.R
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.utils.BitmapUtils.centerSquareCrop
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.util.*
class AppShortcutHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"canPin" -> safe(call, result, ::canPin)
"pin" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::pin) }
else -> result.notImplemented()
}
}
private fun isSupported() = ShortcutManagerCompat.isRequestPinShortcutSupported(context)
private fun canPin(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
result.success(isSupported())
}
private fun pin(call: MethodCall, result: MethodChannel.Result) {
val label = call.argument<String>("label")
val iconBytes = call.argument<ByteArray>("iconBytes")
val filters = call.argument<List<String>>("filters")
if (label == null || filters == null) {
result.error("pin-args", "failed because of missing arguments", null)
return
}
if (!isSupported()) {
result.error("pin-unsupported", "failed because the launcher does not support pinning shortcuts", null)
return
}
var icon: IconCompat? = null
if (iconBytes?.isNotEmpty() == true) {
var bitmap = BitmapFactory.decodeByteArray(iconBytes, 0, iconBytes.size)
bitmap = centerSquareCrop(context, bitmap, 256)
if (bitmap != null) {
// adaptive, so the bitmap is used as background and covers the whole icon
icon = IconCompat.createWithAdaptiveBitmap(bitmap)
}
}
if (icon == null) {
// shortcut adaptive icons are placed in `mipmap`, not `drawable`,
// so that foreground is rendered at the intended scale
val supportAdaptiveIcon = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
icon = IconCompat.createWithResource(context, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_collection else R.drawable.ic_shortcut_collection)
}
val intent = Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
.putExtra("page", "/collection")
.putExtra("filters", 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))
// multiple shortcuts sharing the same ID cannot be created with different labels or icons
// so we provide a unique ID for each one, and let the user manage duplicates (i.e. same filter set), if any
val shortcut = ShortcutInfoCompat.Builder(context, UUID.randomUUID().toString())
.setShortLabel(label)
.setIcon(icon)
.setIntent(intent)
.build()
ShortcutManagerCompat.requestPinShortcut(context, shortcut, null)
result.success(true)
}
companion object {
const val CHANNEL = "deckers.thibault/aves/shortcut"
}
}

View file

@ -20,10 +20,10 @@ import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.metadata.PixyMetaHelper import deckers.thibault.aves.metadata.PixyMetaHelper
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface
import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor
import deckers.thibault.aves.utils.MimeTypes.canReadWithPixyMeta
import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
import deckers.thibault.aves.utils.MimeTypes.isSupportedByPixyMeta
import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.UriUtils.tryParseId import deckers.thibault.aves.utils.UriUtils.tryParseId
@ -60,31 +60,6 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
} }
} }
private fun getPixyMetadata(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("getPixyMetadata-args", "failed because of missing arguments", null)
return
}
if (!isSupportedByPixyMeta(mimeType)) {
result.error("getPixyMetadata-unsupported", "PixyMeta does not support mimeType=$mimeType", null)
return
}
val metadataMap = HashMap<String, String>()
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
metadataMap.putAll(PixyMetaHelper.describe(input))
}
} catch (e: Exception) {
result.error("getPixyMetadata-exception", e.message, e.stackTraceToString())
return
}
result.success(metadataMap)
}
private fun getContextDirs(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { private fun getContextDirs(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
val dirs = hashMapOf( val dirs = hashMapOf(
"cacheDir" to context.cacheDir, "cacheDir" to context.cacheDir,
@ -206,7 +181,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
} }
val metadataMap = HashMap<String, String?>() val metadataMap = HashMap<String, String?>()
if (isSupportedByExifInterface(mimeType, strict = false)) { if (canReadWithExifInterface(mimeType, strict = false)) {
try { try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val exif = ExifInterface(input) val exif = ExifInterface(input)
@ -258,7 +233,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
} }
val metadataMap = HashMap<String, String>() val metadataMap = HashMap<String, String>()
if (isSupportedByMetadataExtractor(mimeType)) { if (canReadWithMetadataExtractor(mimeType)) {
try { try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input) val metadata = ImageMetadataReader.readMetadata(input)
@ -290,6 +265,28 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
result.success(metadataMap) result.success(metadataMap)
} }
private fun getPixyMetadata(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("getPixyMetadata-args", "failed because of missing arguments", null)
return
}
val metadataMap = HashMap<String, String>()
if (canReadWithPixyMeta(mimeType)) {
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
metadataMap.putAll(PixyMetaHelper.describe(input))
}
} catch (e: Exception) {
result.error("getPixyMetadata-exception", e.message, e.stackTraceToString())
return
}
}
result.success(metadataMap)
}
private fun getTiffStructure(call: MethodCall, result: MethodChannel.Result) { private fun getTiffStructure(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) { if (uri == null) {

View file

@ -5,15 +5,21 @@ import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import java.util.*
class DeviceHandler : MethodCallHandler { class DeviceHandler : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"getDefaultTimeZone" -> safe(call, result, ::getDefaultTimeZone)
"getPerformanceClass" -> safe(call, result, ::getPerformanceClass) "getPerformanceClass" -> safe(call, result, ::getPerformanceClass)
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
private fun getDefaultTimeZone(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
result.success(TimeZone.getDefault().id)
}
private fun getPerformanceClass(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { private fun getPerformanceClass(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
result.success(Build.VERSION.MEDIA_PERFORMANCE_CLASS) result.success(Build.VERSION.MEDIA_PERFORMANCE_CLASS)

View file

@ -27,8 +27,8 @@ import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.extensionFor import deckers.thibault.aves.utils.MimeTypes.extensionFor
import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor
import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
@ -62,7 +62,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
} }
val thumbnails = ArrayList<ByteArray>() val thumbnails = ArrayList<ByteArray>()
if (isSupportedByExifInterface(mimeType)) { if (canReadWithExifInterface(mimeType)) {
try { try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
@ -150,7 +150,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
return return
} }
if (isSupportedByMetadataExtractor(mimeType)) { if (canReadWithMetadataExtractor(mimeType)) {
try { try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input) val metadata = ImageMetadataReader.readMetadata(input)
@ -217,7 +217,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
result.success(resultFields) result.success(resultFields)
} }
override fun onFailure(throwable: Throwable) = result.error("copyEmbeddedBytes-failure", "failed to get entry for uri=$uri mime=$mimeType", "${throwable.message}\n${throwable.stackTraceToString()}") override fun onFailure(throwable: Throwable) = result.error("copyEmbeddedBytes-failure", "failed to get entry for uri=$uri mime=$mimeType", throwable.message)
}) })
} }
} else { } else {

View file

@ -10,7 +10,6 @@ import deckers.thibault.aves.channel.calls.fetchers.RegionFetcher
import deckers.thibault.aves.channel.calls.fetchers.SvgRegionFetcher import deckers.thibault.aves.channel.calls.fetchers.SvgRegionFetcher
import deckers.thibault.aves.channel.calls.fetchers.ThumbnailFetcher import deckers.thibault.aves.channel.calls.fetchers.ThumbnailFetcher
import deckers.thibault.aves.channel.calls.fetchers.TiffRegionFetcher import deckers.thibault.aves.channel.calls.fetchers.TiffRegionFetcher
import deckers.thibault.aves.model.ExifOrientationOp
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
@ -24,7 +23,7 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.roundToInt import kotlin.math.roundToInt
class ImageFileHandler(private val activity: Activity) : MethodCallHandler { class MediaFileHandler(private val activity: Activity) : MethodCallHandler {
private val density = activity.resources.displayMetrics.density private val density = activity.resources.displayMetrics.density
private val regionFetcher = RegionFetcher(activity) private val regionFetcher = RegionFetcher(activity)
@ -36,9 +35,6 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
"getRegion" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getRegion) } "getRegion" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getRegion) }
"captureFrame" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::captureFrame) } "captureFrame" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::captureFrame) }
"rename" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::rename) } "rename" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::rename) }
"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) }
"clearSizedThumbnailDiskCache" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::clearSizedThumbnailDiskCache) } "clearSizedThumbnailDiskCache" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::clearSizedThumbnailDiskCache) }
else -> result.notImplemented() else -> result.notImplemented()
} }
@ -60,7 +56,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
provider.fetchSingle(activity, uri, mimeType, object : ImageOpCallback { provider.fetchSingle(activity, uri, mimeType, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields) override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("getEntry-failure", "failed to get entry for uri=$uri", "${throwable.message}\n${throwable.stackTraceToString()}") override fun onFailure(throwable: Throwable) = result.error("getEntry-failure", "failed to get entry for uri=$uri", throwable.message)
}) })
} }
@ -70,10 +66,10 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
val dateModifiedSecs = call.argument<Number>("dateModifiedSecs")?.toLong() val dateModifiedSecs = call.argument<Number>("dateModifiedSecs")?.toLong()
val rotationDegrees = call.argument<Int>("rotationDegrees") val rotationDegrees = call.argument<Int>("rotationDegrees")
val isFlipped = call.argument<Boolean>("isFlipped") val isFlipped = call.argument<Boolean>("isFlipped")
val widthDip = call.argument<Double>("widthDip") val widthDip = call.argument<Number>("widthDip")?.toDouble()
val heightDip = call.argument<Double>("heightDip") val heightDip = call.argument<Number>("heightDip")?.toDouble()
val pageId = call.argument<Int>("pageId") val pageId = call.argument<Int>("pageId")
val defaultSizeDip = call.argument<Double>("defaultSizeDip") val defaultSizeDip = call.argument<Number>("defaultSizeDip")?.toDouble()
if (uri == null || mimeType == null || dateModifiedSecs == null || rotationDegrees == null || isFlipped == null || widthDip == null || heightDip == null || defaultSizeDip == null) { if (uri == null || mimeType == null || dateModifiedSecs == null || rotationDegrees == null || isFlipped == null || widthDip == null || heightDip == null || defaultSizeDip == null) {
result.error("getThumbnail-args", "failed because of missing arguments", null) result.error("getThumbnail-args", "failed because of missing arguments", null)
@ -162,7 +158,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
destinationDir = ensureTrailingSeparator(destinationDir) destinationDir = ensureTrailingSeparator(destinationDir)
provider.captureFrame(activity, desiredName, exifFields, bytes, destinationDir, object : ImageOpCallback { provider.captureFrame(activity, desiredName, exifFields, bytes, destinationDir, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields) override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("captureFrame-failure", "failed to capture frame", "${throwable.message}\n${throwable.stackTraceToString()}") override fun onFailure(throwable: Throwable) = result.error("captureFrame-failure", "failed to capture frame for uri=$uri", throwable.message)
}) })
} }
@ -190,79 +186,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
provider.rename(activity, path, uri, mimeType, newName, object : ImageOpCallback { provider.rename(activity, path, uri, mimeType, newName, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields) override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("rename-failure", "failed to rename", "${throwable.message}\n${throwable.stackTraceToString()}") override fun onFailure(throwable: Throwable) = result.error("rename-failure", "failed to rename", throwable.message)
})
}
private fun rotate(call: MethodCall, result: MethodChannel.Result) {
val clockwise = call.argument<Boolean>("clockwise")
if (clockwise == null) {
result.error("rotate-args", "failed because of missing arguments", null)
return
}
val op = if (clockwise) ExifOrientationOp.ROTATE_CW else ExifOrientationOp.ROTATE_CCW
changeOrientation(call, result, op)
}
private fun flip(call: MethodCall, result: MethodChannel.Result) {
changeOrientation(call, result, ExifOrientationOp.FLIP)
}
private fun changeOrientation(call: MethodCall, result: MethodChannel.Result, op: ExifOrientationOp) {
val entryMap = call.argument<FieldMap>("entry")
if (entryMap == null) {
result.error("changeOrientation-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("changeOrientation-args", "failed because entry fields are missing", null)
return
}
val provider = getProvider(uri)
if (provider == null) {
result.error("changeOrientation-provider", "failed to find provider for uri=$uri", null)
return
}
provider.changeOrientation(activity, path, uri, mimeType, op, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("changeOrientation-failure", "failed to change orientation", "${throwable.message}\n${throwable.stackTraceToString()}")
})
}
private fun editDate(call: MethodCall, result: MethodChannel.Result) {
val dateMillis = call.argument<Number>("dateMillis")?.toLong()
val shiftMinutes = call.argument<Number>("shiftMinutes")?.toLong()
val fields = call.argument<List<String>>("fields")
val entryMap = call.argument<FieldMap>("entry")
if (entryMap == null || fields == null) {
result.error("editDate-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("editDate-args", "failed because entry fields are missing", null)
return
}
val provider = getProvider(uri)
if (provider == null) {
result.error("editDate-provider", "failed to find provider for uri=$uri", null)
return
}
provider.editDate(activity, path, uri, mimeType, dateMillis, shiftMinutes, fields, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("editDate-failure", "failed to edit date", "${throwable.message}\n${throwable.stackTraceToString()}")
}) })
} }
@ -272,6 +196,6 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
} }
companion object { companion object {
const val CHANNEL = "deckers.thibault/aves/image" const val CHANNEL = "deckers.thibault/aves/media_file"
} }
} }

View file

@ -1,6 +1,8 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.app.Activity import android.app.Activity
import android.media.MediaScannerConnection
import android.net.Uri
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.model.provider.MediaStoreImageProvider import deckers.thibault.aves.model.provider.MediaStoreImageProvider
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
@ -15,6 +17,7 @@ class MediaStoreHandler(private val activity: Activity) : MethodCallHandler {
when (call.method) { when (call.method) {
"checkObsoleteContentIds" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::checkObsoleteContentIds) } "checkObsoleteContentIds" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::checkObsoleteContentIds) }
"checkObsoletePaths" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::checkObsoletePaths) } "checkObsoletePaths" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::checkObsoletePaths) }
"scanFile" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::scanFile) }
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
@ -37,7 +40,13 @@ class MediaStoreHandler(private val activity: Activity) : MethodCallHandler {
result.success(MediaStoreImageProvider().checkObsoletePaths(activity, knownPathById)) result.success(MediaStoreImageProvider().checkObsoletePaths(activity, knownPathById))
} }
private fun scanFile(call: MethodCall, result: MethodChannel.Result) {
val path = call.argument<String>("path")
val mimeType = call.argument<String>("mimeType")
MediaScannerConnection.scanFile(activity, arrayOf(path), arrayOf(mimeType)) { _, uri: Uri? -> result.success(uri?.toString()) }
}
companion object { companion object {
const val CHANNEL = "deckers.thibault/aves/mediastore" const val CHANNEL = "deckers.thibault/aves/media_store"
} }
} }

View file

@ -0,0 +1,131 @@
package deckers.thibault.aves.channel.calls
import android.app.Activity
import android.net.Uri
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.model.ExifOrientationOp
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"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) }
"removeTypes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::removeTypes) }
else -> result.notImplemented()
}
}
private fun rotate(call: MethodCall, result: MethodChannel.Result) {
val clockwise = call.argument<Boolean>("clockwise")
if (clockwise == null) {
result.error("rotate-args", "failed because of missing arguments", null)
return
}
val op = if (clockwise) ExifOrientationOp.ROTATE_CW else ExifOrientationOp.ROTATE_CCW
editOrientation(call, result, op)
}
private fun flip(call: MethodCall, result: MethodChannel.Result) {
editOrientation(call, result, ExifOrientationOp.FLIP)
}
private fun editOrientation(call: MethodCall, result: MethodChannel.Result, op: ExifOrientationOp) {
val entryMap = call.argument<FieldMap>("entry")
if (entryMap == null) {
result.error("editOrientation-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("editOrientation-args", "failed because entry fields are missing", null)
return
}
val provider = getProvider(uri)
if (provider == null) {
result.error("editOrientation-provider", "failed to find provider for uri=$uri", null)
return
}
provider.editOrientation(activity, path, uri, mimeType, op, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("editOrientation-failure", "failed to change orientation for mimeType=$mimeType uri=$uri", throwable.message)
})
}
private fun editDate(call: MethodCall, result: MethodChannel.Result) {
val dateMillis = call.argument<Number>("dateMillis")?.toLong()
val shiftMinutes = call.argument<Number>("shiftMinutes")?.toLong()
val fields = call.argument<List<String>>("fields")
val entryMap = call.argument<FieldMap>("entry")
if (entryMap == null || fields == null) {
result.error("editDate-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("editDate-args", "failed because entry fields are missing", null)
return
}
val provider = getProvider(uri)
if (provider == null) {
result.error("editDate-provider", "failed to find provider for uri=$uri", null)
return
}
provider.editDate(activity, path, uri, mimeType, dateMillis, shiftMinutes, fields, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("editDate-failure", "failed to edit date for mimeType=$mimeType uri=$uri", throwable.message)
})
}
private fun removeTypes(call: MethodCall, result: MethodChannel.Result) {
val types = call.argument<List<String>>("types")
val entryMap = call.argument<FieldMap>("entry")
if (entryMap == null || types == null) {
result.error("removeTypes-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("removeTypes-args", "failed because entry fields are missing", null)
return
}
val provider = getProvider(uri)
if (provider == null) {
result.error("removeTypes-provider", "failed to find provider for uri=$uri", null)
return
}
provider.removeMetadataTypes(activity, path, uri, mimeType, types.toSet(), object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("removeTypes-failure", "failed to remove metadata for mimeType=$mimeType uri=$uri", throwable.message)
})
}
companion object {
const val CHANNEL = "deckers.thibault/aves/metadata_edit"
}
}

View file

@ -54,8 +54,8 @@ import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isHeic import deckers.thibault.aves.utils.MimeTypes.isHeic
import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor
import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.tiffExtensionPattern import deckers.thibault.aves.utils.MimeTypes.tiffExtensionPattern
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
@ -70,7 +70,7 @@ import java.text.ParseException
import java.util.* import java.util.*
import kotlin.math.roundToLong import kotlin.math.roundToLong
class MetadataHandler(private val context: Context) : MethodCallHandler { class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"getAllMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getAllMetadata) } "getAllMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getAllMetadata) }
@ -97,7 +97,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
var foundExif = false var foundExif = false
var foundXmp = false var foundXmp = false
if (isSupportedByMetadataExtractor(mimeType)) { if (canReadWithMetadataExtractor(mimeType)) {
try { try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input) val metadata = ImageMetadataReader.readMetadata(input)
@ -225,7 +225,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
} }
} }
if (!foundExif && isSupportedByExifInterface(mimeType)) { if (!foundExif && canReadWithExifInterface(mimeType)) {
// fallback to read EXIF via ExifInterface // fallback to read EXIF via ExifInterface
try { try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
@ -337,7 +337,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
var flags = (metadataMap[KEY_FLAGS] ?: 0) as Int var flags = (metadataMap[KEY_FLAGS] ?: 0) as Int
var foundExif = false var foundExif = false
if (isSupportedByMetadataExtractor(mimeType)) { if (canReadWithMetadataExtractor(mimeType)) {
try { try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input) val metadata = ImageMetadataReader.readMetadata(input)
@ -480,7 +480,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
} }
} }
if (!foundExif && isSupportedByExifInterface(mimeType)) { if (!foundExif && canReadWithExifInterface(mimeType)) {
// fallback to read EXIF via ExifInterface // fallback to read EXIF via ExifInterface
try { try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
@ -584,7 +584,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
} }
var foundExif = false var foundExif = false
if (isSupportedByMetadataExtractor(mimeType)) { if (canReadWithMetadataExtractor(mimeType)) {
try { try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input) val metadata = ImageMetadataReader.readMetadata(input)
@ -603,7 +603,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
} }
} }
if (!foundExif && isSupportedByExifInterface(mimeType)) { if (!foundExif && canReadWithExifInterface(mimeType)) {
// fallback to read EXIF via ExifInterface // fallback to read EXIF via ExifInterface
try { try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
@ -654,7 +654,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
return return
} }
if (isSupportedByMetadataExtractor(mimeType)) { if (canReadWithMetadataExtractor(mimeType)) {
try { try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input) val metadata = ImageMetadataReader.readMetadata(input)
@ -755,8 +755,8 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
} }
companion object { companion object {
private val LOG_TAG = LogUtils.createTag<MetadataHandler>() private val LOG_TAG = LogUtils.createTag<MetadataFetchHandler>()
const val CHANNEL = "deckers.thibault/aves/metadata" const val CHANNEL = "deckers.thibault/aves/metadata_fetch"
private val allMetadataRedundantDirNames = setOf( private val allMetadataRedundantDirNames = setOf(
"MP4", "MP4",

View file

@ -1,8 +1,6 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.content.Context import android.content.Context
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.os.storage.StorageManager import android.os.storage.StorageManager
@ -30,7 +28,6 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
"getRestrictedDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getRestrictedDirectories) } "getRestrictedDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getRestrictedDirectories) }
"revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess) "revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess)
"deleteEmptyDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::deleteEmptyDirectories) } "deleteEmptyDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::deleteEmptyDirectories) }
"scanFile" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::scanFile) }
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
@ -158,12 +155,6 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
result.success(deleted) result.success(deleted)
} }
private fun scanFile(call: MethodCall, result: MethodChannel.Result) {
val path = call.argument<String>("path")
val mimeType = call.argument<String>("mimeType")
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, uri: Uri? -> result.success(uri?.toString()) }
}
companion object { companion object {
const val CHANNEL = "deckers.thibault/aves/storage" const val CHANNEL = "deckers.thibault/aves/storage"
} }

View file

@ -1,24 +0,0 @@
package deckers.thibault.aves.channel.calls
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 TimeHandler : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getDefaultTimeZone" -> safe(call, result, ::getDefaultTimeZone)
else -> result.notImplemented()
}
}
private fun getDefaultTimeZone(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
result.success(TimeZone.getDefault().id)
}
companion object {
const val CHANNEL = "deckers.thibault/aves/time"
}
}

View file

@ -17,7 +17,7 @@ import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isHeic import deckers.thibault.aves.utils.MimeTypes.isHeic
import deckers.thibault.aves.utils.MimeTypes.isSupportedByFlutter import deckers.thibault.aves.utils.MimeTypes.canDecodeWithFlutter
import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
@ -96,7 +96,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
if (isVideo(mimeType)) { if (isVideo(mimeType)) {
streamVideoByGlide(uri, mimeType) streamVideoByGlide(uri, mimeType)
} else if (!isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) { } else if (!canDecodeWithFlutter(mimeType, rotationDegrees, isFlipped)) {
// decode exotic format on platform side, then encode it in portable format for Flutter // decode exotic format on platform side, then encode it in portable format for Flutter
streamImageByGlide(uri, pageId, mimeType, rotationDegrees, isFlipped) streamImageByGlide(uri, pageId, mimeType, rotationDegrees, isFlipped)
} else { } else {
@ -187,7 +187,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
companion object { companion object {
private val LOG_TAG = LogUtils.createTag<ImageByteStreamHandler>() private val LOG_TAG = LogUtils.createTag<ImageByteStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/image_byte_stream" const val CHANNEL = "deckers.thibault/aves/media_byte_stream"
private const val BUFFER_SIZE = 2 shl 17 // 256kB private const val BUFFER_SIZE = 2 shl 17 // 256kB

View file

@ -177,6 +177,6 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
companion object { companion object {
private val LOG_TAG = LogUtils.createTag<ImageOpStreamHandler>() private val LOG_TAG = LogUtils.createTag<ImageOpStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/image_op_stream" const val CHANNEL = "deckers.thibault/aves/media_op_stream"
} }
} }

View file

@ -59,6 +59,6 @@ class MediaStoreChangeStreamHandler(private val context: Context) : EventChannel
companion object { companion object {
private val LOG_TAG = LogUtils.createTag<MediaStoreChangeStreamHandler>() private val LOG_TAG = LogUtils.createTag<MediaStoreChangeStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/mediastorechange" const val CHANNEL = "deckers.thibault/aves/media_store_change"
} }
} }

View file

@ -62,6 +62,6 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E
companion object { companion object {
private val LOG_TAG = LogUtils.createTag<MediaStoreStreamHandler>() private val LOG_TAG = LogUtils.createTag<MediaStoreStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/mediastorestream" const val CHANNEL = "deckers.thibault/aves/media_store_stream"
} }
} }

View file

@ -20,6 +20,7 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
private val contentObserver = object : ContentObserver(null) { private val contentObserver = object : ContentObserver(null) {
private var accelerometerRotation: Int = 0 private var accelerometerRotation: Int = 0
private var transitionAnimationScale: Float = 1f
init { init {
update() update()
@ -33,7 +34,8 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
if (update()) { if (update()) {
success( success(
hashMapOf( hashMapOf(
Settings.System.ACCELEROMETER_ROTATION to accelerometerRotation Settings.System.ACCELEROMETER_ROTATION to accelerometerRotation,
Settings.Global.TRANSITION_ANIMATION_SCALE to transitionAnimationScale,
) )
) )
} }
@ -47,6 +49,12 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
accelerometerRotation = newAccelerometerRotation accelerometerRotation = newAccelerometerRotation
changed = true changed = true
} }
val newTransitionAnimationScale = Settings.Global.getFloat(context.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE)
if (transitionAnimationScale != newTransitionAnimationScale) {
transitionAnimationScale = newTransitionAnimationScale
changed = true
}
} catch (e: Exception) { } catch (e: Exception) {
Log.w(LOG_TAG, "failed to get settings", e) Log.w(LOG_TAG, "failed to get settings", e)
} }
@ -83,6 +91,6 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
companion object { companion object {
private val LOG_TAG = LogUtils.createTag<SettingsChangeStreamHandler>() private val LOG_TAG = LogUtils.createTag<SettingsChangeStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/settingschange" const val CHANNEL = "deckers.thibault/aves/settings_change"
} }
} }

View file

@ -32,6 +32,17 @@ object Metadata {
const val DIR_MEDIA = "Media" // custom const val DIR_MEDIA = "Media" // custom
const val DIR_COVER_ART = "Cover" // custom const val DIR_COVER_ART = "Cover" // custom
// types of metadata
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"
// interpret EXIF code to angle (0, 90, 180 or 270 degrees) // interpret EXIF code to angle (0, 90, 180 or 270 degrees)
fun getRotationDegreesForExifCode(exifOrientation: Int): Int = when (exifOrientation) { fun getRotationDegreesForExifCode(exifOrientation: Int): Int = when (exifOrientation) {
ExifInterface.ORIENTATION_ROTATE_90, ExifInterface.ORIENTATION_TRANSVERSE -> 90 ExifInterface.ORIENTATION_ROTATE_90, ExifInterface.ORIENTATION_TRANSVERSE -> 90

View file

@ -33,7 +33,10 @@ object MetadataExtractorHelper {
} }
fun Directory.getSafeDateMillis(tag: Int, save: (value: Long) -> Unit) { fun Directory.getSafeDateMillis(tag: Int, save: (value: Long) -> Unit) {
if (this.containsTag(tag)) save(this.getDate(tag, null, TimeZone.getDefault()).time) if (this.containsTag(tag)) {
val date = this.getDate(tag, null, TimeZone.getDefault())
if (date != null) save(date.time)
}
} }
// geotiff // geotiff

View file

@ -1,5 +1,14 @@
package deckers.thibault.aves.metadata package deckers.thibault.aves.metadata
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 pixy.meta.meta.Metadata import pixy.meta.meta.Metadata
import pixy.meta.meta.MetadataEntry import pixy.meta.meta.MetadataEntry
import pixy.meta.meta.MetadataType import pixy.meta.meta.MetadataType
@ -54,4 +63,22 @@ object PixyMetaHelper {
fun XMP.xmpDocString(): String = XMLUtils.serializeToString(xmpDocument) fun XMP.xmpDocString(): String = XMLUtils.serializeToString(xmpDocument)
fun XMP.extendedXmpDocString(): String = XMLUtils.serializeToString(extendedXmpDocument) fun XMP.extendedXmpDocString(): String = XMLUtils.serializeToString(extendedXmpDocument)
fun removeMetadata(input: InputStream, output: OutputStream, metadataTypes: Set<String>) {
val types = metadataTypes.map(::toMetadataType).toTypedArray()
Metadata.removeMetadata(input, output, *types)
}
private fun toMetadataType(typeString: String): MetadataType? = when (typeString) {
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
else -> null
}
} }

View file

@ -150,7 +150,7 @@ class SourceEntry {
// finds: width, height, orientation, date, duration // finds: width, height, orientation, date, duration
private fun fillByMetadataExtractor(context: Context) { private fun fillByMetadataExtractor(context: Context) {
// skip raw images because `metadata-extractor` reports the decoded dimensions instead of the raw dimensions // skip raw images because `metadata-extractor` reports the decoded dimensions instead of the raw dimensions
if (!MimeTypes.isSupportedByMetadataExtractor(sourceMimeType) if (!MimeTypes.canReadWithMetadataExtractor(sourceMimeType)
|| MimeTypes.isRaw(sourceMimeType) || MimeTypes.isRaw(sourceMimeType)
) return ) return
@ -204,7 +204,7 @@ class SourceEntry {
// finds: width, height, orientation, date // finds: width, height, orientation, date
private fun fillByExifInterface(context: Context) { private fun fillByExifInterface(context: Context) {
if (!MimeTypes.isSupportedByExifInterface(sourceMimeType)) return if (!MimeTypes.canReadWithExifInterface(sourceMimeType)) return
try { try {
Metadata.openSafeInputStream(context, uri, sourceMimeType, sizeBytes)?.use { input -> Metadata.openSafeInputStream(context, uri, sourceMimeType, sizeBytes)?.use { input ->

View file

@ -1,13 +1,10 @@
package deckers.thibault.aves.model.provider package deckers.thibault.aves.model.provider
import android.app.Activity import android.app.Activity
import android.content.ContentUris
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.media.MediaScannerConnection
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.MediaStore
import android.util.Log import android.util.Log
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
@ -17,41 +14,46 @@ import com.bumptech.glide.request.RequestOptions
import com.commonsware.cwac.document.DocumentFileCompat import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.decoder.MultiTrackImage import deckers.thibault.aves.decoder.MultiTrackImage
import deckers.thibault.aves.decoder.TiffImage import deckers.thibault.aves.decoder.TiffImage
import deckers.thibault.aves.metadata.ExifInterfaceHelper import deckers.thibault.aves.metadata.*
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.MultiPage
import deckers.thibault.aves.metadata.PixyMetaHelper
import deckers.thibault.aves.metadata.PixyMetaHelper.extendedXmpDocString import deckers.thibault.aves.metadata.PixyMetaHelper.extendedXmpDocString
import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString
import deckers.thibault.aves.metadata.XMP
import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.AvesEntry
import deckers.thibault.aves.model.ExifOrientationOp import deckers.thibault.aves.model.ExifOrientationOp
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.* import deckers.thibault.aves.utils.*
import deckers.thibault.aves.utils.MimeTypes.canEditExif
import deckers.thibault.aves.utils.MimeTypes.canEditXmp
import deckers.thibault.aves.utils.MimeTypes.canRemoveMetadata
import deckers.thibault.aves.utils.MimeTypes.extensionFor import deckers.thibault.aves.utils.MimeTypes.extensionFor
import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isSupportedByPixyMeta
import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent
import deckers.thibault.aves.utils.StorageUtils.getDocumentFile import deckers.thibault.aves.utils.StorageUtils.getDocumentFile
import deckers.thibault.aves.utils.UriUtils.tryParseId import java.io.ByteArrayInputStream
import java.io.* import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.util.* import java.util.*
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
abstract class ImageProvider { abstract class ImageProvider {
open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) { open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) {
callback.onFailure(UnsupportedOperationException()) callback.onFailure(UnsupportedOperationException("`fetchSingle` is not supported by this image provider"))
} }
open suspend fun delete(activity: Activity, uri: Uri, path: String?) { open suspend fun delete(activity: Activity, uri: Uri, path: String?) {
throw UnsupportedOperationException() throw UnsupportedOperationException("`delete` is not supported by this image provider")
} }
open suspend fun moveMultiple(activity: Activity, copy: Boolean, destinationDir: String, entries: List<AvesEntry>, callback: ImageOpCallback) { open suspend fun moveMultiple(activity: Activity, copy: Boolean, destinationDir: String, entries: List<AvesEntry>, callback: ImageOpCallback) {
callback.onFailure(UnsupportedOperationException()) callback.onFailure(UnsupportedOperationException("`moveMultiple` is not supported by this image provider"))
}
open fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap<String, Any?>, callback: ImageOpCallback) {
throw UnsupportedOperationException("`scanPostMetadataEdit` is not supported by this image provider")
}
open fun scanObsoletePath(context: Context, path: String, mimeType: String) {
throw UnsupportedOperationException("`scanObsoletePath` is not supported by this image provider")
} }
suspend fun exportMultiple( suspend fun exportMultiple(
@ -123,17 +125,13 @@ abstract class ImageProvider {
val page = if (sourceMimeType == MimeTypes.TIFF) pageId + 1 else pageId val page = if (sourceMimeType == MimeTypes.TIFF) pageId + 1 else pageId
desiredNameWithoutExtension += "_${page.toString().padStart(3, '0')}" desiredNameWithoutExtension += "_${page.toString().padStart(3, '0')}"
} }
val desiredFileName = desiredNameWithoutExtension + extensionFor(exportMimeType) val availableNameWithoutExtension = findAvailableFileNameWithoutExtension(destinationDir, desiredNameWithoutExtension, extensionFor(exportMimeType))
if (File(destinationDir, desiredFileName).exists()) {
throw Exception("file with name=$desiredFileName already exists in destination directory")
}
// the file created from a `TreeDocumentFile` is also a `TreeDocumentFile` // the file created from a `TreeDocumentFile` is also a `TreeDocumentFile`
// but in order to open an output stream to it, we need to use a `SingleDocumentFile` // but in order to open an output stream to it, we need to use a `SingleDocumentFile`
// through a document URI, not a tree URI // through a document URI, not a tree URI
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first // note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
val destinationTreeFile = destinationDirDocFile.createFile(exportMimeType, desiredNameWithoutExtension) val destinationTreeFile = destinationDirDocFile.createFile(exportMimeType, availableNameWithoutExtension)
val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri) val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri)
if (isVideo(sourceMimeType)) { if (isVideo(sourceMimeType)) {
@ -197,7 +195,7 @@ abstract class ImageProvider {
val fileName = destinationDocFile.name val fileName = destinationDocFile.name
val destinationFullPath = destinationDir + fileName val destinationFullPath = destinationDir + fileName
return scanNewPath(context, destinationFullPath, exportMimeType) return MediaStoreImageProvider().scanNewPath(context, destinationFullPath, exportMimeType)
} }
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
@ -216,17 +214,13 @@ abstract class ImageProvider {
} }
val captureMimeType = MimeTypes.JPEG val captureMimeType = MimeTypes.JPEG
val desiredFileName = desiredNameWithoutExtension + extensionFor(captureMimeType) val availableNameWithoutExtension = findAvailableFileNameWithoutExtension(destinationDir, desiredNameWithoutExtension, extensionFor(captureMimeType))
if (File(destinationDir, desiredFileName).exists()) {
callback.onFailure(Exception("file with name=$desiredFileName already exists in destination directory"))
return
}
// the file created from a `TreeDocumentFile` is also a `TreeDocumentFile` // the file created from a `TreeDocumentFile` is also a `TreeDocumentFile`
// but in order to open an output stream to it, we need to use a `SingleDocumentFile` // but in order to open an output stream to it, we need to use a `SingleDocumentFile`
// through a document URI, not a tree URI // through a document URI, not a tree URI
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first // note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
val destinationTreeFile = destinationDirDocFile.createFile(captureMimeType, desiredNameWithoutExtension) val destinationTreeFile = destinationDirDocFile.createFile(captureMimeType, availableNameWithoutExtension)
val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri) val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri)
try { try {
@ -295,13 +289,23 @@ abstract class ImageProvider {
val fileName = destinationDocFile.name val fileName = destinationDocFile.name
val destinationFullPath = destinationDir + fileName val destinationFullPath = destinationDir + fileName
val newFields = scanNewPath(context, destinationFullPath, captureMimeType) val newFields = MediaStoreImageProvider().scanNewPath(context, destinationFullPath, captureMimeType)
callback.onSuccess(newFields) callback.onSuccess(newFields)
} catch (e: Exception) { } catch (e: Exception) {
callback.onFailure(e) callback.onFailure(e)
} }
} }
private fun findAvailableFileNameWithoutExtension(dir: String, desiredNameWithoutExtension: String, extension: String?): String {
var nameWithoutExtension = desiredNameWithoutExtension
var i = 0
while (File(dir, "$nameWithoutExtension$extension").exists()) {
i++
nameWithoutExtension = "$desiredNameWithoutExtension ($i)"
}
return nameWithoutExtension
}
suspend fun rename(context: Context, oldPath: String, oldMediaUri: Uri, mimeType: String, newFilename: String, callback: ImageOpCallback) { suspend fun rename(context: Context, oldPath: String, oldMediaUri: Uri, mimeType: String, newFilename: String, callback: ImageOpCallback) {
val oldFile = File(oldPath) val oldFile = File(oldPath)
val newFile = File(oldFile.parent, newFilename) val newFile = File(oldFile.parent, newFilename)
@ -324,31 +328,14 @@ abstract class ImageProvider {
return return
} }
MediaScannerConnection.scanFile(context, arrayOf(oldPath), arrayOf(mimeType), null) scanObsoletePath(context, oldPath, mimeType)
try { try {
callback.onSuccess(scanNewPath(context, newFile.path, mimeType)) callback.onSuccess(MediaStoreImageProvider().scanNewPath(context, newFile.path, mimeType))
} catch (e: Exception) { } catch (e: Exception) {
callback.onFailure(e) callback.onFailure(e)
} }
} }
// support for writing EXIF
// as of androidx.exifinterface:exifinterface:1.3.3
private fun canEditExif(mimeType: String): Boolean {
return when (mimeType) {
MimeTypes.DNG,
MimeTypes.JPEG,
MimeTypes.PNG,
MimeTypes.WEBP -> true
else -> false
}
}
// support for writing XMP
private fun canEditXmp(mimeType: String): Boolean {
return isSupportedByPixyMeta(mimeType)
}
private fun editExif( private fun editExif(
context: Context, context: Context,
path: String, path: String,
@ -524,28 +511,14 @@ abstract class ImageProvider {
} }
} }
private fun scanPostExifEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap<String, Any?>, callback: ImageOpCallback) { fun editOrientation(
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, _ -> context: Context,
val projection = arrayOf( path: String,
MediaStore.MediaColumns.DATE_MODIFIED, uri: Uri,
MediaStore.MediaColumns.SIZE, mimeType: String,
) op: ExifOrientationOp,
try { callback: ImageOpCallback,
val cursor = context.contentResolver.query(uri, projection, null, null, null) ) {
if (cursor != null && cursor.moveToFirst()) {
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) }
cursor.getColumnIndex(MediaStore.MediaColumns.SIZE).let { if (it != -1) newFields["sizeBytes"] = cursor.getLong(it) }
cursor.close()
}
} catch (e: Exception) {
callback.onFailure(e)
return@scanFile
}
callback.onSuccess(newFields)
}
}
fun changeOrientation(context: Context, path: String, uri: Uri, mimeType: String, op: ExifOrientationOp, callback: ImageOpCallback) {
val newFields = HashMap<String, Any?>() val newFields = HashMap<String, Any?>()
val success = editExif(context, path, uri, mimeType, callback) { exif -> val success = editExif(context, path, uri, mimeType, callback) { exif ->
@ -568,7 +541,7 @@ abstract class ImageProvider {
} }
if (success) { if (success) {
scanPostExifEdit(context, path, uri, mimeType, newFields, callback) scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
} }
} }
@ -662,67 +635,61 @@ abstract class ImageProvider {
} }
if (success) { if (success) {
scanPostExifEdit(context, path, uri, mimeType, HashMap<String, Any?>(), callback) scanPostMetadataEdit(context, path, uri, mimeType, HashMap<String, Any?>(), callback)
} }
} }
protected suspend fun scanNewPath(context: Context, path: String, mimeType: String): FieldMap = fun removeMetadataTypes(
suspendCoroutine { cont -> context: Context,
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, newUri: Uri? -> path: String,
fun scanUri(uri: Uri?): FieldMap? { uri: Uri,
uri ?: return null mimeType: String,
types: Set<String>,
callback: ImageOpCallback,
) {
if (!canRemoveMetadata(mimeType)) {
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
return
}
// we retrieve updated fields as the renamed/moved file became a new entry in the Media Store val originalDocumentFile = getDocumentFile(context, path, uri)
val projection = arrayOf( if (originalDocumentFile == null) {
MediaStore.MediaColumns.DATE_MODIFIED, callback.onFailure(Exception("failed to get document file for path=$path, uri=$uri"))
MediaStore.MediaColumns.DISPLAY_NAME, return
MediaStore.MediaColumns.TITLE, }
)
val originalFileSize = File(path).length()
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.toInt()
val editableFile = File.createTempFile("aves", null).apply {
deleteOnExit()
try { try {
val cursor = context.contentResolver.query(uri, projection, null, null, null) outputStream().use { output ->
if (cursor != null && cursor.moveToFirst()) { // reopen input to read from start
val newFields = HashMap<String, Any?>() originalDocumentFile.openInputStream().use { input ->
newFields["uri"] = uri.toString() PixyMetaHelper.removeMetadata(input, output, types)
newFields["contentId"] = uri.tryParseId() }
newFields["path"] = path
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) }
cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME).let { if (it != -1) newFields["displayName"] = cursor.getString(it) }
cursor.getColumnIndex(MediaStore.MediaColumns.TITLE).let { if (it != -1) newFields["title"] = cursor.getString(it) }
cursor.close()
return newFields
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(LOG_TAG, "failed to scan uri=$uri", e) Log.d(LOG_TAG, "failed to remove metadata", e)
} callback.onFailure(e)
return null return
}
if (newUri == null) {
cont.resumeWithException(Exception("failed to get URI of item at path=$path"))
return@scanFile
}
var contentUri: Uri? = null
// `newURI` is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872")
// but we need an image/video media URI (e.g. "content://media/external/images/media/62872")
val contentId = newUri.tryParseId()
if (contentId != null) {
if (isImage(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId)
} else if (isVideo(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId)
} }
} }
// prefer image/video content URI, fallback to original URI (possibly a file content URI) try {
val newFields = scanUri(contentUri) ?: scanUri(newUri) // copy the edited temporary file back to the original
DocumentFileCompat.fromFile(editableFile).copyTo(originalDocumentFile)
if (newFields != null) { if (!types.contains(Metadata.TYPE_XMP) && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
cont.resume(newFields) return
} else {
cont.resumeWithException(Exception("failed to get item details from provider at contentUri=$contentUri (from newUri=$newUri)"))
} }
} catch (e: IOException) {
callback.onFailure(e)
return
} }
val newFields = HashMap<String, Any?>()
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
} }
interface ImageOpCallback { interface ImageOpCallback {

View file

@ -5,6 +5,7 @@ import android.app.Activity
import android.app.RecoverableSecurityException import android.app.RecoverableSecurityException
import android.content.ContentUris import android.content.ContentUris
import android.content.Context import android.content.Context
import android.media.MediaScannerConnection
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.MediaStore import android.provider.MediaStore
@ -27,6 +28,9 @@ import java.io.File
import java.util.* import java.util.*
import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletableFuture
import kotlin.collections.ArrayList import kotlin.collections.ArrayList
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
class MediaStoreImageProvider : ImageProvider() { class MediaStoreImageProvider : ImageProvider() {
fun fetchAll(context: Context, knownEntries: Map<Int, Int?>, handleNewEntry: NewEntryHandler) { fun fetchAll(context: Context, knownEntries: Map<Int, Int?>, handleNewEntry: NewEntryHandler) {
@ -39,29 +43,41 @@ class MediaStoreImageProvider : ImageProvider() {
} }
override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) { override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) {
var found = false
val fetched = arrayListOf<FieldMap>()
val id = uri.tryParseId() val id = uri.tryParseId()
val onSuccess = fun(entry: FieldMap) { val onSuccess = fun(entry: FieldMap) {
entry["uri"] = uri.toString() entry["uri"] = uri.toString()
callback.onSuccess(entry) fetched.add(entry)
} }
val alwaysValid = { _: Int, _: Int -> true } val alwaysValid = { _: Int, _: Int -> true }
if (id != null) { if (id != null) {
if (sourceMimeType == null || isImage(sourceMimeType)) { if (!found && (sourceMimeType == null || isImage(sourceMimeType))) {
val contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, id) val contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, id)
if (fetchFrom(context, alwaysValid, onSuccess, contentUri, IMAGE_PROJECTION)) return found = fetchFrom(context, alwaysValid, onSuccess, contentUri, IMAGE_PROJECTION)
} }
if (sourceMimeType == null || isVideo(sourceMimeType)) { if (!found && (sourceMimeType == null || isVideo(sourceMimeType))) {
val contentUri = ContentUris.withAppendedId(VIDEO_CONTENT_URI, id) val contentUri = ContentUris.withAppendedId(VIDEO_CONTENT_URI, id)
if (fetchFrom(context, alwaysValid, onSuccess, contentUri, VIDEO_PROJECTION)) return found = fetchFrom(context, alwaysValid, onSuccess, contentUri, VIDEO_PROJECTION)
} }
} }
if (!found) {
// the uri can be a file media URI (e.g. "content://0@media/external/file/30050") // the uri can be a file media URI (e.g. "content://0@media/external/file/30050")
// without an equivalent image/video if it is shared from a file browser // without an equivalent image/video if it is shared from a file browser
// but the file is not publicly visible // but the file is not publicly visible
if (fetchFrom(context, alwaysValid, onSuccess, uri, BASE_PROJECTION, fileMimeType = sourceMimeType)) return found = fetchFrom(context, alwaysValid, onSuccess, uri, BASE_PROJECTION, fileMimeType = sourceMimeType)
}
if (found && fetched.isNotEmpty()) {
if (fetched.size == 1) {
callback.onSuccess(fetched.first())
} else {
callback.onFailure(Exception("found ${fetched.size} entries at uri=$uri"))
}
} else {
callback.onFailure(Exception("failed to fetch entry at uri=$uri")) callback.onFailure(Exception("failed to fetch entry at uri=$uri"))
} }
}
fun checkObsoleteContentIds(context: Context, knownContentIds: List<Int>): List<Int> { fun checkObsoleteContentIds(context: Context, knownContentIds: List<Int>): List<Int> {
val foundContentIds = ArrayList<Int>() val foundContentIds = ArrayList<Int>()
@ -82,7 +98,7 @@ class MediaStoreImageProvider : ImageProvider() {
} }
check(context, IMAGE_CONTENT_URI) check(context, IMAGE_CONTENT_URI)
check(context, VIDEO_CONTENT_URI) check(context, VIDEO_CONTENT_URI)
return knownContentIds.filter { id: Int -> !foundContentIds.contains(id) }.toList() return knownContentIds.subtract(foundContentIds).toList()
} }
fun checkObsoletePaths(context: Context, knownPathById: Map<Int, String>): List<Int> { fun checkObsoletePaths(context: Context, knownPathById: Map<Int, String>): List<Int> {
@ -362,6 +378,90 @@ class MediaStoreImageProvider : ImageProvider() {
} }
} }
override fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap<String, Any?>, callback: ImageOpCallback) {
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, _ ->
val projection = arrayOf(
MediaStore.MediaColumns.DATE_MODIFIED,
MediaStore.MediaColumns.SIZE,
)
try {
val cursor = context.contentResolver.query(uri, projection, null, null, null)
if (cursor != null && cursor.moveToFirst()) {
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) }
cursor.getColumnIndex(MediaStore.MediaColumns.SIZE).let { if (it != -1) newFields["sizeBytes"] = cursor.getLong(it) }
cursor.close()
}
} catch (e: Exception) {
callback.onFailure(e)
return@scanFile
}
callback.onSuccess(newFields)
}
}
override fun scanObsoletePath(context: Context, path: String, mimeType: String) {
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType), null)
}
suspend fun scanNewPath(context: Context, path: String, mimeType: String): FieldMap =
suspendCoroutine { cont ->
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, newUri: Uri? ->
fun scanUri(uri: Uri?): FieldMap? {
uri ?: return null
// we retrieve updated fields as the renamed/moved file became a new entry in the Media Store
val projection = arrayOf(
MediaStore.MediaColumns.DATE_MODIFIED,
MediaStore.MediaColumns.DISPLAY_NAME,
MediaStore.MediaColumns.TITLE,
)
try {
val cursor = context.contentResolver.query(uri, projection, null, null, null)
if (cursor != null && cursor.moveToFirst()) {
val newFields = HashMap<String, Any?>()
newFields["uri"] = uri.toString()
newFields["contentId"] = uri.tryParseId()
newFields["path"] = path
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) }
cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME).let { if (it != -1) newFields["displayName"] = cursor.getString(it) }
cursor.getColumnIndex(MediaStore.MediaColumns.TITLE).let { if (it != -1) newFields["title"] = cursor.getString(it) }
cursor.close()
return newFields
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to scan uri=$uri", e)
}
return null
}
if (newUri == null) {
cont.resumeWithException(Exception("failed to get URI of item at path=$path"))
return@scanFile
}
var contentUri: Uri? = null
// `newURI` is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872")
// but we need an image/video media URI (e.g. "content://media/external/images/media/62872")
val contentId = newUri.tryParseId()
if (contentId != null) {
if (isImage(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId)
} else if (isVideo(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId)
}
}
// prefer image/video content URI, fallback to original URI (possibly a file content URI)
val newFields = scanUri(contentUri) ?: scanUri(newUri)
if (newFields != null) {
cont.resume(newFields)
} else {
cont.resumeWithException(Exception("failed to get item details from provider at contentUri=$contentUri (from newUri=$newUri)"))
}
}
}
companion object { companion object {
private val LOG_TAG = LogUtils.createTag<MediaStoreImageProvider>() private val LOG_TAG = LogUtils.createTag<MediaStoreImageProvider>()

View file

@ -23,7 +23,7 @@ object MimeTypes {
// raw raster // raw raster
private const val ARW = "image/x-sony-arw" private const val ARW = "image/x-sony-arw"
private const val CR2 = "image/x-canon-cr2" private const val CR2 = "image/x-canon-cr2"
const val DNG = "image/x-adobe-dng" private const val DNG = "image/x-adobe-dng"
private const val NEF = "image/x-nikon-nef" private const val NEF = "image/x-nikon-nef"
private const val NRW = "image/x-nikon-nrw" private const val NRW = "image/x-nikon-nrw"
private const val ORF = "image/x-olympus-orf" private const val ORF = "image/x-olympus-orf"
@ -65,27 +65,46 @@ object MimeTypes {
} }
// as of Flutter v1.22.0, with additional custom handling for SVG // as of Flutter v1.22.0, with additional custom handling for SVG
fun isSupportedByFlutter(mimeType: String, rotationDegrees: Int?, isFlipped: Boolean?) = when (mimeType) { fun canDecodeWithFlutter(mimeType: String, rotationDegrees: Int?, isFlipped: Boolean?) = when (mimeType) {
JPEG, GIF, WEBP, BMP, WBMP, ICO, SVG -> true JPEG, GIF, WEBP, BMP, WBMP, ICO, SVG -> true
PNG -> rotationDegrees ?: 0 == 0 && !(isFlipped ?: false) PNG -> rotationDegrees ?: 0 == 0 && !(isFlipped ?: false)
else -> false else -> false
} }
// as of `metadata-extractor` v2.14.0 // as of `metadata-extractor` v2.14.0
fun isSupportedByMetadataExtractor(mimeType: String) = when (mimeType) { fun canReadWithMetadataExtractor(mimeType: String) = when (mimeType) {
DJVU, WBMP, MKV, MP2T, MP2TS, OGV, WEBM -> false DJVU, WBMP, MKV, MP2T, MP2TS, OGV, WEBM -> false
else -> true else -> true
} }
// as of `ExifInterface` v1.3.1, `isSupportedMimeType` reports // as of `ExifInterface` v1.3.1, `isSupportedMimeType` reports
// no support for TIFF images, but it can actually open them (maybe other formats too) // no support for TIFF images, but it can actually open them (maybe other formats too)
fun isSupportedByExifInterface(mimeType: String, strict: Boolean = true) = ExifInterface.isSupportedMimeType(mimeType) || !strict fun canReadWithExifInterface(mimeType: String, strict: Boolean = true) = ExifInterface.isSupportedMimeType(mimeType) || !strict
fun isSupportedByPixyMeta(mimeType: String) = when (mimeType) { // as of latest PixyMeta
fun canReadWithPixyMeta(mimeType: String) = when (mimeType) {
JPEG, TIFF, PNG, GIF, BMP -> true JPEG, TIFF, PNG, GIF, BMP -> true
else -> false else -> false
} }
// as of androidx.exifinterface:exifinterface:1.3.3
fun canEditExif(mimeType: String) = when (mimeType) {
DNG,
JPEG,
PNG,
WEBP -> true
else -> false
}
// as of latest PixyMeta
fun canEditXmp(mimeType: String) = canReadWithPixyMeta(mimeType)
// as of latest PixyMeta
fun canRemoveMetadata(mimeType: String) = when (mimeType) {
JPEG, TIFF -> true
else -> false
}
// Glide automatically applies EXIF orientation when decoding images of known formats // Glide automatically applies EXIF orientation when decoding images of known formats
// but we need to rotate the decoded bitmap for the other formats // but we need to rotate the decoded bitmap for the other formats
// maybe related to ExifInterface version used by Glide: // maybe related to ExifInterface version used by Glide:

View file

@ -1,12 +1,12 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
ext.kotlin_version = '1.5.30' ext.kotlin_version = '1.5.31'
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:7.0.1' classpath 'com.android.tools.build:gradle:7.0.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.google.gms:google-services:4.3.10' classpath 'com.google.gms:google-services:4.3.10'
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.7.1' classpath 'com.google.firebase:firebase-crashlytics-gradle:2.7.1'

View file

@ -2,7 +2,7 @@ import 'dart:async';
import 'dart:math'; import 'dart:math';
import 'dart:ui' as ui show Codec; import 'dart:ui' as ui show Codec;
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -33,7 +33,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
final mimeType = key.mimeType; final mimeType = key.mimeType;
final pageId = key.pageId; final pageId = key.pageId;
try { try {
final bytes = await imageFileService.getRegion( final bytes = await mediaFileService.getRegion(
uri, uri,
mimeType, mimeType,
key.rotationDegrees, key.rotationDegrees,
@ -56,11 +56,11 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
@override @override
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, RegionProviderKey key, ImageErrorListener handleError) { void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, RegionProviderKey key, ImageErrorListener handleError) {
imageFileService.resumeLoading(key); mediaFileService.resumeLoading(key);
super.resolveStreamForKey(configuration, stream, key, handleError); super.resolveStreamForKey(configuration, stream, key, handleError);
} }
void pause() => imageFileService.cancelRegion(key); void pause() => mediaFileService.cancelRegion(key);
} }
@immutable @immutable

View file

@ -1,6 +1,6 @@
import 'dart:ui' as ui show Codec; import 'dart:ui' as ui show Codec;
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -35,7 +35,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
final mimeType = key.mimeType; final mimeType = key.mimeType;
final pageId = key.pageId; final pageId = key.pageId;
try { try {
final bytes = await imageFileService.getThumbnail( final bytes = await mediaFileService.getThumbnail(
uri: uri, uri: uri,
mimeType: mimeType, mimeType: mimeType,
pageId: pageId, pageId: pageId,
@ -57,11 +57,11 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
@override @override
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, ThumbnailProviderKey key, ImageErrorListener handleError) { void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, ThumbnailProviderKey key, ImageErrorListener handleError) {
imageFileService.resumeLoading(key); mediaFileService.resumeLoading(key);
super.resolveStreamForKey(configuration, stream, key, handleError); super.resolveStreamForKey(configuration, stream, key, handleError);
} }
void pause() => imageFileService.cancelThumbnail(key); void pause() => mediaFileService.cancelThumbnail(key);
} }
@immutable @immutable

View file

@ -1,8 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:ui' as ui show Codec; import 'dart:ui' as ui show Codec;
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/utils/pedantic.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -50,7 +49,7 @@ class UriImage extends ImageProvider<UriImage> with EquatableMixin {
assert(key == this); assert(key == this);
try { try {
final bytes = await imageFileService.getImage( final bytes = await mediaFileService.getImage(
uri, uri,
mimeType, mimeType,
rotationDegrees, rotationDegrees,

View file

@ -3,7 +3,7 @@
"@appName": {}, "@appName": {},
"welcomeMessage": "Welcome to Aves", "welcomeMessage": "Welcome to Aves",
"@welcomeMessage": {}, "@welcomeMessage": {},
"welcomeCrashReportToggle": "Allow anonymous crash reporting (optional)", "welcomeCrashReportToggle": "Allow anonymous error reporting (optional)",
"@welcomeCrashReportToggle": {}, "@welcomeCrashReportToggle": {},
"welcomeTermsToggle": "I agree to the terms and conditions", "welcomeTermsToggle": "I agree to the terms and conditions",
"@welcomeTermsToggle": {}, "@welcomeTermsToggle": {},
@ -14,6 +14,19 @@
} }
}, },
"timeSeconds": "{seconds, plural, =1{1 second} other{{seconds} seconds}}",
"@timeSeconds": {
"placeholders": {
"seconds": {}
}
},
"timeMinutes": "{minutes, plural, =1{1 minute} other{{minutes} minutes}}",
"@timeMinutes": {
"placeholders": {
"minutes": {}
}
},
"applyButtonLabel": "APPLY", "applyButtonLabel": "APPLY",
"@applyButtonLabel": {}, "@applyButtonLabel": {},
"deleteButtonLabel": "DELETE", "deleteButtonLabel": "DELETE",
@ -103,7 +116,7 @@
"@entryActionOpen": {}, "@entryActionOpen": {},
"entryActionSetAs": "Set as…", "entryActionSetAs": "Set as…",
"@entryActionSetAs": {}, "@entryActionSetAs": {},
"entryActionOpenMap": "Show on map…", "entryActionOpenMap": "Show in map app…",
"@entryActionOpenMap": {}, "@entryActionOpenMap": {},
"entryActionRotateScreen": "Rotate screen", "entryActionRotateScreen": "Rotate screen",
"@entryActionRotateScreen": {}, "@entryActionRotateScreen": {},
@ -131,6 +144,8 @@
"entryInfoActionEditDate": "Edit date & time", "entryInfoActionEditDate": "Edit date & time",
"@entryInfoActionEditDate": {}, "@entryInfoActionEditDate": {},
"entryInfoActionRemoveMetadata": "Remove metadata",
"@entryInfoActionRemoveMetadata": {},
"filterFavouriteLabel": "Favourite", "filterFavouriteLabel": "Favourite",
"@filterFavouriteLabel": {}, "@filterFavouriteLabel": {},
@ -185,6 +200,11 @@
"keepScreenOnAlways": "Always", "keepScreenOnAlways": "Always",
"@keepScreenOnAlways": {}, "@keepScreenOnAlways": {},
"accessibilityAnimationsRemove": "Prevent screen effects",
"@accessibilityAnimationsRemove": {},
"accessibilityAnimationsKeep": "Keep screen effects",
"@accessibilityAnimationsKeep": {},
"albumTierNew": "New", "albumTierNew": "New",
"@albumTierNew": {}, "@albumTierNew": {},
"albumTierPinned": "Pinned", "albumTierPinned": "Pinned",
@ -325,6 +345,14 @@
"editEntryDateDialogMinutes": "Minutes", "editEntryDateDialogMinutes": "Minutes",
"@editEntryDateDialogMinutes": {}, "@editEntryDateDialogMinutes": {},
"removeEntryMetadataDialogTitle": "Metadata Removal",
"@removeEntryMetadataDialogTitle": {},
"removeEntryMetadataDialogMore": "More",
"@removeEntryMetadataDialogMore": {},
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP is required to play the video inside this motion photo. Are you sure you want to remove it?",
"@removeEntryMetadataMotionPhotoXmpWarningDialogMessage": {},
"videoSpeedDialogLabel": "Playback speed", "videoSpeedDialogLabel": "Playback speed",
"@videoSpeedDialogLabel": {}, "@videoSpeedDialogLabel": {},
@ -415,7 +443,7 @@
"@aboutLicensesFlutterPackages": {}, "@aboutLicensesFlutterPackages": {},
"aboutLicensesDartPackages": "Dart Packages", "aboutLicensesDartPackages": "Dart Packages",
"@aboutLicensesDartPackages": {}, "@aboutLicensesDartPackages": {},
"aboutLicensesShowAllButtonLabel": "SHOW ALL LICENSES", "aboutLicensesShowAllButtonLabel": "Show All Licenses",
"@aboutLicensesShowAllButtonLabel": {}, "@aboutLicensesShowAllButtonLabel": {},
"collectionPageTitle": "Collection", "collectionPageTitle": "Collection",
@ -435,8 +463,8 @@
"@collectionActionCopy": {}, "@collectionActionCopy": {},
"collectionActionMove": "Move to album", "collectionActionMove": "Move to album",
"@collectionActionMove": {}, "@collectionActionMove": {},
"collectionActionRefreshMetadata": "Refresh metadata", "collectionActionRescan": "Rescan",
"@collectionActionRefreshMetadata": {}, "@collectionActionRescan": {},
"collectionSortTitle": "Sort", "collectionSortTitle": "Sort",
"@collectionSortTitle": {}, "@collectionSortTitle": {},
@ -606,6 +634,8 @@
"@settingsPageTitle": {}, "@settingsPageTitle": {},
"settingsSystemDefault": "System", "settingsSystemDefault": "System",
"@settingsSystemDefault": {}, "@settingsSystemDefault": {},
"settingsDefault": "Default",
"@settingsDefault": {},
"settingsActionExport": "Export", "settingsActionExport": "Export",
"@settingsActionExport": {}, "@settingsActionExport": {},
@ -642,6 +672,8 @@
"@settingsSectionThumbnails": {}, "@settingsSectionThumbnails": {},
"settingsThumbnailShowLocationIcon": "Show location icon", "settingsThumbnailShowLocationIcon": "Show location icon",
"@settingsThumbnailShowLocationIcon": {}, "@settingsThumbnailShowLocationIcon": {},
"settingsThumbnailShowMotionPhotoIcon": "Show motion photo icon",
"@settingsThumbnailShowMotionPhotoIcon": {},
"settingsThumbnailShowRawIcon": "Show raw icon", "settingsThumbnailShowRawIcon": "Show raw icon",
"@settingsThumbnailShowRawIcon": {}, "@settingsThumbnailShowRawIcon": {},
"settingsThumbnailShowVideoDuration": "Show video duration", "settingsThumbnailShowVideoDuration": "Show video duration",
@ -734,8 +766,8 @@
"settingsSectionPrivacy": "Privacy", "settingsSectionPrivacy": "Privacy",
"@settingsSectionPrivacy": {}, "@settingsSectionPrivacy": {},
"settingsEnableCrashReport": "Allow anonymous error reporting", "settingsEnableErrorReporting": "Allow anonymous error reporting",
"@settingsEnableCrashReport": {}, "@settingsEnableErrorReporting": {},
"settingsSaveSearchHistory": "Save search history", "settingsSaveSearchHistory": "Save search history",
"@settingsSaveSearchHistory": {}, "@settingsSaveSearchHistory": {},
@ -770,6 +802,17 @@
"settingsStorageAccessRevokeTooltip": "Revoke", "settingsStorageAccessRevokeTooltip": "Revoke",
"@settingsStorageAccessRevokeTooltip": {}, "@settingsStorageAccessRevokeTooltip": {},
"settingsSectionAccessibility": "Accessibility",
"@settingsSectionAccessibility": {},
"settingsRemoveAnimationsTile": "Remove animations",
"@settingsRemoveAnimationsTile": {},
"settingsRemoveAnimationsTitle": "Remove Animations",
"@settingsRemoveAnimationsTitle": {},
"settingsTimeToTakeActionTile": "Time to take action",
"@settingsTimeToTakeActionTile": {},
"settingsTimeToTakeActionTitle": "Time to Take Action",
"@settingsTimeToTakeActionTitle": {},
"settingsSectionLanguage": "Language & Formats", "settingsSectionLanguage": "Language & Formats",
"@settingsSectionLanguage": {}, "@settingsSectionLanguage": {},
"settingsLanguage": "Language", "settingsLanguage": "Language",
@ -779,9 +822,6 @@
"settingsCoordinateFormatTitle": "Coordinate Format", "settingsCoordinateFormatTitle": "Coordinate Format",
"@settingsCoordinateFormatTitle": {}, "@settingsCoordinateFormatTitle": {},
"mapPageTitle": "Map",
"@mapPageTitle": {},
"statsPageTitle": "Stats", "statsPageTitle": "Stats",
"@statsPageTitle": {}, "@statsPageTitle": {},
"statsImage": "{count, plural, =1{image} other{images}}", "statsImage": "{count, plural, =1{image} other{images}}",
@ -846,18 +886,24 @@
"viewerInfoLabelAddress": "Address", "viewerInfoLabelAddress": "Address",
"@viewerInfoLabelAddress": {}, "@viewerInfoLabelAddress": {},
"viewerInfoMapStyleTitle": "Map Style", "mapStyleTitle": "Map Style",
"@viewerInfoMapStyleTitle": {}, "@mapStyleTitle": {},
"viewerInfoMapStyleTooltip": "Select map style", "mapStyleTooltip": "Select map style",
"@viewerInfoMapStyleTooltip": {}, "@mapStyleTooltip": {},
"viewerInfoMapZoomInTooltip": "Zoom in", "mapZoomInTooltip": "Zoom in",
"@viewerInfoMapZoomInTooltip": {}, "@mapZoomInTooltip": {},
"viewerInfoMapZoomOutTooltip": "Zoom out", "mapZoomOutTooltip": "Zoom out",
"@viewerInfoMapZoomOutTooltip": {}, "@mapZoomOutTooltip": {},
"mapPointNorthUpTooltip": "Point north up",
"@mapPointNorthUpTooltip": {},
"mapAttributionOsmHot": "Map data © [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors • Tiles by [HOT](https://www.hotosm.org/) • Hosted by [OSM France](https://openstreetmap.fr/)", "mapAttributionOsmHot": "Map data © [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors • Tiles by [HOT](https://www.hotosm.org/) • Hosted by [OSM France](https://openstreetmap.fr/)",
"@mapAttributionOsmHot": {}, "@mapAttributionOsmHot": {},
"mapAttributionStamen": "Map data © [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors • Tiles by [Stamen Design](http://stamen.com), [CC BY 3.0](http://creativecommons.org/licenses/by/3.0)", "mapAttributionStamen": "Map data © [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors • Tiles by [Stamen Design](http://stamen.com), [CC BY 3.0](http://creativecommons.org/licenses/by/3.0)",
"@mapAttributionStamen": {}, "@mapAttributionStamen": {},
"openMapPageTooltip": "View on Map page",
"@openMapPageTooltip": {},
"mapEmptyRegion": "No images in this region",
"@mapEmpty": {},
"viewerInfoOpenEmbeddedFailureFeedback": "Failed to extract embedded data", "viewerInfoOpenEmbeddedFailureFeedback": "Failed to extract embedded data",
"@viewerInfoOpenEmbeddedFailureFeedback": {}, "@viewerInfoOpenEmbeddedFailureFeedback": {},

View file

@ -5,6 +5,9 @@
"welcomeTermsToggle": "이용약관에 동의합니다", "welcomeTermsToggle": "이용약관에 동의합니다",
"itemCount": "{count, plural, other{{count}개}}", "itemCount": "{count, plural, other{{count}개}}",
"timeSeconds": "{seconds, plural, other{{seconds}초}}",
"timeMinutes": "{minutes, plural, other{{minutes}분}}",
"applyButtonLabel": "확인", "applyButtonLabel": "확인",
"deleteButtonLabel": "삭제", "deleteButtonLabel": "삭제",
"nextButtonLabel": "다음", "nextButtonLabel": "다음",
@ -52,7 +55,7 @@
"entryActionEdit": "편집…", "entryActionEdit": "편집…",
"entryActionOpen": "다른 앱에서 열기…", "entryActionOpen": "다른 앱에서 열기…",
"entryActionSetAs": "다음 용도로 사용…", "entryActionSetAs": "다음 용도로 사용…",
"entryActionOpenMap": "지도에서 보기…", "entryActionOpenMap": "지도에서 보기…",
"entryActionRotateScreen": "화면 회전", "entryActionRotateScreen": "화면 회전",
"entryActionAddFavourite": "즐겨찾기에 추가", "entryActionAddFavourite": "즐겨찾기에 추가",
"entryActionRemoveFavourite": "즐겨찾기에서 삭제", "entryActionRemoveFavourite": "즐겨찾기에서 삭제",
@ -67,6 +70,7 @@
"videoActionSettings": "설정", "videoActionSettings": "설정",
"entryInfoActionEditDate": "날짜와 시간 수정", "entryInfoActionEditDate": "날짜와 시간 수정",
"entryInfoActionRemoveMetadata": "메타데이터 삭제",
"filterFavouriteLabel": "즐겨찾기", "filterFavouriteLabel": "즐겨찾기",
"filterLocationEmptyLabel": "장소 없음", "filterLocationEmptyLabel": "장소 없음",
@ -97,6 +101,9 @@
"keepScreenOnViewerOnly": "뷰어 이용 시 작동", "keepScreenOnViewerOnly": "뷰어 이용 시 작동",
"keepScreenOnAlways": "항상 켜짐", "keepScreenOnAlways": "항상 켜짐",
"accessibilityAnimationsRemove": "화면 효과 제한",
"accessibilityAnimationsKeep": "화면 효과 유지",
"albumTierNew": "신규", "albumTierNew": "신규",
"albumTierPinned": "고정", "albumTierPinned": "고정",
"albumTierSpecial": "기본", "albumTierSpecial": "기본",
@ -149,6 +156,11 @@
"editEntryDateDialogHours": "시간", "editEntryDateDialogHours": "시간",
"editEntryDateDialogMinutes": "분", "editEntryDateDialogMinutes": "분",
"removeEntryMetadataDialogTitle": "메타데이터 삭제",
"removeEntryMetadataDialogMore": "더 보기",
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP가 있어야 모션 포토에 포함되는 동영상을 재생할 수 있습니다. 삭제하시겠습니까?",
"videoSpeedDialogLabel": "재생 배속", "videoSpeedDialogLabel": "재생 배속",
"videoStreamSelectionDialogVideo": "동영상", "videoStreamSelectionDialogVideo": "동영상",
@ -207,7 +219,7 @@
"collectionActionAddShortcut": "홈 화면에 추가", "collectionActionAddShortcut": "홈 화면에 추가",
"collectionActionCopy": "앨범으로 복사", "collectionActionCopy": "앨범으로 복사",
"collectionActionMove": "앨범으로 이동", "collectionActionMove": "앨범으로 이동",
"collectionActionRefreshMetadata": "새로 분석", "collectionActionRescan": "새로 분석",
"collectionSortTitle": "정렬", "collectionSortTitle": "정렬",
"collectionSortDate": "날짜", "collectionSortDate": "날짜",
@ -288,6 +300,7 @@
"settingsPageTitle": "설정", "settingsPageTitle": "설정",
"settingsSystemDefault": "시스템", "settingsSystemDefault": "시스템",
"settingsDefault": "기본",
"settingsActionExport": "내보내기", "settingsActionExport": "내보내기",
"settingsActionImport": "가져오기", "settingsActionImport": "가져오기",
@ -308,6 +321,7 @@
"settingsSectionThumbnails": "섬네일", "settingsSectionThumbnails": "섬네일",
"settingsThumbnailShowLocationIcon": "위치 아이콘 표시", "settingsThumbnailShowLocationIcon": "위치 아이콘 표시",
"settingsThumbnailShowMotionPhotoIcon": "모션 포토 아이콘 표시",
"settingsThumbnailShowRawIcon": "Raw 아이콘 표시", "settingsThumbnailShowRawIcon": "Raw 아이콘 표시",
"settingsThumbnailShowVideoDuration": "동영상 길이 표시", "settingsThumbnailShowVideoDuration": "동영상 길이 표시",
@ -357,7 +371,7 @@
"settingsSubtitleThemeTextAlignmentRight": "오른쪽", "settingsSubtitleThemeTextAlignmentRight": "오른쪽",
"settingsSectionPrivacy": "개인정보 보호", "settingsSectionPrivacy": "개인정보 보호",
"settingsEnableCrashReport": "오류 보고서 보내기", "settingsEnableErrorReporting": "오류 보고서 보내기",
"settingsSaveSearchHistory": "검색기록", "settingsSaveSearchHistory": "검색기록",
"settingsHiddenFiltersTile": "숨겨진 필터", "settingsHiddenFiltersTile": "숨겨진 필터",
@ -377,13 +391,17 @@
"settingsStorageAccessEmpty": "접근 허용이 없습니다", "settingsStorageAccessEmpty": "접근 허용이 없습니다",
"settingsStorageAccessRevokeTooltip": "취소", "settingsStorageAccessRevokeTooltip": "취소",
"settingsSectionAccessibility": "접근성",
"settingsRemoveAnimationsTile": "애니메이션 삭제",
"settingsRemoveAnimationsTitle": "애니메이션 삭제",
"settingsTimeToTakeActionTile": "액션 취하기 전 대기 시간",
"settingsTimeToTakeActionTitle": "액션 취하기 전 대기 시간",
"settingsSectionLanguage": "언어 및 표시 형식", "settingsSectionLanguage": "언어 및 표시 형식",
"settingsLanguage": "언어", "settingsLanguage": "언어",
"settingsCoordinateFormatTile": "좌표 표현", "settingsCoordinateFormatTile": "좌표 표현",
"settingsCoordinateFormatTitle": "좌표 표현", "settingsCoordinateFormatTitle": "좌표 표현",
"mapPageTitle": "지도",
"statsPageTitle": "통계", "statsPageTitle": "통계",
"statsImage": "{count, plural, other{사진}}", "statsImage": "{count, plural, other{사진}}",
"statsVideo": "{count, plural, other{동영상}}", "statsVideo": "{count, plural, other{동영상}}",
@ -412,12 +430,15 @@
"viewerInfoLabelCoordinates": "좌표", "viewerInfoLabelCoordinates": "좌표",
"viewerInfoLabelAddress": "주소", "viewerInfoLabelAddress": "주소",
"viewerInfoMapStyleTitle": "지도 유형", "mapStyleTitle": "지도 유형",
"viewerInfoMapStyleTooltip": "지도 유형 선택", "mapStyleTooltip": "지도 유형 선택",
"viewerInfoMapZoomInTooltip": "확대", "mapZoomInTooltip": "확대",
"viewerInfoMapZoomOutTooltip": "축소", "mapZoomOutTooltip": "축소",
"mapPointNorthUpTooltip": "북쪽을 위로 가리키기",
"mapAttributionOsmHot": "지도 데이터 © [OpenStreetMap](https://www.openstreetmap.org/copyright) 기여자 • 타일 [HOT](https://www.hotosm.org/) • 호스팅 [OSM France](https://openstreetmap.fr/)", "mapAttributionOsmHot": "지도 데이터 © [OpenStreetMap](https://www.openstreetmap.org/copyright) 기여자 • 타일 [HOT](https://www.hotosm.org/) • 호스팅 [OSM France](https://openstreetmap.fr/)",
"mapAttributionStamen": "지도 데이터 © [OpenStreetMap](https://www.openstreetmap.org/copyright) 기여자 • 타일 [Stamen Design](http://stamen.com), [CC BY 3.0](http://creativecommons.org/licenses/by/3.0)", "mapAttributionStamen": "지도 데이터 © [OpenStreetMap](https://www.openstreetmap.org/copyright) 기여자 • 타일 [Stamen Design](http://stamen.com), [CC BY 3.0](http://creativecommons.org/licenses/by/3.0)",
"openMapPageTooltip": "지도 페이지에서 보기",
"mapEmptyRegion": "이 지역의 사진이 없습니다",
"viewerInfoOpenEmbeddedFailureFeedback": "첨부 데이터 추출 오류", "viewerInfoOpenEmbeddedFailureFeedback": "첨부 데이터 추출 오류",
"viewerInfoOpenLinkText": "열기", "viewerInfoOpenLinkText": "열기",

View file

@ -1,6 +1,6 @@
import 'dart:isolate'; import 'dart:isolate';
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/widgets/aves_app.dart'; import 'package:aves/widgets/aves_app.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';

View file

@ -1,3 +1,4 @@
enum EntryInfoAction { enum EntryInfoAction {
editDate, editDate,
removeMetadata,
} }

View file

@ -19,7 +19,7 @@ enum EntrySetAction {
delete, delete,
copy, copy,
move, move,
refreshMetadata, rescan,
} }
class EntrySetActions { class EntrySetActions {
@ -28,7 +28,7 @@ class EntrySetActions {
EntrySetAction.delete, EntrySetAction.delete,
EntrySetAction.copy, EntrySetAction.copy,
EntrySetAction.move, EntrySetAction.move,
EntrySetAction.refreshMetadata, EntrySetAction.rescan,
EntrySetAction.map, EntrySetAction.map,
EntrySetAction.stats, EntrySetAction.stats,
]; ];
@ -65,8 +65,8 @@ extension ExtraEntrySetAction on EntrySetAction {
return context.l10n.collectionActionCopy; return context.l10n.collectionActionCopy;
case EntrySetAction.move: case EntrySetAction.move:
return context.l10n.collectionActionMove; return context.l10n.collectionActionMove;
case EntrySetAction.refreshMetadata: case EntrySetAction.rescan:
return context.l10n.collectionActionRefreshMetadata; return context.l10n.collectionActionRescan;
} }
} }
@ -104,7 +104,7 @@ extension ExtraEntrySetAction on EntrySetAction {
return AIcons.copy; return AIcons.copy;
case EntrySetAction.move: case EntrySetAction.move:
return AIcons.move; return AIcons.move;
case EntrySetAction.refreshMetadata: case EntrySetAction.rescan:
return AIcons.refresh; return AIcons.refresh;
} }
} }

View file

@ -1,7 +1,7 @@
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';

View file

@ -6,14 +6,15 @@ import 'package:aves/model/favourites.dart';
import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/address.dart';
import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/metadata/catalog.dart';
import 'package:aves/model/metadata/date_modifier.dart'; import 'package:aves/model/metadata/date_modifier.dart';
import 'package:aves/model/metadata/enums.dart';
import 'package:aves/model/multipage.dart'; import 'package:aves/model/multipage.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/video/metadata.dart'; import 'package:aves/model/video/metadata.dart';
import 'package:aves/ref/mime_types.dart'; import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/common/service_policy.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/services/geocoding_service.dart'; import 'package:aves/services/geocoding_service.dart';
import 'package:aves/services/service_policy.dart'; import 'package:aves/services/metadata/svg_metadata_service.dart';
import 'package:aves/services/services.dart';
import 'package:aves/services/svg_metadata_service.dart';
import 'package:aves/theme/format.dart'; import 'package:aves/theme/format.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -35,7 +36,7 @@ class AvesEntry {
// `dateModifiedSecs` can be missing in viewer mode // `dateModifiedSecs` can be missing in viewer mode
int? _dateModifiedSecs; int? _dateModifiedSecs;
final int? sourceDateTakenMillis; int? sourceDateTakenMillis;
int? _durationMillis; int? _durationMillis;
int? _catalogDateMillis; int? _catalogDateMillis;
CatalogMetadata? _catalogMetadata; CatalogMetadata? _catalogMetadata;
@ -230,7 +231,6 @@ class AvesEntry {
bool get canRotateAndFlip => canEdit && canEditExif; bool get canRotateAndFlip => canEdit && canEditExif;
// support for writing EXIF
// as of androidx.exifinterface:exifinterface:1.3.3 // as of androidx.exifinterface:exifinterface:1.3.3
bool get canEditExif { bool get canEditExif {
switch (mimeType.toLowerCase()) { switch (mimeType.toLowerCase()) {
@ -244,6 +244,17 @@ class AvesEntry {
} }
} }
// as of latest PixyMeta
bool get canRemoveMetadata {
switch (mimeType.toLowerCase()) {
case MimeTypes.jpeg:
case MimeTypes.tiff:
return true;
default:
return false;
}
}
// Media Store size/rotation is inaccurate, e.g. a portrait FHD video is rotated according to its metadata, // Media Store size/rotation is inaccurate, e.g. a portrait FHD video is rotated according to its metadata,
// so it should be registered as width=1920, height=1080, orientation=90, // so it should be registered as width=1920, height=1080, orientation=90,
// but is incorrectly registered as width=1080, height=1920, orientation=0. // but is incorrectly registered as width=1080, height=1920, orientation=0.
@ -339,11 +350,13 @@ class AvesEntry {
_bestDate = null; _bestDate = null;
} }
// TODO TLAD cache _monthTaken
DateTime? get monthTaken { DateTime? get monthTaken {
final d = bestDate; final d = bestDate;
return d == null ? null : DateTime(d.year, d.month); return d == null ? null : DateTime(d.year, d.month);
} }
// TODO TLAD cache _dayTaken
DateTime? get dayTaken { DateTime? get dayTaken {
final d = bestDate; final d = bestDate;
return d == null ? null : DateTime(d.year, d.month, d.day); return d == null ? null : DateTime(d.year, d.month, d.day);
@ -434,7 +447,7 @@ class AvesEntry {
final fields = await VideoMetadataFormatter.getLoadingMetadata(this); final fields = await VideoMetadataFormatter.getLoadingMetadata(this);
await _applyNewFields(fields, persist: persist); await _applyNewFields(fields, persist: persist);
} }
catalogMetadata = await metadataService.getCatalogMetadata(this, background: background); catalogMetadata = await metadataFetchService.getCatalogMetadata(this, background: background);
if (isVideo && (catalogMetadata?.dateMillis ?? 0) == 0) { if (isVideo && (catalogMetadata?.dateMillis ?? 0) == 0) {
catalogMetadata = await VideoMetadataFormatter.getCatalogMetadata(this); catalogMetadata = await VideoMetadataFormatter.getCatalogMetadata(this);
@ -551,8 +564,13 @@ class AvesEntry {
if (path is String) this.path = path; if (path is String) this.path = path;
final contentId = newFields['contentId']; final contentId = newFields['contentId'];
if (contentId is int) this.contentId = contentId; if (contentId is int) this.contentId = contentId;
final sourceTitle = newFields['title']; final sourceTitle = newFields['title'];
if (sourceTitle is String) this.sourceTitle = sourceTitle; if (sourceTitle is String) this.sourceTitle = sourceTitle;
final sourceRotationDegrees = newFields['sourceRotationDegrees'];
if (sourceRotationDegrees is int) this.sourceRotationDegrees = sourceRotationDegrees;
final sourceDateTakenMillis = newFields['sourceDateTakenMillis'];
if (sourceDateTakenMillis is int) this.sourceDateTakenMillis = sourceDateTakenMillis;
final width = newFields['width']; final width = newFields['width'];
if (width is int) this.width = width; if (width is int) this.width = width;
@ -578,8 +596,26 @@ class AvesEntry {
metadataChangeNotifier.notifyListeners(); metadataChangeNotifier.notifyListeners();
} }
Future<void> refresh({required bool persist}) async {
_catalogMetadata = null;
_addressDetails = null;
_bestDate = null;
_bestTitle = null;
_xmpSubjects = null;
if (persist) {
await metadataDb.removeIds({contentId!}, metadataOnly: true);
}
final updated = await mediaFileService.getEntry(uri, mimeType);
if (updated != null) {
await _applyNewFields(updated.toMap(), persist: persist);
await catalog(background: false, persist: persist);
await locate(background: false);
}
}
Future<bool> rotate({required bool clockwise, required bool persist}) async { Future<bool> rotate({required bool clockwise, required bool persist}) async {
final newFields = await imageFileService.rotate(this, clockwise: clockwise); final newFields = await metadataEditService.rotate(this, clockwise: clockwise);
if (newFields.isEmpty) return false; if (newFields.isEmpty) return false;
final oldDateModifiedSecs = dateModifiedSecs; final oldDateModifiedSecs = dateModifiedSecs;
@ -591,7 +627,7 @@ class AvesEntry {
} }
Future<bool> flip({required bool persist}) async { Future<bool> flip({required bool persist}) async {
final newFields = await imageFileService.flip(this); final newFields = await metadataEditService.flip(this);
if (newFields.isEmpty) return false; if (newFields.isEmpty) return false;
final oldDateModifiedSecs = dateModifiedSecs; final oldDateModifiedSecs = dateModifiedSecs;
@ -602,18 +638,19 @@ class AvesEntry {
return true; return true;
} }
Future<bool> editDate(DateModifier modifier, {required bool persist}) async { Future<bool> editDate(DateModifier modifier) async {
final newFields = await imageFileService.editDate(this, modifier); final newFields = await metadataEditService.editDate(this, modifier);
if (newFields.isEmpty) return false; return newFields.isNotEmpty;
}
await _applyNewFields(newFields, persist: persist); Future<bool> removeMetadata(Set<MetadataType> types) async {
await catalog(background: false, persist: persist, force: true); final newFields = await metadataEditService.removeTypes(this, types);
return true; return newFields.isNotEmpty;
} }
Future<bool> delete() { Future<bool> delete() {
final completer = Completer<bool>(); final completer = Completer<bool>();
imageFileService.delete([this]).listen( mediaFileService.delete([this]).listen(
(event) => completer.complete(event.success), (event) => completer.complete(event.success),
onError: completer.completeError, onError: completer.completeError,
onDone: () { onDone: () {
@ -694,7 +731,7 @@ class AvesEntry {
.toList(), .toList(),
); );
} else { } else {
return await metadataService.getMultiPageInfo(this); return await metadataFetchService.getMultiPageInfo(this);
} }
} }

View file

@ -1,5 +1,5 @@
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';

View file

@ -1,6 +1,6 @@
import 'package:aves/image_providers/app_icon_image_provider.dart'; import 'package:aves/image_providers/app_icon_image_provider.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/common/identity/aves_icons.dart'; import 'package:aves/widgets/common/identity/aves_icons.dart';

View file

@ -19,6 +19,11 @@ class QueryFilter extends CollectionFilter {
QueryFilter(this.query, {this.colorful = true}) { QueryFilter(this.query, {this.colorful = true}) {
var upQuery = query.toUpperCase(); var upQuery = query.toUpperCase();
if (upQuery.startsWith('ID=')) {
final id = int.tryParse(upQuery.substring(3));
_test = (entry) => entry.contentId == id;
return;
}
// allow NOT queries starting with `-` // allow NOT queries starting with `-`
final not = upQuery.startsWith('-'); final not = upQuery.startsWith('-');

View file

@ -10,3 +10,72 @@ enum DateEditAction {
shift, shift,
clear, clear,
} }
enum MetadataType {
// Exif: https://en.wikipedia.org/wiki/Exif
exif,
// ICC profile: https://en.wikipedia.org/wiki/ICC_profile
iccProfile,
// IPTC: https://en.wikipedia.org/wiki/IPTC_Information_Interchange_Model
iptc,
// JPEG APP0 / JFIF: https://en.wikipedia.org/wiki/JPEG_File_Interchange_Format
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/
photoshopIrb,
// XMP: https://en.wikipedia.org/wiki/Extensible_Metadata_Platform
xmp,
}
class MetadataTypes {
static const main = {
MetadataType.exif,
MetadataType.xmp,
};
static const common = {
MetadataType.exif,
MetadataType.xmp,
MetadataType.iccProfile,
MetadataType.iptc,
MetadataType.photoshopIrb,
};
static const jpeg = {
MetadataType.jfif,
MetadataType.jpegAdobe,
MetadataType.jpegComment,
MetadataType.jpegDucky,
};
}
extension ExtraMetadataType on MetadataType {
// match `ExifInterface` directory names
String getText() {
switch (this) {
case MetadataType.exif:
return 'Exif';
case MetadataType.iccProfile:
return 'ICC Profile';
case MetadataType.iptc:
return 'IPTC';
case MetadataType.jfif:
return 'JFIF';
case MetadataType.jpegAdobe:
return 'Adobe JPEG';
case MetadataType.jpegComment:
return 'JpegComment';
case MetadataType.jpegDucky:
return 'Ducky';
case MetadataType.photoshopIrb:
return 'Photoshop';
case MetadataType.xmp:
return 'XMP';
}
}
}

View file

@ -7,7 +7,7 @@ import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/address.dart';
import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/metadata/catalog.dart';
import 'package:aves/model/metadata_db_upgrade.dart'; import 'package:aves/model/metadata_db_upgrade.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
@ -171,7 +171,7 @@ class SqfliteMetadataDb implements MetadataDb {
Future<void> removeIds(Set<int> contentIds, {required bool metadataOnly}) async { Future<void> removeIds(Set<int> contentIds, {required bool metadataOnly}) async {
if (contentIds.isEmpty) return; if (contentIds.isEmpty) return;
final stopwatch = Stopwatch()..start(); // final stopwatch = Stopwatch()..start();
final db = await _database; final db = await _database;
// using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead // using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead
final batch = db.batch(); final batch = db.batch();
@ -188,7 +188,7 @@ class SqfliteMetadataDb implements MetadataDb {
} }
}); });
await batch.commit(noResult: true); await batch.commit(noResult: true);
debugPrint('$runtimeType removeIds complete in ${stopwatch.elapsed.inMilliseconds}ms for ${contentIds.length} entries'); // debugPrint('$runtimeType removeIds complete in ${stopwatch.elapsed.inMilliseconds}ms for ${contentIds.length} entries');
} }
// entries // entries
@ -202,11 +202,11 @@ class SqfliteMetadataDb implements MetadataDb {
@override @override
Future<Set<AvesEntry>> loadEntries() async { Future<Set<AvesEntry>> loadEntries() async {
final stopwatch = Stopwatch()..start(); // final stopwatch = Stopwatch()..start();
final db = await _database; final db = await _database;
final maps = await db.query(entryTable); final maps = await db.query(entryTable);
final entries = maps.map((map) => AvesEntry.fromMap(map)).toSet(); final entries = maps.map((map) => AvesEntry.fromMap(map)).toSet();
debugPrint('$runtimeType loadEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries'); // debugPrint('$runtimeType loadEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries');
return entries; return entries;
} }

View file

@ -1,6 +1,6 @@
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/ref/mime_types.dart'; import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';

View file

@ -0,0 +1,31 @@
import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart';
import 'enums.dart';
extension ExtraAccessibilityAnimations on AccessibilityAnimations {
String getName(BuildContext context) {
switch (this) {
case AccessibilityAnimations.system:
return context.l10n.settingsSystemDefault;
case AccessibilityAnimations.disabled:
return context.l10n.accessibilityAnimationsRemove;
case AccessibilityAnimations.enabled:
return context.l10n.accessibilityAnimationsKeep;
}
}
bool get animate {
switch (this) {
case AccessibilityAnimations.system:
// as of Flutter v2.5.1, the check for `disableAnimations` is unreliable
// so we cannot use `window.accessibilityFeatures.disableAnimations` nor `MediaQuery.of(context).disableAnimations`
return !settings.areAnimationsRemoved;
case AccessibilityAnimations.disabled:
return false;
case AccessibilityAnimations.enabled:
return true;
}
}
}

View file

@ -0,0 +1,23 @@
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart';
import 'enums.dart';
extension ExtraAccessibilityTimeout on AccessibilityTimeout {
String getName(BuildContext context) {
switch (this) {
case AccessibilityTimeout.system:
return context.l10n.settingsSystemDefault;
case AccessibilityTimeout.appDefault:
return context.l10n.settingsDefault;
case AccessibilityTimeout.s10:
return context.l10n.timeSeconds(10);
case AccessibilityTimeout.s30:
return context.l10n.timeSeconds(30);
case AccessibilityTimeout.s60:
return context.l10n.timeMinutes(1);
case AccessibilityTimeout.s120:
return context.l10n.timeMinutes(2);
}
}
}

View file

@ -12,8 +12,6 @@ extension ExtraCoordinateFormat on CoordinateFormat {
return context.l10n.coordinateFormatDms; return context.l10n.coordinateFormatDms;
case CoordinateFormat.decimal: case CoordinateFormat.decimal:
return context.l10n.coordinateFormatDecimal; return context.l10n.coordinateFormatDecimal;
default:
return toString();
} }
} }
@ -23,8 +21,6 @@ extension ExtraCoordinateFormat on CoordinateFormat {
return toDMS(latLng).join(', '); return toDMS(latLng).join(', ');
case CoordinateFormat.decimal: case CoordinateFormat.decimal:
return [latLng.latitude, latLng.longitude].map((n) => n.toStringAsFixed(6)).join(', '); return [latLng.latitude, latLng.longitude].map((n) => n.toStringAsFixed(6)).join(', ');
default:
return toString();
} }
} }
} }

View file

@ -13,7 +13,7 @@ import 'package:flutter/material.dart';
class SettingsDefaults { class SettingsDefaults {
// app // app
static const hasAcceptedTerms = false; static const hasAcceptedTerms = false;
static const isCrashlyticsEnabled = false; static const isErrorReportingEnabled = false;
static const mustBackTwiceToExit = true; static const mustBackTwiceToExit = true;
static const keepScreenOn = KeepScreenOn.viewerOnly; static const keepScreenOn = KeepScreenOn.viewerOnly;
static const homePage = HomePageSetting.collection; static const homePage = HomePageSetting.collection;
@ -38,6 +38,7 @@ class SettingsDefaults {
EntrySetAction.delete, EntrySetAction.delete,
]; ];
static const showThumbnailLocation = true; static const showThumbnailLocation = true;
static const showThumbnailMotionPhoto = true;
static const showThumbnailRaw = true; static const showThumbnailRaw = true;
static const showThumbnailVideoDuration = true; static const showThumbnailVideoDuration = true;
@ -56,7 +57,7 @@ class SettingsDefaults {
static const showOverlayMinimap = false; static const showOverlayMinimap = false;
static const showOverlayInfo = true; static const showOverlayInfo = true;
static const showOverlayShootingDetails = false; static const showOverlayShootingDetails = false;
static const enableOverlayBlurEffect = true; static const enableOverlayBlurEffect = true; // `enableOverlayBlurEffect` has a contextual default value
static const viewerUseCutout = true; static const viewerUseCutout = true;
// video // video
@ -77,7 +78,7 @@ class SettingsDefaults {
static const subtitleBackgroundColor = Colors.transparent; static const subtitleBackgroundColor = Colors.transparent;
// info // info
static const infoMapStyle = EntryMapStyle.stamenWatercolor; static const infoMapStyle = EntryMapStyle.stamenWatercolor; // `infoMapStyle` has a contextual default value
static const infoMapZoom = 12.0; static const infoMapZoom = 12.0;
static const coordinateFormat = CoordinateFormat.dms; static const coordinateFormat = CoordinateFormat.dms;
@ -86,4 +87,8 @@ class SettingsDefaults {
// search // search
static const saveSearchHistory = true; static const saveSearchHistory = true;
// accessibility
static const accessibilityAnimations = AccessibilityAnimations.system;
static const timeToTakeAction = AccessibilityTimeout.appDefault; // `timeToTakeAction` has a contextual default value
} }

View file

@ -1,5 +1,9 @@
enum CoordinateFormat { dms, decimal } enum CoordinateFormat { dms, decimal }
enum AccessibilityAnimations { system, disabled, enabled }
enum AccessibilityTimeout { system, appDefault, s10, s30, s60, s120 }
enum EntryBackground { black, white, checkered } enum EntryBackground { black, white, checkered }
enum HomePageSetting { collection, albums } enum HomePageSetting { collection, albums }

View file

@ -12,8 +12,6 @@ extension ExtraHomePageSetting on HomePageSetting {
return context.l10n.collectionPageTitle; return context.l10n.collectionPageTitle;
case HomePageSetting.albums: case HomePageSetting.albums:
return context.l10n.albumPageTitle; return context.l10n.albumPageTitle;
default:
return toString();
} }
} }
@ -23,8 +21,6 @@ extension ExtraHomePageSetting on HomePageSetting {
return CollectionPage.routeName; return CollectionPage.routeName;
case HomePageSetting.albums: case HomePageSetting.albums:
return AlbumListPage.routeName; return AlbumListPage.routeName;
default:
return toString();
} }
} }
} }

View file

@ -18,8 +18,6 @@ extension ExtraEntryMapStyle on EntryMapStyle {
return context.l10n.mapStyleStamenToner; return context.l10n.mapStyleStamenToner;
case EntryMapStyle.stamenWatercolor: case EntryMapStyle.stamenWatercolor:
return context.l10n.mapStyleStamenWatercolor; return context.l10n.mapStyleStamenWatercolor;
default:
return toString();
} }
} }

View file

@ -1,4 +1,4 @@
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -13,8 +13,6 @@ extension ExtraKeepScreenOn on KeepScreenOn {
return context.l10n.keepScreenOnViewerOnly; return context.l10n.keepScreenOnViewerOnly;
case KeepScreenOn.always: case KeepScreenOn.always:
return context.l10n.keepScreenOnAlways; return context.l10n.keepScreenOnAlways;
default:
return toString();
} }
} }

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:math'; import 'dart:math';
@ -8,13 +9,10 @@ import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/defaults.dart'; import 'package:aves/model/settings/defaults.dart';
import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/map_style.dart'; import 'package:aves/model/settings/map_style.dart';
import 'package:aves/model/settings/screen_on.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/services/device_service.dart'; import 'package:aves/services/accessibility_service.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/utils/pedantic.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@ -22,7 +20,10 @@ import 'package:shared_preferences/shared_preferences.dart';
final Settings settings = Settings._private(); final Settings settings = Settings._private();
class Settings extends ChangeNotifier { class Settings extends ChangeNotifier {
final EventChannel _platformSettingsChangeChannel = const EventChannel('deckers.thibault/aves/settingschange'); final EventChannel _platformSettingsChangeChannel = const EventChannel('deckers.thibault/aves/settings_change');
final StreamController<String> _updateStreamController = StreamController<String>.broadcast();
Stream<String> get updateStream => _updateStreamController.stream;
static SharedPreferences? _prefs; static SharedPreferences? _prefs;
@ -40,7 +41,7 @@ class Settings extends ChangeNotifier {
// app // app
static const hasAcceptedTermsKey = 'has_accepted_terms'; static const hasAcceptedTermsKey = 'has_accepted_terms';
static const isCrashlyticsEnabledKey = 'is_crashlytics_enabled'; static const isErrorReportingEnabledKey = 'is_crashlytics_enabled';
static const localeKey = 'locale'; static const localeKey = 'locale';
static const mustBackTwiceToExitKey = 'must_back_twice_to_exit'; static const mustBackTwiceToExitKey = 'must_back_twice_to_exit';
static const keepScreenOnKey = 'keep_screen_on'; static const keepScreenOnKey = 'keep_screen_on';
@ -58,6 +59,7 @@ class Settings extends ChangeNotifier {
static const collectionSortFactorKey = 'collection_sort_factor'; static const collectionSortFactorKey = 'collection_sort_factor';
static const collectionSelectionQuickActionsKey = 'collection_selection_quick_actions'; static const collectionSelectionQuickActionsKey = 'collection_selection_quick_actions';
static const showThumbnailLocationKey = 'show_thumbnail_location'; static const showThumbnailLocationKey = 'show_thumbnail_location';
static const showThumbnailMotionPhotoKey = 'show_thumbnail_motion_photo';
static const showThumbnailRawKey = 'show_thumbnail_raw'; static const showThumbnailRawKey = 'show_thumbnail_raw';
static const showThumbnailVideoDurationKey = 'show_thumbnail_video_duration'; static const showThumbnailVideoDurationKey = 'show_thumbnail_video_duration';
@ -103,32 +105,42 @@ class Settings extends ChangeNotifier {
static const saveSearchHistoryKey = 'save_search_history'; static const saveSearchHistoryKey = 'save_search_history';
static const searchHistoryKey = 'search_history'; static const searchHistoryKey = 'search_history';
// accessibility
static const accessibilityAnimationsKey = 'accessibility_animations';
static const timeToTakeActionKey = 'time_to_take_action';
// version // version
static const lastVersionCheckDateKey = 'last_version_check_date'; static const lastVersionCheckDateKey = 'last_version_check_date';
Future<void> init() async { // platform settings
_prefs = await SharedPreferences.getInstance(); // cf Android `Settings.System.ACCELEROMETER_ROTATION`
_isRotationLocked = await windowService.isRotationLocked(); static const platformAccelerometerRotationKey = 'accelerometer_rotation';
}
// Crashlytics initialization is separated from the main settings initialization // cf Android `Settings.Global.TRANSITION_ANIMATION_SCALE`
// to allow settings customization without Firebase context (e.g. before a Flutter Driver test) static const platformTransitionAnimationScaleKey = 'transition_animation_scale';
Future<void> initFirebase() async {
await Firebase.app().setAutomaticDataCollectionEnabled(isCrashlyticsEnabled); bool get initialized => _prefs != null;
await reportService.setCollectionEnabled(isCrashlyticsEnabled);
Future<void> init({
bool isRotationLocked = false,
bool areAnimationsRemoved = false,
}) async {
_prefs = await SharedPreferences.getInstance();
_isRotationLocked = isRotationLocked;
_areAnimationsRemoved = areAnimationsRemoved;
} }
Future<void> reset({required bool includeInternalKeys}) async { Future<void> reset({required bool includeInternalKeys}) async {
if (includeInternalKeys) { if (includeInternalKeys) {
await _prefs!.clear(); await _prefs!.clear();
} else { } else {
await Future.forEach(_prefs!.getKeys().whereNot(internalKeys.contains), _prefs!.remove); await Future.forEach<String>(_prefs!.getKeys().whereNot(internalKeys.contains), _prefs!.remove);
} }
} }
Future<void> setContextualDefaults() async { Future<void> setContextualDefaults() async {
// performance // performance
final performanceClass = await DeviceService.getPerformanceClass(); final performanceClass = await deviceService.getPerformanceClass();
enableOverlayBlurEffect = performanceClass >= 30; enableOverlayBlurEffect = performanceClass >= 30;
// availability // availability
@ -139,6 +151,10 @@ class Settings extends ChangeNotifier {
final styles = EntryMapStyle.values.whereNot((v) => v.isGoogleMaps).toList(); final styles = EntryMapStyle.values.whereNot((v) => v.isGoogleMaps).toList();
infoMapStyle = styles[Random().nextInt(styles.length)]; infoMapStyle = styles[Random().nextInt(styles.length)];
} }
// accessibility
final hasRecommendedTimeouts = await AccessibilityService.hasRecommendedTimeouts();
timeToTakeAction = hasRecommendedTimeouts ? AccessibilityTimeout.system : AccessibilityTimeout.appDefault;
} }
// app // app
@ -147,12 +163,9 @@ class Settings extends ChangeNotifier {
set hasAcceptedTerms(bool newValue) => setAndNotify(hasAcceptedTermsKey, newValue); set hasAcceptedTerms(bool newValue) => setAndNotify(hasAcceptedTermsKey, newValue);
bool get isCrashlyticsEnabled => getBoolOrDefault(isCrashlyticsEnabledKey, SettingsDefaults.isCrashlyticsEnabled); bool get isErrorReportingEnabled => getBoolOrDefault(isErrorReportingEnabledKey, SettingsDefaults.isErrorReportingEnabled);
set isCrashlyticsEnabled(bool newValue) { set isErrorReportingEnabled(bool newValue) => setAndNotify(isErrorReportingEnabledKey, newValue);
setAndNotify(isCrashlyticsEnabledKey, newValue);
unawaited(initFirebase());
}
static const localeSeparator = '-'; static const localeSeparator = '-';
@ -188,10 +201,7 @@ class Settings extends ChangeNotifier {
KeepScreenOn get keepScreenOn => getEnumOrDefault(keepScreenOnKey, SettingsDefaults.keepScreenOn, KeepScreenOn.values); KeepScreenOn get keepScreenOn => getEnumOrDefault(keepScreenOnKey, SettingsDefaults.keepScreenOn, KeepScreenOn.values);
set keepScreenOn(KeepScreenOn newValue) { set keepScreenOn(KeepScreenOn newValue) => setAndNotify(keepScreenOnKey, newValue.toString());
setAndNotify(keepScreenOnKey, newValue.toString());
newValue.apply();
}
HomePageSetting get homePage => getEnumOrDefault(homePageKey, SettingsDefaults.homePage, HomePageSetting.values); HomePageSetting get homePage => getEnumOrDefault(homePageKey, SettingsDefaults.homePage, HomePageSetting.values);
@ -242,6 +252,10 @@ class Settings extends ChangeNotifier {
set showThumbnailLocation(bool newValue) => setAndNotify(showThumbnailLocationKey, newValue); set showThumbnailLocation(bool newValue) => setAndNotify(showThumbnailLocationKey, newValue);
bool get showThumbnailMotionPhoto => getBoolOrDefault(showThumbnailMotionPhotoKey, SettingsDefaults.showThumbnailMotionPhoto);
set showThumbnailMotionPhoto(bool newValue) => setAndNotify(showThumbnailMotionPhotoKey, newValue);
bool get showThumbnailRaw => getBoolOrDefault(showThumbnailRawKey, SettingsDefaults.showThumbnailRaw); bool get showThumbnailRaw => getBoolOrDefault(showThumbnailRawKey, SettingsDefaults.showThumbnailRaw);
set showThumbnailRaw(bool newValue) => setAndNotify(showThumbnailRawKey, newValue); set showThumbnailRaw(bool newValue) => setAndNotify(showThumbnailRawKey, newValue);
@ -376,6 +390,16 @@ class Settings extends ChangeNotifier {
set searchHistory(List<CollectionFilter> newValue) => setAndNotify(searchHistoryKey, newValue.map((filter) => filter.toJson()).toList()); set searchHistory(List<CollectionFilter> newValue) => setAndNotify(searchHistoryKey, newValue.map((filter) => filter.toJson()).toList());
// accessibility
AccessibilityAnimations get accessibilityAnimations => getEnumOrDefault(accessibilityAnimationsKey, SettingsDefaults.accessibilityAnimations, AccessibilityAnimations.values);
set accessibilityAnimations(AccessibilityAnimations newValue) => setAndNotify(accessibilityAnimationsKey, newValue.toString());
AccessibilityTimeout get timeToTakeAction => getEnumOrDefault(timeToTakeActionKey, SettingsDefaults.timeToTakeAction, AccessibilityTimeout.values);
set timeToTakeAction(AccessibilityTimeout newValue) => setAndNotify(timeToTakeActionKey, newValue.toString());
// version // version
DateTime get lastVersionCheckDate => DateTime.fromMillisecondsSinceEpoch(_prefs!.getInt(lastVersionCheckDateKey) ?? 0); DateTime get lastVersionCheckDate => DateTime.fromMillisecondsSinceEpoch(_prefs!.getInt(lastVersionCheckDateKey) ?? 0);
@ -422,6 +446,7 @@ class Settings extends ChangeNotifier {
_prefs!.setBool(key, newValue); _prefs!.setBool(key, newValue);
} }
if (oldValue != newValue) { if (oldValue != newValue) {
_updateStreamController.add(key);
notifyListeners(); notifyListeners();
} }
} }
@ -429,29 +454,46 @@ class Settings extends ChangeNotifier {
// platform settings // platform settings
void _onPlatformSettingsChange(Map? fields) { void _onPlatformSettingsChange(Map? fields) {
var changed = false;
fields?.forEach((key, value) { fields?.forEach((key, value) {
switch (key) { switch (key) {
// cf Android `Settings.System.ACCELEROMETER_ROTATION` case platformAccelerometerRotationKey:
case 'accelerometer_rotation': if (value is num) {
if (value is int) {
final newValue = value == 0; final newValue = value == 0;
if (_isRotationLocked != newValue) { if (_isRotationLocked != newValue) {
_isRotationLocked = newValue; _isRotationLocked = newValue;
if (!_isRotationLocked) { if (!_isRotationLocked) {
windowService.requestOrientation(); windowService.requestOrientation();
} }
notifyListeners(); _updateStreamController.add(key);
changed = true;
} }
} }
break; break;
case platformTransitionAnimationScaleKey:
if (value is num) {
final newValue = value == 0;
if (_areAnimationsRemoved != newValue) {
_areAnimationsRemoved = newValue;
_updateStreamController.add(key);
changed = true;
}
}
} }
}); });
if (changed) {
notifyListeners();
}
} }
bool _isRotationLocked = false; bool _isRotationLocked = false;
bool get isRotationLocked => _isRotationLocked; bool get isRotationLocked => _isRotationLocked;
bool _areAnimationsRemoved = false;
bool get areAnimationsRemoved => _areAnimationsRemoved;
// import/export // import/export
String toJson() => jsonEncode(Map.fromEntries( String toJson() => jsonEncode(Map.fromEntries(
@ -492,9 +534,10 @@ class Settings extends ChangeNotifier {
debugPrint('failed to import key=$key, value=$value is not a double'); debugPrint('failed to import key=$key, value=$value is not a double');
} }
break; break;
case isCrashlyticsEnabledKey: case isErrorReportingEnabledKey:
case mustBackTwiceToExitKey: case mustBackTwiceToExitKey:
case showThumbnailLocationKey: case showThumbnailLocationKey:
case showThumbnailMotionPhotoKey:
case showThumbnailRawKey: case showThumbnailRawKey:
case showThumbnailVideoDurationKey: case showThumbnailVideoDurationKey:
case showOverlayMinimapKey: case showOverlayMinimapKey:
@ -526,6 +569,8 @@ class Settings extends ChangeNotifier {
case infoMapStyleKey: case infoMapStyleKey:
case coordinateFormatKey: case coordinateFormatKey:
case imageBackgroundKey: case imageBackgroundKey:
case accessibilityAnimationsKey:
case timeToTakeActionKey:
if (value is String) { if (value is String) {
_prefs!.setString(key, value); _prefs!.setString(key, value);
} else { } else {
@ -548,6 +593,7 @@ class Settings extends ChangeNotifier {
break; break;
} }
} }
_updateStreamController.add(key);
}); });
notifyListeners(); notifyListeners();
} }

View file

@ -13,8 +13,6 @@ extension ExtraVideoLoopMode on VideoLoopMode {
return context.l10n.videoLoopModeShortOnly; return context.l10n.videoLoopModeShortOnly;
case VideoLoopMode.always: case VideoLoopMode.always:
return context.l10n.videoLoopModeAlways; return context.l10n.videoLoopModeAlways;
default:
return toString();
} }
} }

View file

@ -2,7 +2,7 @@ import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -140,6 +140,8 @@ mixin AlbumMixin on SourceBase {
final Map<String, AvesEntry?> _filterRecentEntryMap = {}; final Map<String, AvesEntry?> _filterRecentEntryMap = {};
void invalidateAlbumFilterSummary({Set<AvesEntry>? entries, Set<String?>? directories}) { void invalidateAlbumFilterSummary({Set<AvesEntry>? entries, Set<String?>? directories}) {
if (_filterEntryCountMap.isEmpty && _filterRecentEntryMap.isEmpty) return;
if (entries == null && directories == null) { if (entries == null && directories == null) {
_filterEntryCountMap.clear(); _filterEntryCountMap.clear();
_filterRecentEntryMap.clear(); _filterRecentEntryMap.clear();

View file

@ -28,6 +28,7 @@ class CollectionLens with ChangeNotifier {
final List<StreamSubscription> _subscriptions = []; final List<StreamSubscription> _subscriptions = [];
int? id; int? id;
bool listenToSource; bool listenToSource;
List<AvesEntry>? fixedSelection;
List<AvesEntry> _filteredSortedEntries = []; List<AvesEntry> _filteredSortedEntries = [];
@ -38,6 +39,7 @@ class CollectionLens with ChangeNotifier {
Iterable<CollectionFilter?>? filters, Iterable<CollectionFilter?>? filters,
this.id, this.id,
this.listenToSource = true, this.listenToSource = true,
this.fixedSelection,
}) : filters = (filters ?? {}).whereNotNull().toSet(), }) : filters = (filters ?? {}).whereNotNull().toSet(),
sectionFactor = settings.collectionSectionFactor, sectionFactor = settings.collectionSectionFactor,
sortFactor = settings.collectionSortFactor { sortFactor = settings.collectionSortFactor {
@ -47,6 +49,7 @@ class CollectionLens with ChangeNotifier {
_subscriptions.add(sourceEvents.on<EntryAddedEvent>().listen((e) => onEntryAdded(e.entries))); _subscriptions.add(sourceEvents.on<EntryAddedEvent>().listen((e) => onEntryAdded(e.entries)));
_subscriptions.add(sourceEvents.on<EntryRemovedEvent>().listen((e) => onEntryRemoved(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) => _refresh()));
_subscriptions.add(sourceEvents.on<EntryRefreshedEvent>().listen((e) => _refresh()));
_subscriptions.add(sourceEvents.on<FilterVisibilityChangedEvent>().listen((e) => _refresh())); _subscriptions.add(sourceEvents.on<FilterVisibilityChangedEvent>().listen((e) => _refresh()));
_subscriptions.add(sourceEvents.on<CatalogMetadataChangedEvent>().listen((e) => _refresh())); _subscriptions.add(sourceEvents.on<CatalogMetadataChangedEvent>().listen((e) => _refresh()));
_subscriptions.add(sourceEvents.on<AddressMetadataChangedEvent>().listen((e) { _subscriptions.add(sourceEvents.on<AddressMetadataChangedEvent>().listen((e) {
@ -117,7 +120,7 @@ class CollectionLens with ChangeNotifier {
final bool groupBursts = true; final bool groupBursts = true;
void _applyFilters() { void _applyFilters() {
final entries = source.visibleEntries; final entries = fixedSelection ?? source.visibleEntries;
_filteredSortedEntries = List.of(filters.isEmpty ? entries : entries.where((entry) => filters.every((filter) => filter.test(entry)))); _filteredSortedEntries = List.of(filters.isEmpty ? entries : entries.where((entry) => filters.every((filter) => filter.test(entry))));
if (groupBursts) { if (groupBursts) {

View file

@ -12,8 +12,8 @@ import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/tag.dart'; import 'package:aves/model/source/tag.dart';
import 'package:aves/services/image_op_events.dart'; import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:event_bus/event_bus.dart'; import 'package:event_bus/event_bus.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -128,9 +128,9 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
_rawEntries.clear(); _rawEntries.clear();
_invalidate(); _invalidate();
updateDirectories(); // do not update directories/locations/tags here
updateLocations(); // as it could reset filter dependent settings (pins, bookmarks, etc.)
updateTags(); // caller should take care of updating these at the right time
} }
Future<void> _moveEntry(AvesEntry entry, Map newFields, {required bool persist}) async { Future<void> _moveEntry(AvesEntry entry, Map newFields, {required bool persist}) async {
@ -159,7 +159,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
Future<bool> renameEntry(AvesEntry entry, String newName, {required bool persist}) async { Future<bool> renameEntry(AvesEntry entry, String newName, {required bool persist}) async {
if (newName == entry.filenameWithoutExtension) return true; if (newName == entry.filenameWithoutExtension) return true;
final newFields = await imageFileService.rename(entry, '$newName${entry.extension}'); final newFields = await mediaFileService.rename(entry, '$newName${entry.extension}');
if (newFields.isEmpty) return false; if (newFields.isEmpty) return false;
await _moveEntry(entry, newFields, persist: persist); await _moveEntry(entry, newFields, persist: persist);
@ -254,7 +254,17 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
Future<void> refresh(); Future<void> refresh();
Future<void> refreshMetadata(Set<AvesEntry> entries); Future<void> rescan(Set<AvesEntry> entries);
Future<void> refreshMetadata(Set<AvesEntry> entries) async {
await Future.forEach<AvesEntry>(entries, (entry) => entry.refresh(persist: true));
_invalidate(entries);
updateLocations();
updateTags();
eventBus.fire(EntryRefreshedEvent(entries));
}
// monitoring // monitoring
@ -334,6 +344,12 @@ class EntryMovedEvent {
const EntryMovedEvent(this.entries); const EntryMovedEvent(this.entries);
} }
class EntryRefreshedEvent {
final Set<AvesEntry> entries;
const EntryRefreshedEvent(this.entries);
}
class FilterVisibilityChangedEvent { class FilterVisibilityChangedEvent {
final Set<CollectionFilter> filters; final Set<CollectionFilter> filters;
final bool visible; final bool visible;

View file

@ -6,7 +6,7 @@ import 'package:aves/model/filters/location.dart';
import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/address.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
@ -18,11 +18,11 @@ mixin LocationMixin on SourceBase {
List<String> sortedPlaces = List.unmodifiable([]); List<String> sortedPlaces = List.unmodifiable([]);
Future<void> loadAddresses() async { Future<void> loadAddresses() async {
final stopwatch = Stopwatch()..start(); // final stopwatch = Stopwatch()..start();
final saved = await metadataDb.loadAddresses(); final saved = await metadataDb.loadAddresses();
final idMap = entryById; final idMap = entryById;
saved.forEach((metadata) => idMap[metadata.contentId]?.addressDetails = metadata); saved.forEach((metadata) => idMap[metadata.contentId]?.addressDetails = metadata);
debugPrint('$runtimeType loadAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries'); // debugPrint('$runtimeType loadAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries');
onAddressMetadataChanged(); onAddressMetadataChanged();
} }
@ -159,6 +159,8 @@ mixin LocationMixin on SourceBase {
final Map<String, AvesEntry?> _filterRecentEntryMap = {}; final Map<String, AvesEntry?> _filterRecentEntryMap = {};
void invalidateCountryFilterSummary([Set<AvesEntry>? entries]) { void invalidateCountryFilterSummary([Set<AvesEntry>? entries]) {
if (_filterEntryCountMap.isEmpty && _filterRecentEntryMap.isEmpty) return;
Set<String>? countryCodes; Set<String>? countryCodes;
if (entries == null) { if (entries == null) {
_filterEntryCountMap.clear(); _filterEntryCountMap.clear();

View file

@ -7,7 +7,7 @@ import 'package:aves/model/favourites.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -25,7 +25,7 @@ class MediaStoreSource extends CollectionSource {
await metadataDb.init(); await metadataDb.init();
await favourites.init(); await favourites.init();
await covers.init(); await covers.init();
final currentTimeZone = await timeService.getDefaultTimeZone(); final currentTimeZone = await deviceService.getDefaultTimeZone();
if (currentTimeZone != null) { if (currentTimeZone != null) {
final catalogTimeZone = settings.catalogTimeZone; final catalogTimeZone = settings.catalogTimeZone;
if (currentTimeZone != catalogTimeZone) { if (currentTimeZone != catalogTimeZone) {
@ -49,21 +49,27 @@ class MediaStoreSource extends CollectionSource {
stateNotifier.value = SourceState.loading; stateNotifier.value = SourceState.loading;
clearEntries(); clearEntries();
debugPrint('$runtimeType refresh ${stopwatch.elapsed} fetch known entries');
final oldEntries = await metadataDb.loadEntries(); final oldEntries = await metadataDb.loadEntries();
debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete entries');
final knownDateById = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId!, entry.dateModifiedSecs!))); final knownDateById = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId!, entry.dateModifiedSecs!)));
final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(knownDateById.keys.toList())).toSet(); final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(knownDateById.keys.toList())).toSet();
oldEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId)); oldEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId));
// show known entries // show known entries
debugPrint('$runtimeType refresh ${stopwatch.elapsed} add known entries');
addEntries(oldEntries); addEntries(oldEntries);
debugPrint('$runtimeType refresh ${stopwatch.elapsed} load catalog metadata');
await loadCatalogMetadata(); await loadCatalogMetadata();
debugPrint('$runtimeType refresh ${stopwatch.elapsed} load address metadata');
await loadAddresses(); await loadAddresses();
debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}');
// clean up obsolete entries // clean up obsolete entries
debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries');
await metadataDb.removeIds(obsoleteContentIds, metadataOnly: false); await metadataDb.removeIds(obsoleteContentIds, metadataOnly: false);
// verify paths because some apps move files without updating their `last modified date` // verify paths because some apps move files without updating their `last modified date`
debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete paths');
final knownPathById = Map.fromEntries(allEntries.map((entry) => MapEntry(entry.contentId!, entry.path))); final knownPathById = Map.fromEntries(allEntries.map((entry) => MapEntry(entry.contentId!, entry.path)));
final movedContentIds = (await mediaStoreService.checkObsoletePaths(knownPathById)).toSet(); final movedContentIds = (await mediaStoreService.checkObsoletePaths(knownPathById)).toSet();
movedContentIds.forEach((contentId) { movedContentIds.forEach((contentId) {
@ -72,6 +78,7 @@ class MediaStoreSource extends CollectionSource {
}); });
// fetch new entries // fetch new entries
debugPrint('$runtimeType refresh ${stopwatch.elapsed} fetch new entries');
// refresh after the first 10 entries, then after 100 more, then every 1000 entries // refresh after the first 10 entries, then after 100 more, then every 1000 entries
var refreshCount = 10; var refreshCount = 10;
const refreshCountMax = 1000; const refreshCountMax = 1000;
@ -92,22 +99,24 @@ class MediaStoreSource extends CollectionSource {
}, },
onDone: () async { onDone: () async {
addPendingEntries(); addPendingEntries();
debugPrint('$runtimeType refresh loaded ${allNewEntries.length} new entries, elapsed=${stopwatch.elapsed}');
await metadataDb.saveEntries(allNewEntries);
if (allNewEntries.isNotEmpty) { if (allNewEntries.isNotEmpty) {
debugPrint('$runtimeType refresh ${stopwatch.elapsed} save new entries');
await metadataDb.saveEntries(allNewEntries);
// new entries include existing entries with obsolete paths // new entries include existing entries with obsolete paths
// so directories may be added, but also removed or simply have their content summary changed // so directories may be added, but also removed or simply have their content summary changed
invalidateAlbumFilterSummary(); invalidateAlbumFilterSummary();
updateDirectories(); updateDirectories();
} }
debugPrint('$runtimeType refresh ${stopwatch.elapsed} catalog entries');
await catalogEntries(); await catalogEntries();
debugPrint('$runtimeType refresh ${stopwatch.elapsed} locate entries');
await locateEntries(); await locateEntries();
stateNotifier.value = SourceState.ready; stateNotifier.value = SourceState.ready;
debugPrint('$runtimeType refresh done, elapsed=${stopwatch.elapsed}'); debugPrint('$runtimeType refresh ${stopwatch.elapsed} done for ${oldEntries.length} known, ${allNewEntries.length} new, ${obsoleteContentIds.length} obsolete');
}, },
onError: (error) => debugPrint('$runtimeType stream error=$error'), onError: (error) => debugPrint('$runtimeType stream error=$error'),
); );
@ -121,6 +130,7 @@ class MediaStoreSource extends CollectionSource {
Future<Set<String>> refreshUris(Set<String> changedUris) async { Future<Set<String>> refreshUris(Set<String> changedUris) async {
if (!_initialized || !isMonitoring) return changedUris; if (!_initialized || !isMonitoring) return changedUris;
debugPrint('$runtimeType refreshUris ${changedUris.length} uris');
final uriByContentId = Map.fromEntries(changedUris.map((uri) { final uriByContentId = Map.fromEntries(changedUris.map((uri) {
final pathSegments = Uri.parse(uri).pathSegments; final pathSegments = Uri.parse(uri).pathSegments;
// e.g. URI `content://media/` has no path segment // e.g. URI `content://media/` has no path segment
@ -144,7 +154,7 @@ class MediaStoreSource extends CollectionSource {
for (final kv in uriByContentId.entries) { for (final kv in uriByContentId.entries) {
final contentId = kv.key; final contentId = kv.key;
final uri = kv.value; final uri = kv.value;
final sourceEntry = await imageFileService.getEntry(uri, null); final sourceEntry = await mediaFileService.getEntry(uri, null);
if (sourceEntry != null) { if (sourceEntry != null) {
final existingEntry = allEntries.firstWhereOrNull((entry) => entry.contentId == contentId); final existingEntry = allEntries.firstWhereOrNull((entry) => entry.contentId == contentId);
// compare paths because some apps move files without updating their `last modified date` // compare paths because some apps move files without updating their `last modified date`
@ -179,9 +189,9 @@ class MediaStoreSource extends CollectionSource {
} }
@override @override
Future<void> refreshMetadata(Set<AvesEntry> entries) { Future<void> rescan(Set<AvesEntry> entries) async {
final contentIds = entries.map((entry) => entry.contentId).whereNotNull().toSet(); final contentIds = entries.map((entry) => entry.contentId).whereNotNull().toSet();
metadataDb.removeIds(contentIds, metadataOnly: true); await metadataDb.removeIds(contentIds, metadataOnly: true);
return refresh(); return refresh();
} }
} }

View file

@ -3,7 +3,7 @@ import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/metadata/catalog.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -13,11 +13,11 @@ mixin TagMixin on SourceBase {
List<String> sortedTags = List.unmodifiable([]); List<String> sortedTags = List.unmodifiable([]);
Future<void> loadCatalogMetadata() async { Future<void> loadCatalogMetadata() async {
final stopwatch = Stopwatch()..start(); // final stopwatch = Stopwatch()..start();
final saved = await metadataDb.loadMetadataEntries(); final saved = await metadataDb.loadMetadataEntries();
final idMap = entryById; final idMap = entryById;
saved.forEach((metadata) => idMap[metadata.contentId]?.catalogMetadata = metadata); saved.forEach((metadata) => idMap[metadata.contentId]?.catalogMetadata = metadata);
debugPrint('$runtimeType loadCatalogMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries'); // debugPrint('$runtimeType loadCatalogMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries');
onCatalogMetadataChanged(); onCatalogMetadataChanged();
} }
@ -70,6 +70,8 @@ mixin TagMixin on SourceBase {
final Map<String, AvesEntry?> _filterRecentEntryMap = {}; final Map<String, AvesEntry?> _filterRecentEntryMap = {};
void invalidateTagFilterSummary([Set<AvesEntry>? entries]) { void invalidateTagFilterSummary([Set<AvesEntry>? entries]) {
if (_filterEntryCountMap.isEmpty && _filterRecentEntryMap.isEmpty) return;
Set<String>? tags; Set<String>? tags;
if (entries == null) { if (entries == null) {
_filterEntryCountMap.clear(); _filterEntryCountMap.clear();

View file

@ -10,7 +10,7 @@ import 'package:aves/model/video/profiles/h264.dart';
import 'package:aves/model/video/profiles/hevc.dart'; import 'package:aves/model/video/profiles/hevc.dart';
import 'package:aves/ref/languages.dart'; import 'package:aves/ref/languages.dart';
import 'package:aves/ref/mp4.dart'; import 'package:aves/ref/mp4.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/theme/format.dart'; import 'package:aves/theme/format.dart';
import 'package:aves/utils/file_utils.dart'; import 'package:aves/utils/file_utils.dart';
import 'package:aves/utils/math_utils.dart'; import 'package:aves/utils/math_utils.dart';

View file

@ -52,6 +52,9 @@ class MimeTypes {
static const json = 'application/json'; static const json = 'application/json';
static const plainText = 'text/plain'; static const plainText = 'text/plain';
// JB2, JPC, JPX?
static const octetStream = 'application/octet-stream';
// groups // groups
// formats that support transparency // formats that support transparency
@ -60,7 +63,7 @@ class MimeTypes {
static const Set<String> rawImages = {arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f}; static const Set<String> rawImages = {arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f};
// TODO TLAD [codec] make it dynamic if it depends on OS/lib versions // TODO TLAD [codec] make it dynamic if it depends on OS/lib versions
static const Set<String> undecodableImages = {art, crw, djvu, psdVnd, psdX}; static const Set<String> undecodableImages = {art, crw, djvu, psdVnd, psdX, octetStream};
static const Set<String> _knownOpaqueImages = {heic, heif, jpeg}; static const Set<String> _knownOpaqueImages = {heic, heif, jpeg};

View file

@ -0,0 +1,52 @@
import 'package:aves/services/common/services.dart';
import 'package:flutter/services.dart';
class AccessibilityService {
static const platform = MethodChannel('deckers.thibault/aves/accessibility');
static Future<bool> areAnimationsRemoved() async {
try {
final result = await platform.invokeMethod('areAnimationsRemoved');
if (result != null) return result as bool;
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return false;
}
static Future<bool> hasRecommendedTimeouts() async {
try {
final result = await platform.invokeMethod('hasRecommendedTimeouts');
if (result != null) return result as bool;
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return false;
}
static Future<int> getRecommendedTimeToRead(int originalTimeoutMillis) async {
try {
final result = await platform.invokeMethod('getRecommendedTimeoutMillis', <String, dynamic>{
'originalTimeoutMillis': originalTimeoutMillis,
'content': ['icons', 'text']
});
if (result != null) return result as int;
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return originalTimeoutMillis;
}
static Future<int> getRecommendedTimeToTakeAction(int originalTimeoutMillis) async {
try {
final result = await platform.invokeMethod('getRecommendedTimeoutMillis', <String, dynamic>{
'originalTimeoutMillis': originalTimeoutMillis,
'content': ['controls', 'icons', 'text']
});
if (result != null) return result as int;
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return originalTimeoutMillis;
}
}

View file

@ -1,10 +1,12 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/services/services.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/math_utils.dart'; import 'package:aves/utils/math_utils.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
@ -136,4 +138,49 @@ class AndroidAppService {
} }
return false; return false;
} }
// app shortcuts
// this ability will not change over the lifetime of the app
static bool? _canPin;
static 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;
}
static Future<void> pinToHomeScreen(String label, AvesEntry? entry, Set<CollectionFilter> filters) async {
Uint8List? iconBytes;
if (entry != null) {
final size = entry.isVideo ? 0.0 : 256.0;
iconBytes = await mediaFileService.getThumbnail(
uri: entry.uri,
mimeType: entry.mimeType,
pageId: entry.pageId,
rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
dateModifiedSecs: entry.dateModifiedSecs,
extent: size,
);
}
try {
await platform.invokeMethod('pin', <String, dynamic>{
'label': label,
'iconBytes': iconBytes,
'filters': filters.map((filter) => filter.toJson()).toList(),
});
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
}
} }

View file

@ -1,6 +1,6 @@
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/ref/mime_types.dart'; import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
class AndroidDebugService { class AndroidDebugService {

View file

@ -1,54 +0,0 @@
import 'dart:typed_data';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
class AppShortcutService {
static const platform = MethodChannel('deckers.thibault/aves/shortcut');
// this ability will not change over the lifetime of the app
static bool? _canPin;
static Future<bool> canPin() 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;
}
static Future<void> pin(String label, AvesEntry? entry, Set<CollectionFilter> filters) async {
Uint8List? iconBytes;
if (entry != null) {
final size = entry.isVideo ? 0.0 : 256.0;
iconBytes = await imageFileService.getThumbnail(
uri: entry.uri,
mimeType: entry.mimeType,
pageId: entry.pageId,
rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
dateModifiedSecs: entry.dateModifiedSecs,
extent: size,
);
}
try {
await platform.invokeMethod('pin', <String, dynamic>{
'label': label,
'iconBytes': iconBytes,
'filters': filters.map((filter) => filter.toJson()).toList(),
});
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
}
}

View file

@ -1,12 +1,13 @@
import 'package:aves/model/availability.dart'; import 'package:aves/model/availability.dart';
import 'package:aves/model/metadata_db.dart'; import 'package:aves/model/metadata_db.dart';
import 'package:aves/services/embedded_data_service.dart'; import 'package:aves/services/device_service.dart';
import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/media/embedded_data_service.dart';
import 'package:aves/services/media_store_service.dart'; import 'package:aves/services/media/media_file_service.dart';
import 'package:aves/services/metadata_service.dart'; import 'package:aves/services/media/media_store_service.dart';
import 'package:aves/services/metadata/metadata_edit_service.dart';
import 'package:aves/services/metadata/metadata_fetch_service.dart';
import 'package:aves/services/report_service.dart'; import 'package:aves/services/report_service.dart';
import 'package:aves/services/storage_service.dart'; import 'package:aves/services/storage_service.dart';
import 'package:aves/services/time_service.dart';
import 'package:aves/services/window_service.dart'; import 'package:aves/services/window_service.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@ -17,13 +18,14 @@ final p.Context pContext = getIt<p.Context>();
final AvesAvailability availability = getIt<AvesAvailability>(); final AvesAvailability availability = getIt<AvesAvailability>();
final MetadataDb metadataDb = getIt<MetadataDb>(); final MetadataDb metadataDb = getIt<MetadataDb>();
final DeviceService deviceService = getIt<DeviceService>();
final EmbeddedDataService embeddedDataService = getIt<EmbeddedDataService>(); final EmbeddedDataService embeddedDataService = getIt<EmbeddedDataService>();
final ImageFileService imageFileService = getIt<ImageFileService>(); final MediaFileService mediaFileService = getIt<MediaFileService>();
final MediaStoreService mediaStoreService = getIt<MediaStoreService>(); final MediaStoreService mediaStoreService = getIt<MediaStoreService>();
final MetadataService metadataService = getIt<MetadataService>(); final MetadataEditService metadataEditService = getIt<MetadataEditService>();
final MetadataFetchService metadataFetchService = getIt<MetadataFetchService>();
final ReportService reportService = getIt<ReportService>(); final ReportService reportService = getIt<ReportService>();
final StorageService storageService = getIt<StorageService>(); final StorageService storageService = getIt<StorageService>();
final TimeService timeService = getIt<TimeService>();
final WindowService windowService = getIt<WindowService>(); final WindowService windowService = getIt<WindowService>();
void initPlatformServices() { void initPlatformServices() {
@ -31,12 +33,13 @@ void initPlatformServices() {
getIt.registerLazySingleton<AvesAvailability>(() => LiveAvesAvailability()); getIt.registerLazySingleton<AvesAvailability>(() => LiveAvesAvailability());
getIt.registerLazySingleton<MetadataDb>(() => SqfliteMetadataDb()); getIt.registerLazySingleton<MetadataDb>(() => SqfliteMetadataDb());
getIt.registerLazySingleton<DeviceService>(() => PlatformDeviceService());
getIt.registerLazySingleton<EmbeddedDataService>(() => PlatformEmbeddedDataService()); getIt.registerLazySingleton<EmbeddedDataService>(() => PlatformEmbeddedDataService());
getIt.registerLazySingleton<ImageFileService>(() => PlatformImageFileService()); getIt.registerLazySingleton<MediaFileService>(() => PlatformMediaFileService());
getIt.registerLazySingleton<MediaStoreService>(() => PlatformMediaStoreService()); getIt.registerLazySingleton<MediaStoreService>(() => PlatformMediaStoreService());
getIt.registerLazySingleton<MetadataService>(() => PlatformMetadataService()); getIt.registerLazySingleton<MetadataEditService>(() => PlatformMetadataEditService());
getIt.registerLazySingleton<MetadataFetchService>(() => PlatformMetadataFetchService());
getIt.registerLazySingleton<ReportService>(() => CrashlyticsReportService()); getIt.registerLazySingleton<ReportService>(() => CrashlyticsReportService());
getIt.registerLazySingleton<StorageService>(() => PlatformStorageService()); getIt.registerLazySingleton<StorageService>(() => PlatformStorageService());
getIt.registerLazySingleton<TimeService>(() => PlatformTimeService());
getIt.registerLazySingleton<WindowService>(() => PlatformWindowService()); getIt.registerLazySingleton<WindowService>(() => PlatformWindowService());
} }

View file

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

View file

@ -1,6 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';

View file

@ -1,6 +1,6 @@
import 'dart:ui'; import 'dart:ui';
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/theme/format.dart'; import 'package:aves/theme/format.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';

View file

@ -1,7 +1,7 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
abstract class EmbeddedDataService { abstract class EmbeddedDataService {

View file

@ -4,18 +4,16 @@ import 'dart:typed_data';
import 'dart:ui'; import 'dart:ui';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/metadata/date_modifier.dart';
import 'package:aves/model/metadata/enums.dart';
import 'package:aves/ref/mime_types.dart'; import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/image_op_events.dart'; import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/output_buffer.dart'; import 'package:aves/services/common/output_buffer.dart';
import 'package:aves/services/service_policy.dart'; import 'package:aves/services/common/service_policy.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:streams_channel/streams_channel.dart'; import 'package:streams_channel/streams_channel.dart';
abstract class ImageFileService { abstract class MediaFileService {
Future<AvesEntry?> getEntry(String uri, String? mimeType); Future<AvesEntry?> getEntry(String uri, String? mimeType);
Future<Uint8List> getSvg( Future<Uint8List> getSvg(
@ -92,18 +90,12 @@ abstract class ImageFileService {
}); });
Future<Map<String, dynamic>> rename(AvesEntry entry, String newName); Future<Map<String, dynamic>> rename(AvesEntry entry, String newName);
Future<Map<String, dynamic>> rotate(AvesEntry entry, {required bool clockwise});
Future<Map<String, dynamic>> flip(AvesEntry entry);
Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier);
} }
class PlatformImageFileService implements ImageFileService { class PlatformMediaFileService implements MediaFileService {
static const platform = MethodChannel('deckers.thibault/aves/image'); static const platform = MethodChannel('deckers.thibault/aves/media_file');
static final StreamsChannel _byteStreamChannel = StreamsChannel('deckers.thibault/aves/image_byte_stream'); static final StreamsChannel _byteStreamChannel = StreamsChannel('deckers.thibault/aves/media_byte_stream');
static final StreamsChannel _opStreamChannel = StreamsChannel('deckers.thibault/aves/image_op_stream'); static final StreamsChannel _opStreamChannel = StreamsChannel('deckers.thibault/aves/media_op_stream');
static const double thumbnailDefaultSize = 64.0; static const double thumbnailDefaultSize = 64.0;
static Map<String, dynamic> _toPlatformEntryMap(AvesEntry entry) { static Map<String, dynamic> _toPlatformEntryMap(AvesEntry entry) {
@ -383,62 +375,4 @@ class PlatformImageFileService implements ImageFileService {
} }
return {}; return {};
} }
@override
Future<Map<String, dynamic>> rotate(AvesEntry entry, {required bool clockwise}) async {
try {
// returns map with: 'rotationDegrees' 'isFlipped'
final result = await platform.invokeMethod('rotate', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
'clockwise': clockwise,
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
@override
Future<Map<String, dynamic>> flip(AvesEntry entry) async {
try {
// returns map with: 'rotationDegrees' 'isFlipped'
final result = await platform.invokeMethod('flip', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
@override
Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier) async {
try {
final result = await platform.invokeMethod('editDate', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
'dateMillis': modifier.dateTime?.millisecondsSinceEpoch,
'shiftMinutes': modifier.shiftMinutes,
'fields': modifier.fields.map(_toExifInterfaceTag).toList(),
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
String _toExifInterfaceTag(MetadataField field) {
switch (field) {
case MetadataField.exifDate:
return 'DateTime';
case MetadataField.exifDateOriginal:
return 'DateTimeOriginal';
case MetadataField.exifDateDigitized:
return 'DateTimeDigitized';
case MetadataField.exifGpsDate:
return 'GPSDateStamp';
}
}
} }

View file

@ -1,7 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:streams_channel/streams_channel.dart'; import 'package:streams_channel/streams_channel.dart';
@ -12,11 +12,14 @@ abstract class MediaStoreService {
// knownEntries: map of contentId -> dateModifiedSecs // knownEntries: map of contentId -> dateModifiedSecs
Stream<AvesEntry> getEntries(Map<int, int> knownEntries); Stream<AvesEntry> getEntries(Map<int, int> knownEntries);
// returns media URI
Future<Uri?> scanFile(String path, String mimeType);
} }
class PlatformMediaStoreService implements MediaStoreService { class PlatformMediaStoreService implements MediaStoreService {
static const platform = MethodChannel('deckers.thibault/aves/mediastore'); static const platform = MethodChannel('deckers.thibault/aves/media_store');
static final StreamsChannel _streamChannel = StreamsChannel('deckers.thibault/aves/mediastorestream'); static final StreamsChannel _streamChannel = StreamsChannel('deckers.thibault/aves/media_store_stream');
@override @override
Future<List<int>> checkObsoleteContentIds(List<int> knownContentIds) async { Future<List<int>> checkObsoleteContentIds(List<int> knownContentIds) async {
@ -55,4 +58,19 @@ class PlatformMediaStoreService implements MediaStoreService {
return Stream.error(e); return Stream.error(e);
} }
} }
// returns media URI
@override
Future<Uri?> scanFile(String path, String mimeType) async {
try {
final result = await platform.invokeMethod('scanFile', <String, dynamic>{
'path': path,
'mimeType': mimeType,
});
if (result != null) return Uri.tryParse(result);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return null;
}
} }

View file

@ -0,0 +1,131 @@
import 'dart:async';
import 'package:aves/model/entry.dart';
import 'package:aves/model/metadata/date_modifier.dart';
import 'package:aves/model/metadata/enums.dart';
import 'package:aves/services/common/services.dart';
import 'package:flutter/services.dart';
abstract class MetadataEditService {
Future<Map<String, dynamic>> rotate(AvesEntry entry, {required bool clockwise});
Future<Map<String, dynamic>> flip(AvesEntry entry);
Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier);
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types);
}
class PlatformMetadataEditService implements MetadataEditService {
static const platform = MethodChannel('deckers.thibault/aves/metadata_edit');
static Map<String, dynamic> _toPlatformEntryMap(AvesEntry entry) {
return {
'uri': entry.uri,
'path': entry.path,
'pageId': entry.pageId,
'mimeType': entry.mimeType,
'width': entry.width,
'height': entry.height,
'rotationDegrees': entry.rotationDegrees,
'isFlipped': entry.isFlipped,
'dateModifiedSecs': entry.dateModifiedSecs,
'sizeBytes': entry.sizeBytes,
};
}
@override
Future<Map<String, dynamic>> rotate(AvesEntry entry, {required bool clockwise}) async {
try {
// returns map with: 'rotationDegrees' 'isFlipped'
final result = await platform.invokeMethod('rotate', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
'clockwise': clockwise,
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
@override
Future<Map<String, dynamic>> flip(AvesEntry entry) async {
try {
// returns map with: 'rotationDegrees' 'isFlipped'
final result = await platform.invokeMethod('flip', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
@override
Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier) async {
try {
final result = await platform.invokeMethod('editDate', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
'dateMillis': modifier.dateTime?.millisecondsSinceEpoch,
'shiftMinutes': modifier.shiftMinutes,
'fields': modifier.fields.map(_toExifInterfaceTag).toList(),
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
@override
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types) async {
try {
final result = await platform.invokeMethod('removeTypes', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
'types': types.map(_toPlatformMetadataType).toList(),
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
String _toExifInterfaceTag(MetadataField field) {
switch (field) {
case MetadataField.exifDate:
return 'DateTime';
case MetadataField.exifDateOriginal:
return 'DateTimeOriginal';
case MetadataField.exifDateDigitized:
return 'DateTimeDigitized';
case MetadataField.exifGpsDate:
return 'GPSDateStamp';
}
}
String _toPlatformMetadataType(MetadataType type) {
switch (type) {
case MetadataType.exif:
return 'exif';
case MetadataType.iccProfile:
return 'icc_profile';
case MetadataType.iptc:
return 'iptc';
case MetadataType.jfif:
return 'jfif';
case MetadataType.jpegAdobe:
return 'jpeg_adobe';
case MetadataType.jpegComment:
return 'jpeg_comment';
case MetadataType.jpegDucky:
return 'jpeg_ducky';
case MetadataType.photoshopIrb:
return 'photoshop_irb';
case MetadataType.xmp:
return 'xmp';
}
}
}

View file

@ -3,12 +3,12 @@ import 'package:aves/model/metadata/catalog.dart';
import 'package:aves/model/metadata/overlay.dart'; import 'package:aves/model/metadata/overlay.dart';
import 'package:aves/model/multipage.dart'; import 'package:aves/model/multipage.dart';
import 'package:aves/model/panorama.dart'; import 'package:aves/model/panorama.dart';
import 'package:aves/services/service_policy.dart'; import 'package:aves/services/common/service_policy.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
abstract class MetadataService { abstract class MetadataFetchService {
// returns Map<Map<Key, Value>> (map of directories, each directory being a map of metadata label and value description) // returns Map<Map<Key, Value>> (map of directories, each directory being a map of metadata label and value description)
Future<Map> getAllMetadata(AvesEntry entry); Future<Map> getAllMetadata(AvesEntry entry);
@ -25,8 +25,8 @@ abstract class MetadataService {
Future<String?> getContentResolverProp(AvesEntry entry, String prop); Future<String?> getContentResolverProp(AvesEntry entry, String prop);
} }
class PlatformMetadataService implements MetadataService { class PlatformMetadataFetchService implements MetadataFetchService {
static const platform = MethodChannel('deckers.thibault/aves/metadata'); static const platform = MethodChannel('deckers.thibault/aves/metadata_fetch');
@override @override
Future<Map> getAllMetadata(AvesEntry entry) async { Future<Map> getAllMetadata(AvesEntry entry) async {

View file

@ -1,7 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/utils/string_utils.dart'; import 'package:aves/utils/string_utils.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -18,7 +18,7 @@ class SvgMetadataService {
static Future<Size?> getSize(AvesEntry entry) async { static Future<Size?> getSize(AvesEntry entry) async {
try { try {
final data = await imageFileService.getSvg(entry.uri, entry.mimeType); final data = await mediaFileService.getSvg(entry.uri, entry.mimeType);
final document = XmlDocument.parse(utf8.decode(data)); final document = XmlDocument.parse(utf8.decode(data));
final root = document.rootElement; final root = document.rootElement;
@ -64,7 +64,7 @@ class SvgMetadataService {
} }
try { try {
final data = await imageFileService.getSvg(entry.uri, entry.mimeType); final data = await mediaFileService.getSvg(entry.uri, entry.mimeType);
final document = XmlDocument.parse(utf8.decode(data)); final document = XmlDocument.parse(utf8.decode(data));
final root = document.rootElement; final root = document.rootElement;

View file

@ -1,10 +1,13 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:stack_trace/stack_trace.dart'; import 'package:stack_trace/stack_trace.dart';
abstract class ReportService { abstract class ReportService {
Future<void> init();
bool get isCollectionEnabled; bool get isCollectionEnabled;
Future<void> setCollectionEnabled(bool enabled); Future<void> setCollectionEnabled(bool enabled);
@ -21,23 +24,29 @@ abstract class ReportService {
} }
class CrashlyticsReportService extends ReportService { class CrashlyticsReportService extends ReportService {
FirebaseCrashlytics get instance => FirebaseCrashlytics.instance; FirebaseCrashlytics get _instance => FirebaseCrashlytics.instance;
@override @override
bool get isCollectionEnabled => instance.isCrashlyticsCollectionEnabled; Future<void> init() => Firebase.initializeApp();
@override @override
Future<void> setCollectionEnabled(bool enabled) => instance.setCrashlyticsCollectionEnabled(enabled); bool get isCollectionEnabled => _instance.isCrashlyticsCollectionEnabled;
@override @override
Future<void> log(String message) => instance.log(message); Future<void> setCollectionEnabled(bool enabled) async {
debugPrint('${enabled ? 'enable' : 'disable'} Firebase & Crashlytics collection');
await Firebase.app().setAutomaticDataCollectionEnabled(enabled);
await _instance.setCrashlyticsCollectionEnabled(enabled);
}
@override @override
Future<void> setCustomKey(String key, Object value) => instance.setCustomKey(key, value); Future<void> log(String message) => _instance.log(message);
@override
Future<void> setCustomKey(String key, Object value) => _instance.setCustomKey(key, value);
@override @override
Future<void> setCustomKeys(Map<String, Object> map) { Future<void> setCustomKeys(Map<String, Object> map) {
final _instance = instance;
return Future.forEach<MapEntry<String, Object>>(map.entries, (kv) => _instance.setCustomKey(kv.key, kv.value)); return Future.forEach<MapEntry<String, Object>>(map.entries, (kv) => _instance.setCustomKey(kv.key, kv.value));
} }
@ -56,11 +65,11 @@ class CrashlyticsReportService extends ReportService {
) )
.join('\n')); .join('\n'));
} }
return instance.recordError(exception, stack); return _instance.recordError(exception, stack);
} }
@override @override
Future<void> recordFlutterError(FlutterErrorDetails flutterErrorDetails) { Future<void> recordFlutterError(FlutterErrorDetails flutterErrorDetails) {
return instance.recordFlutterError(flutterErrorDetails); return _instance.recordFlutterError(flutterErrorDetails);
} }
} }

View file

@ -1,10 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:aves/services/output_buffer.dart'; import 'package:aves/services/common/output_buffer.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:streams_channel/streams_channel.dart'; import 'package:streams_channel/streams_channel.dart';
@ -15,20 +14,17 @@ abstract class StorageService {
Future<List<String>> getGrantedDirectories(); Future<List<String>> getGrantedDirectories();
Future<void> revokeDirectoryAccess(String path);
Future<Set<VolumeRelativeDirectory>> getInaccessibleDirectories(Iterable<String> dirPaths); Future<Set<VolumeRelativeDirectory>> getInaccessibleDirectories(Iterable<String> dirPaths);
Future<Set<VolumeRelativeDirectory>> getRestrictedDirectories(); Future<Set<VolumeRelativeDirectory>> getRestrictedDirectories();
// returns whether user granted access to volume root at `volumePath` Future<void> revokeDirectoryAccess(String path);
Future<bool> requestVolumeAccess(String volumePath);
// returns number of deleted directories // returns number of deleted directories
Future<int> deleteEmptyDirectories(Iterable<String> dirPaths); Future<int> deleteEmptyDirectories(Iterable<String> dirPaths);
// returns media URI // returns whether user granted access to volume root at `volumePath`
Future<Uri?> scanFile(String path, String mimeType); Future<bool> requestVolumeAccess(String volumePath);
// return whether operation succeeded (`null` if user cancelled) // return whether operation succeeded (`null` if user cancelled)
Future<bool?> createFile(String name, String mimeType, Uint8List bytes); Future<bool?> createFile(String name, String mimeType, Uint8List bytes);
@ -77,18 +73,6 @@ class PlatformStorageService implements StorageService {
return []; return [];
} }
@override
Future<void> revokeDirectoryAccess(String path) async {
try {
await platform.invokeMethod('revokeDirectoryAccess', <String, dynamic>{
'path': path,
});
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return;
}
@override @override
Future<Set<VolumeRelativeDirectory>> getInaccessibleDirectories(Iterable<String> dirPaths) async { Future<Set<VolumeRelativeDirectory>> getInaccessibleDirectories(Iterable<String> dirPaths) async {
try { try {
@ -117,6 +101,32 @@ class PlatformStorageService implements StorageService {
return {}; return {};
} }
@override
Future<void> revokeDirectoryAccess(String path) async {
try {
await platform.invokeMethod('revokeDirectoryAccess', <String, dynamic>{
'path': path,
});
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return;
}
// returns number of deleted directories
@override
Future<int> deleteEmptyDirectories(Iterable<String> dirPaths) async {
try {
final result = await platform.invokeMethod('deleteEmptyDirectories', <String, dynamic>{
'dirPaths': dirPaths.toList(),
});
if (result != null) return result as int;
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return 0;
}
// returns whether user granted access to volume root at `volumePath` // returns whether user granted access to volume root at `volumePath`
@override @override
Future<bool> requestVolumeAccess(String volumePath) async { Future<bool> requestVolumeAccess(String volumePath) async {
@ -140,36 +150,6 @@ class PlatformStorageService implements StorageService {
return false; return false;
} }
// returns number of deleted directories
@override
Future<int> deleteEmptyDirectories(Iterable<String> dirPaths) async {
try {
final result = await platform.invokeMethod('deleteEmptyDirectories', <String, dynamic>{
'dirPaths': dirPaths.toList(),
});
if (result != null) return result as int;
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return 0;
}
// returns media URI
@override
Future<Uri?> scanFile(String path, String mimeType) async {
debugPrint('scanFile with path=$path, mimeType=$mimeType');
try {
final result = await platform.invokeMethod('scanFile', <String, dynamic>{
'path': path,
'mimeType': mimeType,
});
if (result != null) return Uri.tryParse(result);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return null;
}
@override @override
Future<bool?> createFile(String name, String mimeType, Uint8List bytes) async { Future<bool?> createFile(String name, String mimeType, Uint8List bytes) async {
try { try {

View file

@ -1,20 +0,0 @@
import 'package:aves/services/services.dart';
import 'package:flutter/services.dart';
abstract class TimeService {
Future<String?> getDefaultTimeZone();
}
class PlatformTimeService implements TimeService {
static const platform = MethodChannel('deckers.thibault/aves/time');
@override
Future<String?> getDefaultTimeZone() async {
try {
return await platform.invokeMethod('getDefaultTimeZone');
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return null;
}
}

View file

@ -1,4 +1,4 @@
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
class ViewerService { class ViewerService {

View file

@ -1,4 +1,4 @@
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';

View file

@ -1,4 +1,7 @@
import 'package:flutter/scheduler.dart'; import 'package:aves/model/settings/accessibility_animations.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:flutter/widgets.dart';
import 'package:provider/provider.dart';
class Durations { class Durations {
// Flutter animations (with margin) // Flutter animations (with margin)
@ -10,12 +13,8 @@ class Durations {
static const toggleableTransitionAnimation = Duration(milliseconds: 200 + 10); // ref `_kToggleDuration` used in `ToggleableStateMixin` static const toggleableTransitionAnimation = Duration(milliseconds: 200 + 10); // ref `_kToggleDuration` used in `ToggleableStateMixin`
// common animations // common animations
static const iconAnimation = Duration(milliseconds: 300);
static const sweeperOpacityAnimation = Duration(milliseconds: 150); static const sweeperOpacityAnimation = Duration(milliseconds: 150);
static const sweepingAnimation = Duration(milliseconds: 650); static const sweepingAnimation = Duration(milliseconds: 650);
static const staggeredAnimation = Duration(milliseconds: 375);
static const staggeredAnimationPageTarget = Duration(milliseconds: 800);
static const dialogFieldReachAnimation = Duration(milliseconds: 300); static const dialogFieldReachAnimation = Duration(milliseconds: 300);
static const appBarTitleAnimation = Duration(milliseconds: 300); static const appBarTitleAnimation = Duration(milliseconds: 300);
@ -40,9 +39,6 @@ class Durations {
static const filterRowExpandAnimation = Duration(milliseconds: 300); static const filterRowExpandAnimation = Duration(milliseconds: 300);
// viewer animations // viewer animations
static const viewerVerticalPageScrollAnimation = Duration(milliseconds: 500);
static const viewerOverlayAnimation = Duration(milliseconds: 200);
static const viewerOverlayChangeAnimation = Duration(milliseconds: 150);
static const thumbnailScrollerScrollAnimation = Duration(milliseconds: 200); static const thumbnailScrollerScrollAnimation = Duration(milliseconds: 200);
static const thumbnailScrollerShadeAnimation = Duration(milliseconds: 150); static const thumbnailScrollerShadeAnimation = Duration(milliseconds: 150);
static const viewerVideoPlayerTransition = Duration(milliseconds: 500); static const viewerVideoPlayerTransition = Duration(milliseconds: 500);
@ -56,7 +52,7 @@ class Durations {
static const quickActionHighlightAnimation = Duration(milliseconds: 200); static const quickActionHighlightAnimation = Duration(milliseconds: 200);
// delays & refresh intervals // delays & refresh intervals
static const opToastDisplay = Duration(seconds: 3); static const opToastTextDisplay = Duration(seconds: 3);
static const opToastActionDisplay = Duration(seconds: 5); static const opToastActionDisplay = Duration(seconds: 5);
static const infoScrollMonitoringTimerDelay = Duration(milliseconds: 100); static const infoScrollMonitoringTimerDelay = Duration(milliseconds: 100);
static const collectionScrollMonitoringTimerDelay = Duration(milliseconds: 100); static const collectionScrollMonitoringTimerDelay = Duration(milliseconds: 100);
@ -64,14 +60,73 @@ class Durations {
static const highlightScrollInitDelay = Duration(milliseconds: 800); static const highlightScrollInitDelay = Duration(milliseconds: 800);
static const videoOverlayHideDelay = Duration(milliseconds: 500); static const videoOverlayHideDelay = Duration(milliseconds: 500);
static const videoProgressTimerInterval = Duration(milliseconds: 300); static const videoProgressTimerInterval = Duration(milliseconds: 300);
static Duration staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation;
static const doubleBackTimerDelay = Duration(milliseconds: 1000); static const doubleBackTimerDelay = Duration(milliseconds: 1000);
static const softKeyboardDisplayDelay = Duration(milliseconds: 300); static const softKeyboardDisplayDelay = Duration(milliseconds: 300);
static const searchDebounceDelay = Duration(milliseconds: 250); static const searchDebounceDelay = Duration(milliseconds: 250);
static const contentChangeDebounceDelay = Duration(milliseconds: 1000); static const contentChangeDebounceDelay = Duration(milliseconds: 1000);
static const mapScrollDebounceDelay = Duration(milliseconds: 150); static const mapInfoDebounceDelay = Duration(milliseconds: 150);
static const mapIdleDebounceDelay = Duration(milliseconds: 100); static const mapIdleDebounceDelay = Duration(milliseconds: 100);
// app life // app life
static const lastVersionCheckInterval = Duration(days: 7); static const lastVersionCheckInterval = Duration(days: 7);
} }
class DurationsProvider extends StatelessWidget {
final Widget child;
const DurationsProvider({
Key? key,
required this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return ProxyProvider<Settings, DurationsData>(
update: (context, settings, __) {
final enabled = settings.accessibilityAnimations.animate;
return enabled ? DurationsData() : DurationsData.noAnimation();
},
child: child,
);
}
}
@immutable
class DurationsData {
// common animations
final Duration expansionTileAnimation;
final Duration iconAnimation;
final Duration staggeredAnimation;
final Duration staggeredAnimationPageTarget;
// viewer animations
final Duration viewerVerticalPageScrollAnimation;
final Duration viewerOverlayAnimation;
final Duration viewerOverlayChangeAnimation;
// delays & refresh intervals
final Duration staggeredAnimationDelay;
const DurationsData({
this.expansionTileAnimation = const Duration(milliseconds: 200),
this.iconAnimation = const Duration(milliseconds: 300),
this.staggeredAnimation = const Duration(milliseconds: 375),
this.staggeredAnimationPageTarget = const Duration(milliseconds: 800),
this.viewerVerticalPageScrollAnimation = const Duration(milliseconds: 500),
this.viewerOverlayAnimation = const Duration(milliseconds: 200),
this.viewerOverlayChangeAnimation = const Duration(milliseconds: 150),
}) : staggeredAnimationDelay = staggeredAnimation ~/ 6;
factory DurationsData.noAnimation() {
return DurationsData(
// as of Flutter v2.5.1, `ExpansionPanelList` throws if animation duration is zero
expansionTileAnimation: const Duration(microseconds: 1),
iconAnimation: Duration.zero,
staggeredAnimation: Duration.zero,
staggeredAnimationPageTarget: Duration.zero,
viewerVerticalPageScrollAnimation: Duration.zero,
viewerOverlayAnimation: Duration.zero,
viewerOverlayChangeAnimation: Duration.zero,
);
}
}

View file

@ -7,6 +7,7 @@ class AIcons {
static const IconData video = Icons.movie_outlined; static const IconData video = Icons.movie_outlined;
static const IconData vector = Icons.code_outlined; static const IconData vector = Icons.code_outlined;
static const IconData accessibility = Icons.accessibility_new_outlined;
static const IconData android = Icons.android; static const IconData android = Icons.android;
static const IconData broken = Icons.broken_image_outlined; static const IconData broken = Icons.broken_image_outlined;
static const IconData checked = Icons.done_outlined; static const IconData checked = Icons.done_outlined;

View file

@ -17,25 +17,24 @@ class Themes {
static final darkTheme = ThemeData( static final darkTheme = ThemeData(
brightness: Brightness.dark, brightness: Brightness.dark,
accentColor: _accentColor,
// canvas color is used as background for the drawer and popups // canvas color is used as background for the drawer and popups
// when using a popup menu on a dialog, lighten the background via `PopupMenuTheme` // when using a popup menu on a dialog, lighten the background via `PopupMenuTheme`
canvasColor: Colors.grey[850], canvasColor: Colors.grey[850],
scaffoldBackgroundColor: Colors.grey.shade900, scaffoldBackgroundColor: Colors.grey.shade900,
dialogBackgroundColor: Colors.grey[850], dialogBackgroundColor: Colors.grey[850],
indicatorColor: _accentColor,
toggleableActiveColor: _accentColor, toggleableActiveColor: _accentColor,
tooltipTheme: const TooltipThemeData( tooltipTheme: const TooltipThemeData(
verticalOffset: 32, verticalOffset: 32,
), ),
appBarTheme: const AppBarTheme( appBarTheme: AppBarTheme(
textTheme: TextTheme( backgroundColor: Colors.grey.shade900,
headline6: TextStyle( titleTextStyle: const TextStyle(
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.normal, fontWeight: FontWeight.normal,
fontFeatures: [FontFeature.enable('smcp')], fontFeatures: [FontFeature.enable('smcp')],
), ),
), ),
),
colorScheme: const ColorScheme.dark( colorScheme: const ColorScheme.dark(
primary: _accentColor, primary: _accentColor,
secondary: _accentColor, secondary: _accentColor,

View file

@ -1,5 +1,5 @@
import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';

27
lib/utils/geo_utils.dart Normal file
View file

@ -0,0 +1,27 @@
import 'dart:math';
import 'package:latlong2/latlong.dart';
LatLng getLatLngCenter(List<LatLng> points) {
double x = 0;
double y = 0;
double z = 0;
points.forEach((point) {
final lat = point.latitudeInRad;
final lng = point.longitudeInRad;
x += cos(lat) * cos(lng);
y += cos(lat) * sin(lng);
z += sin(lat);
});
final pointCount = points.length;
x /= pointCount;
y /= pointCount;
z /= pointCount;
final lng = atan2(y, x);
final hyp = sqrt(x * x + y * y);
final lat = atan2(z, hyp);
return LatLng(radianToDeg(lat), radianToDeg(lng));
}

View file

@ -1,11 +1,5 @@
import 'dart:math'; import 'dart:math';
const double _piOver180 = pi / 180.0;
double toDegrees(num radians) => radians / _piOver180;
double toRadians(num degrees) => degrees * _piOver180;
int highestPowerOf2(num x) => x < 1 ? 0 : pow(2, (log(x) / ln2).floor()).toInt(); int highestPowerOf2(num x) => x < 1 ? 0 : pow(2, (log(x) / ln2).floor()).toInt();
int smallestPowerOf2(num x) => x < 1 ? 1 : pow(2, (log(x) / ln2).ceil()).toInt(); int smallestPowerOf2(num x) => x < 1 ? 1 : pow(2, (log(x) / ln2).ceil()).toInt();

View file

@ -1,2 +0,0 @@
// cf https://github.com/google/pedantic/blob/master/lib/pedantic.dart
void unawaited(Future<void>? future) {}

View file

@ -4,16 +4,19 @@ import 'dart:typed_data';
import 'package:aves/flutter_version.dart'; import 'package:aves/flutter_version.dart';
import 'package:aves/ref/mime_types.dart'; import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:aves/widgets/common/identity/buttons.dart';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
class BugReport extends StatefulWidget { class BugReport extends StatefulWidget {
@ -36,10 +39,12 @@ class _BugReportState extends State<BugReport> with FeedbackMixin {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
final animationDuration = context.select<DurationsData, Duration>((v) => v.expansionTileAnimation);
return ExpansionPanelList( return ExpansionPanelList(
expansionCallback: (index, isExpanded) { expansionCallback: (index, isExpanded) {
setState(() => _showInstructions = !isExpanded); setState(() => _showInstructions = !isExpanded);
}, },
animationDuration: animationDuration,
expandedHeaderPadding: EdgeInsets.zero, expandedHeaderPadding: EdgeInsets.zero,
elevation: 0, elevation: 0,
children: [ children: [
@ -84,7 +89,7 @@ class _BugReportState extends State<BugReport> with FeedbackMixin {
), ),
isExpanded: _showInstructions, isExpanded: _showInstructions,
canTapOnHeader: true, canTapOnHeader: true,
backgroundColor: Colors.transparent, backgroundColor: Theme.of(context).scaffoldBackgroundColor,
), ),
], ],
); );
@ -99,7 +104,7 @@ class _BugReportState extends State<BugReport> with FeedbackMixin {
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.fromBorderSide(BorderSide( border: Border.fromBorderSide(BorderSide(
color: Theme.of(context).accentColor, color: Theme.of(context).colorScheme.secondary,
width: AvesFilterChip.outlineWidth, width: AvesFilterChip.outlineWidth,
)), )),
shape: BoxShape.circle, shape: BoxShape.circle,
@ -109,13 +114,9 @@ class _BugReportState extends State<BugReport> with FeedbackMixin {
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded(child: Text(text)), Expanded(child: Text(text)),
const SizedBox(width: 8), const SizedBox(width: 8),
OutlinedButton( AvesOutlinedButton(
label: buttonText,
onPressed: onPressed, onPressed: onPressed,
style: ButtonStyle(
side: MaterialStateProperty.all<BorderSide>(BorderSide(color: Theme.of(context).accentColor)),
foregroundColor: MaterialStateProperty.all<Color>(Colors.white),
),
child: Text(buttonText),
) )
], ],
), ),

View file

@ -3,6 +3,7 @@ import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/basic/link_chip.dart'; import 'package:aves/widgets/common/basic/link_chip.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/common/identity/buttons.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -69,7 +70,8 @@ class _LicensesState extends State<Licenses> {
children: _dartPackages.map((package) => LicenseRow(package: package)).toList(), children: _dartPackages.map((package) => LicenseRow(package: package)).toList(),
), ),
Center( Center(
child: TextButton( child: AvesOutlinedButton(
label: context.l10n.aboutLicensesShowAllButtonLabel,
onPressed: () => Navigator.push( onPressed: () => Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@ -82,7 +84,6 @@ class _LicensesState extends State<Licenses> {
), ),
), ),
), ),
child: Text(context.l10n.aboutLicensesShowAllButtonLabel),
), ),
), ),
], ],

View file

@ -1,4 +1,4 @@
import 'package:aves/services/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/about/news_badge.dart'; import 'package:aves/widgets/about/news_badge.dart';
import 'package:aves/widgets/common/basic/link_chip.dart'; import 'package:aves/widgets/common/basic/link_chip.dart';

View file

@ -1,10 +1,13 @@
import 'dart:ui'; import 'dart:ui';
import 'package:aves/app_mode.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/model/settings/accessibility_animations.dart';
import 'package:aves/model/settings/screen_on.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/media_store_source.dart'; import 'package:aves/model/source/media_store_source.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/accessibility_service.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/theme/themes.dart'; import 'package:aves/theme/themes.dart';
@ -16,13 +19,13 @@ import 'package:aves/widgets/common/providers/highlight_info_provider.dart';
import 'package:aves/widgets/home_page.dart'; import 'package:aves/widgets/home_page.dart';
import 'package:aves/widgets/welcome_page.dart'; import 'package:aves/widgets/welcome_page.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:overlay_support/overlay_support.dart'; import 'package:overlay_support/overlay_support.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class AvesApp extends StatefulWidget { class AvesApp extends StatefulWidget {
const AvesApp({Key? key}) : super(key: key); const AvesApp({Key? key}) : super(key: key);
@ -41,7 +44,7 @@ class _AvesAppState extends State<AvesApp> {
// observers are not registered when using the same list object with different items // observers are not registered when using the same list object with different items
// the list itself needs to be reassigned // the list itself needs to be reassigned
List<NavigatorObserver> _navigatorObservers = []; List<NavigatorObserver> _navigatorObservers = [];
final EventChannel _mediaStoreChangeChannel = const EventChannel('deckers.thibault/aves/mediastorechange'); final EventChannel _mediaStoreChangeChannel = const EventChannel('deckers.thibault/aves/media_store_change');
final EventChannel _newIntentChannel = const EventChannel('deckers.thibault/aves/intent'); final EventChannel _newIntentChannel = const EventChannel('deckers.thibault/aves/intent');
final EventChannel _errorChannel = const EventChannel('deckers.thibault/aves/error'); final EventChannel _errorChannel = const EventChannel('deckers.thibault/aves/error');
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator'); final GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator');
@ -68,6 +71,7 @@ class _AvesAppState extends State<AvesApp> {
value: appModeNotifier, value: appModeNotifier,
child: Provider<CollectionSource>.value( child: Provider<CollectionSource>.value(
value: _mediaStoreSource, value: _mediaStoreSource,
child: DurationsProvider(
child: HighlightInfoProvider( child: HighlightInfoProvider(
child: OverlaySupport( child: OverlaySupport(
child: FutureBuilder<void>( child: FutureBuilder<void>(
@ -79,13 +83,27 @@ class _AvesAppState extends State<AvesApp> {
: Scaffold( : Scaffold(
body: snapshot.hasError ? _buildError(snapshot.error!) : const SizedBox(), body: snapshot.hasError ? _buildError(snapshot.error!) : const SizedBox(),
); );
return Selector<Settings, Locale?>( return Selector<Settings, Tuple2<Locale?, bool>>(
selector: (context, s) => s.locale, selector: (context, s) => Tuple2(s.locale, s.initialized ? s.accessibilityAnimations.animate : true),
builder: (context, settingsLocale, child) { builder: (context, s, child) {
final settingsLocale = s.item1;
final areAnimationsEnabled = s.item2;
return MaterialApp( return MaterialApp(
navigatorKey: _navigatorKey, navigatorKey: _navigatorKey,
home: home, home: home,
navigatorObservers: _navigatorObservers, navigatorObservers: _navigatorObservers,
builder: (context, child) {
if (!areAnimationsEnabled) {
child = Theme(
data: Theme.of(context).copyWith(
// strip page transitions used by `MaterialPageRoute`
pageTransitionsTheme: DirectPageTransitionsTheme(),
),
child: child!,
);
}
return child!;
},
onGenerateTitle: (context) => context.l10n.appName, onGenerateTitle: (context) => context.l10n.appName,
darkTheme: Themes.darkTheme, darkTheme: Themes.darkTheme,
themeMode: ThemeMode.dark, themeMode: ThemeMode.dark,
@ -97,8 +115,10 @@ class _AvesAppState extends State<AvesApp> {
// checkerboardRasterCacheImages: true, // checkerboardRasterCacheImages: true,
// checkerboardOffscreenLayers: true, // checkerboardOffscreenLayers: true,
); );
});
}, },
);
},
),
), ),
), ),
), ),
@ -123,7 +143,24 @@ class _AvesAppState extends State<AvesApp> {
} }
Future<void> _setup() async { Future<void> _setup() async {
await Firebase.initializeApp().then((app) async { await settings.init(
isRotationLocked: await windowService.isRotationLocked(),
areAnimationsRemoved: await AccessibilityService.areAnimationsRemoved(),
);
// keep screen on
settings.updateStream.where((key) => key == Settings.keepScreenOnKey).listen(
(_) => settings.keepScreenOn.apply(),
);
settings.keepScreenOn.apply();
// error reporting
await reportService.init();
settings.updateStream.where((key) => key == Settings.isErrorReportingEnabledKey).listen(
(_) => reportService.setCollectionEnabled(settings.isErrorReportingEnabled),
);
await reportService.setCollectionEnabled(settings.isErrorReportingEnabled);
FlutterError.onError = reportService.recordFlutterError; FlutterError.onError = reportService.recordFlutterError;
final now = DateTime.now(); final now = DateTime.now();
final hasPlayServices = await availability.hasPlayServices; final hasPlayServices = await availability.hasPlayServices;
@ -137,11 +174,8 @@ class _AvesAppState extends State<AvesApp> {
'locales': window.locales.join(', '), 'locales': window.locales.join(', '),
'time_zone': '${now.timeZoneName} (${now.timeZoneOffset})', 'time_zone': '${now.timeZoneName} (${now.timeZoneOffset})',
}); });
});
await settings.init();
await settings.initFirebase();
_navigatorObservers = [ _navigatorObservers = [
CrashlyticsRouteTracker(), ReportingRouteTracker(),
]; ];
} }

View file

@ -9,9 +9,8 @@ import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/services/app_shortcut_service.dart'; import 'package:aves/services/android_app_service.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/utils/pedantic.dart';
import 'package:aves/widgets/collection/entry_set_action_delegate.dart'; import 'package:aves/widgets/collection/entry_set_action_delegate.dart';
import 'package:aves/widgets/collection/filter_bar.dart'; import 'package:aves/widgets/collection/filter_bar.dart';
import 'package:aves/widgets/common/app_bar_subtitle.dart'; import 'package:aves/widgets/common/app_bar_subtitle.dart';
@ -58,11 +57,11 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
void initState() { void initState() {
super.initState(); super.initState();
_browseToSelectAnimation = AnimationController( _browseToSelectAnimation = AnimationController(
duration: Durations.iconAnimation, duration: context.read<DurationsData>().iconAnimation,
vsync: this, vsync: this,
); );
_isSelectingNotifier.addListener(_onActivityChange); _isSelectingNotifier.addListener(_onActivityChange);
_canAddShortcutsLoader = AppShortcutService.canPin(); _canAddShortcutsLoader = AndroidAppService.canPinToHomeScreen();
_registerWidget(widget); _registerWidget(widget);
WidgetsBinding.instance!.addPostFrameCallback((_) => _onFilterChanged()); WidgetsBinding.instance!.addPostFrameCallback((_) => _onFilterChanged());
} }
@ -243,9 +242,10 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
] ]
]; ];
}, },
onSelected: (action) { onSelected: (action) async {
// wait for the popup menu to hide before proceeding with the action // wait for the popup menu to hide before proceeding with the action
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onCollectionActionSelected(action)); await Future.delayed(Durations.popupMenuAnimation * timeDilation);
await _onCollectionActionSelected(action);
}, },
), ),
); );
@ -290,7 +290,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
case EntrySetAction.delete: case EntrySetAction.delete:
case EntrySetAction.copy: case EntrySetAction.copy:
case EntrySetAction.move: case EntrySetAction.move:
case EntrySetAction.refreshMetadata: case EntrySetAction.rescan:
case EntrySetAction.map: case EntrySetAction.map:
case EntrySetAction.stats: case EntrySetAction.stats:
_actionDelegate.onActionSelected(context, action); _actionDelegate.onActionSelected(context, action);
@ -371,7 +371,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
final name = result.item2; final name = result.item2;
if (name.isEmpty) return; if (name.isEmpty) return;
unawaited(AppShortcutService.pin(name, coverEntry, filters)); unawaited(AndroidAppService.pinToHomeScreen(name, coverEntry, filters));
} }
void _goToSearch() { void _goToSearch() {

View file

@ -79,18 +79,18 @@ class _CollectionGridContent extends StatelessWidget {
final sectionedListLayoutProvider = ValueListenableBuilder<double>( final sectionedListLayoutProvider = ValueListenableBuilder<double>(
valueListenable: context.select<TileExtentController, ValueNotifier<double>>((controller) => controller.extentNotifier), valueListenable: context.select<TileExtentController, ValueNotifier<double>>((controller) => controller.extentNotifier),
builder: (context, tileExtent, child) { builder: (context, tileExtent, child) {
return GridTheme( return Selector<TileExtentController, Tuple3<double, int, double>>(
extent: tileExtent,
child: Selector<TileExtentController, Tuple3<double, int, double>>(
selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing), selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing),
builder: (context, c, child) { builder: (context, c, child) {
final scrollableWidth = c.item1; final scrollableWidth = c.item1;
final columnCount = c.item2; final columnCount = c.item2;
final tileSpacing = c.item3; final tileSpacing = c.item3;
// do not listen for animation delay change // do not listen for animation delay change
final controller = Provider.of<TileExtentController>(context, listen: false); final target = context.read<DurationsData>().staggeredAnimationPageTarget;
final tileAnimationDelay = controller.getTileAnimationDelay(Durations.staggeredAnimationPageTarget); final tileAnimationDelay = context.read<TileExtentController>().getTileAnimationDelay(target);
return SectionedEntryListLayoutProvider( return GridTheme(
extent: tileExtent,
child: SectionedEntryListLayoutProvider(
collection: collection, collection: collection,
scrollableWidth: scrollableWidth, scrollableWidth: scrollableWidth,
columnCount: columnCount, columnCount: columnCount,
@ -104,17 +104,19 @@ class _CollectionGridContent extends StatelessWidget {
isScrollingNotifier: _isScrollingNotifier, isScrollingNotifier: _isScrollingNotifier,
), ),
tileAnimationDelay: tileAnimationDelay, tileAnimationDelay: tileAnimationDelay,
child: child!,
),
);
},
child: child,
);
},
child: _CollectionSectionedContent( child: _CollectionSectionedContent(
collection: collection, collection: collection,
isScrollingNotifier: _isScrollingNotifier, isScrollingNotifier: _isScrollingNotifier,
scrollController: PrimaryScrollController.of(context)!, scrollController: PrimaryScrollController.of(context)!,
), ),
); );
},
),
);
},
);
return sectionedListLayoutProvider; return sectionedListLayoutProvider;
}, },
); );
@ -199,10 +201,11 @@ class _CollectionScaler extends StatelessWidget {
final tileSpacing = context.select<TileExtentController, double>((controller) => controller.spacing); final tileSpacing = context.select<TileExtentController, double>((controller) => controller.spacing);
return GridScaleGestureDetector<AvesEntry>( return GridScaleGestureDetector<AvesEntry>(
scrollableKey: scrollableKey, scrollableKey: scrollableKey,
gridBuilder: (center, extent, child) => CustomPaint( heightForWidth: (width) => width,
gridBuilder: (center, tileSize, child) => CustomPaint(
painter: GridPainter( painter: GridPainter(
center: center, center: center,
extent: extent, tileSize: tileSize,
spacing: tileSpacing, spacing: tileSpacing,
borderWidth: DecoratedThumbnail.borderWidth, borderWidth: DecoratedThumbnail.borderWidth,
borderRadius: Radius.zero, borderRadius: Radius.zero,
@ -210,7 +213,7 @@ class _CollectionScaler extends StatelessWidget {
), ),
child: child, child: child,
), ),
scaledBuilder: (entry, extent) => DecoratedThumbnail( scaledBuilder: (entry, tileSize) => DecoratedThumbnail(
entry: entry, entry: entry,
tileExtent: context.read<TileExtentController>().effectiveExtentMax, tileExtent: context.read<TileExtentController>().effectiveExtentMax,
selectable: false, selectable: false,

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