Merge branch 'develop'
This commit is contained in:
commit
da4d79a6f8
223 changed files with 4106 additions and 1950 deletions
2
.github/workflows/check.yml
vendored
2
.github/workflows/check.yml
vendored
|
@ -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
|
||||||
|
|
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
@ -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
|
||||||
|
|
17
CHANGELOG.md
17
CHANGELOG.md
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>()
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
||||||
|
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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",
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -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 ->
|
||||||
|
|
|
@ -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,69 +635,63 @@ 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,
|
}
|
||||||
)
|
|
||||||
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) {
|
val originalFileSize = File(path).length()
|
||||||
cont.resumeWithException(Exception("failed to get URI of item at path=$path"))
|
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.toInt()
|
||||||
return@scanFile
|
val editableFile = File.createTempFile("aves", null).apply {
|
||||||
}
|
deleteOnExit()
|
||||||
|
try {
|
||||||
var contentUri: Uri? = null
|
outputStream().use { output ->
|
||||||
// `newURI` is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872")
|
// reopen input to read from start
|
||||||
// but we need an image/video media URI (e.g. "content://media/external/images/media/62872")
|
originalDocumentFile.openInputStream().use { input ->
|
||||||
val contentId = newUri.tryParseId()
|
PixyMetaHelper.removeMetadata(input, output, types)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
// prefer image/video content URI, fallback to original URI (possibly a file content URI)
|
Log.d(LOG_TAG, "failed to remove metadata", e)
|
||||||
val newFields = scanUri(contentUri) ?: scanUri(newUri)
|
callback.onFailure(e)
|
||||||
|
return
|
||||||
if (newFields != null) {
|
|
||||||
cont.resume(newFields)
|
|
||||||
} else {
|
|
||||||
cont.resumeWithException(Exception("failed to get item details from provider at contentUri=$contentUri (from newUri=$newUri)"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// copy the edited temporary file back to the original
|
||||||
|
DocumentFileCompat.fromFile(editableFile).copyTo(originalDocumentFile)
|
||||||
|
|
||||||
|
if (!types.contains(Metadata.TYPE_XMP) && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
callback.onFailure(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val newFields = HashMap<String, Any?>()
|
||||||
|
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
|
||||||
|
}
|
||||||
|
|
||||||
interface ImageOpCallback {
|
interface ImageOpCallback {
|
||||||
fun onSuccess(fields: FieldMap)
|
fun onSuccess(fields: FieldMap)
|
||||||
fun onFailure(throwable: Throwable)
|
fun onFailure(throwable: Throwable)
|
||||||
|
|
|
@ -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,28 +43,40 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// the uri can be a file media URI (e.g. "content://0@media/external/file/30050")
|
if (!found) {
|
||||||
// without an equivalent image/video if it is shared from a file browser
|
// the uri can be a file media URI (e.g. "content://0@media/external/file/30050")
|
||||||
// but the file is not publicly visible
|
// without an equivalent image/video if it is shared from a file browser
|
||||||
if (fetchFrom(context, alwaysValid, onSuccess, uri, BASE_PROJECTION, fileMimeType = sourceMimeType)) return
|
// but the file is not publicly visible
|
||||||
|
found = fetchFrom(context, alwaysValid, onSuccess, uri, BASE_PROJECTION, fileMimeType = sourceMimeType)
|
||||||
|
}
|
||||||
|
|
||||||
callback.onFailure(Exception("failed to fetch entry at uri=$uri"))
|
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"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun checkObsoleteContentIds(context: Context, knownContentIds: List<Int>): List<Int> {
|
fun checkObsoleteContentIds(context: Context, knownContentIds: List<Int>): List<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>()
|
||||||
|
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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": {},
|
||||||
|
|
|
@ -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": "열기",
|
||||||
|
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
enum EntryInfoAction {
|
enum EntryInfoAction {
|
||||||
editDate,
|
editDate,
|
||||||
|
removeMetadata,
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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('-');
|
||||||
|
|
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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';
|
||||||
|
|
||||||
|
|
31
lib/model/settings/accessibility_animations.dart
Normal file
31
lib/model/settings/accessibility_animations.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
23
lib/model/settings/accessibility_timeout.dart
Normal file
23
lib/model/settings/accessibility_timeout.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
|
@ -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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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};
|
||||||
|
|
||||||
|
|
52
lib/services/accessibility_service.dart
Normal file
52
lib/services/accessibility_service.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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());
|
||||||
}
|
}
|
|
@ -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');
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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 {
|
|
@ -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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
131
lib/services/metadata/metadata_edit_service.dart
Normal file
131
lib/services/metadata/metadata_edit_service.dart
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 {
|
|
@ -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;
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -17,23 +17,22 @@ 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(
|
||||||
|
|
|
@ -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
27
lib/utils/geo_utils.dart
Normal 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));
|
||||||
|
}
|
|
@ -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();
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
// cf https://github.com/google/pedantic/blob/master/lib/pedantic.dart
|
|
||||||
void unawaited(Future<void>? future) {}
|
|
|
@ -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),
|
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
@ -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),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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,24 +71,39 @@ class _AvesAppState extends State<AvesApp> {
|
||||||
value: appModeNotifier,
|
value: appModeNotifier,
|
||||||
child: Provider<CollectionSource>.value(
|
child: Provider<CollectionSource>.value(
|
||||||
value: _mediaStoreSource,
|
value: _mediaStoreSource,
|
||||||
child: HighlightInfoProvider(
|
child: DurationsProvider(
|
||||||
child: OverlaySupport(
|
child: HighlightInfoProvider(
|
||||||
child: FutureBuilder<void>(
|
child: OverlaySupport(
|
||||||
future: _appSetup,
|
child: FutureBuilder<void>(
|
||||||
builder: (context, snapshot) {
|
future: _appSetup,
|
||||||
final initialized = !snapshot.hasError && snapshot.connectionState == ConnectionState.done;
|
builder: (context, snapshot) {
|
||||||
final home = initialized
|
final initialized = !snapshot.hasError && snapshot.connectionState == ConnectionState.done;
|
||||||
? getFirstPage()
|
final home = initialized
|
||||||
: Scaffold(
|
? getFirstPage()
|
||||||
body: snapshot.hasError ? _buildError(snapshot.error!) : const SizedBox(),
|
: Scaffold(
|
||||||
);
|
body: snapshot.hasError ? _buildError(snapshot.error!) : const SizedBox(),
|
||||||
return Selector<Settings, Locale?>(
|
);
|
||||||
selector: (context, s) => s.locale,
|
return Selector<Settings, Tuple2<Locale?, bool>>(
|
||||||
builder: (context, settingsLocale, child) {
|
selector: (context, s) => Tuple2(s.locale, s.initialized ? s.accessibilityAnimations.animate : true),
|
||||||
|
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,25 +143,39 @@ class _AvesAppState extends State<AvesApp> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _setup() async {
|
Future<void> _setup() async {
|
||||||
await Firebase.initializeApp().then((app) async {
|
await settings.init(
|
||||||
FlutterError.onError = reportService.recordFlutterError;
|
isRotationLocked: await windowService.isRotationLocked(),
|
||||||
final now = DateTime.now();
|
areAnimationsRemoved: await AccessibilityService.areAnimationsRemoved(),
|
||||||
final hasPlayServices = await availability.hasPlayServices;
|
);
|
||||||
await reportService.setCustomKeys({
|
|
||||||
'build_mode': kReleaseMode
|
// keep screen on
|
||||||
? 'release'
|
settings.updateStream.where((key) => key == Settings.keepScreenOnKey).listen(
|
||||||
: kProfileMode
|
(_) => settings.keepScreenOn.apply(),
|
||||||
? 'profile'
|
);
|
||||||
: 'debug',
|
settings.keepScreenOn.apply();
|
||||||
'has_play_services': hasPlayServices,
|
|
||||||
'locales': window.locales.join(', '),
|
// error reporting
|
||||||
'time_zone': '${now.timeZoneName} (${now.timeZoneOffset})',
|
await reportService.init();
|
||||||
});
|
settings.updateStream.where((key) => key == Settings.isErrorReportingEnabledKey).listen(
|
||||||
|
(_) => reportService.setCollectionEnabled(settings.isErrorReportingEnabled),
|
||||||
|
);
|
||||||
|
await reportService.setCollectionEnabled(settings.isErrorReportingEnabled);
|
||||||
|
|
||||||
|
FlutterError.onError = reportService.recordFlutterError;
|
||||||
|
final now = DateTime.now();
|
||||||
|
final hasPlayServices = await availability.hasPlayServices;
|
||||||
|
await reportService.setCustomKeys({
|
||||||
|
'build_mode': kReleaseMode
|
||||||
|
? 'release'
|
||||||
|
: kProfileMode
|
||||||
|
? 'profile'
|
||||||
|
: 'debug',
|
||||||
|
'has_play_services': hasPlayServices,
|
||||||
|
'locales': window.locales.join(', '),
|
||||||
|
'time_zone': '${now.timeZoneName} (${now.timeZoneOffset})',
|
||||||
});
|
});
|
||||||
await settings.init();
|
|
||||||
await settings.initFirebase();
|
|
||||||
_navigatorObservers = [
|
_navigatorObservers = [
|
||||||
CrashlyticsRouteTracker(),
|
ReportingRouteTracker(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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,
|
selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing),
|
||||||
child: Selector<TileExtentController, Tuple3<double, int, double>>(
|
builder: (context, c, child) {
|
||||||
selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing),
|
final scrollableWidth = c.item1;
|
||||||
builder: (context, c, child) {
|
final columnCount = c.item2;
|
||||||
final scrollableWidth = c.item1;
|
final tileSpacing = c.item3;
|
||||||
final columnCount = c.item2;
|
// do not listen for animation delay change
|
||||||
final tileSpacing = c.item3;
|
final target = context.read<DurationsData>().staggeredAnimationPageTarget;
|
||||||
// do not listen for animation delay change
|
final tileAnimationDelay = context.read<TileExtentController>().getTileAnimationDelay(target);
|
||||||
final controller = Provider.of<TileExtentController>(context, listen: false);
|
return GridTheme(
|
||||||
final tileAnimationDelay = controller.getTileAnimationDelay(Durations.staggeredAnimationPageTarget);
|
extent: tileExtent,
|
||||||
return SectionedEntryListLayoutProvider(
|
child: SectionedEntryListLayoutProvider(
|
||||||
collection: collection,
|
collection: collection,
|
||||||
scrollableWidth: scrollableWidth,
|
scrollableWidth: scrollableWidth,
|
||||||
columnCount: columnCount,
|
columnCount: columnCount,
|
||||||
|
@ -104,16 +104,18 @@ class _CollectionGridContent extends StatelessWidget {
|
||||||
isScrollingNotifier: _isScrollingNotifier,
|
isScrollingNotifier: _isScrollingNotifier,
|
||||||
),
|
),
|
||||||
tileAnimationDelay: tileAnimationDelay,
|
tileAnimationDelay: tileAnimationDelay,
|
||||||
child: _CollectionSectionedContent(
|
child: child!,
|
||||||
collection: collection,
|
),
|
||||||
isScrollingNotifier: _isScrollingNotifier,
|
);
|
||||||
scrollController: PrimaryScrollController.of(context)!,
|
},
|
||||||
),
|
child: child,
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
child: _CollectionSectionedContent(
|
||||||
|
collection: collection,
|
||||||
|
isScrollingNotifier: _isScrollingNotifier,
|
||||||
|
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
Loading…
Reference in a new issue