Merge branch 'develop'
This commit is contained in:
commit
5fbc9eebe5
174 changed files with 4115 additions and 2258 deletions
2
.github/workflows/check.yml
vendored
2
.github/workflows/check.yml
vendored
|
@ -15,7 +15,7 @@ jobs:
|
|||
- uses: subosito/flutter-action@v1
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: '1.22.5'
|
||||
flutter-version: '1.22.6'
|
||||
|
||||
- name: Clone the repository.
|
||||
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
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: '1.22.5'
|
||||
flutter-version: '1.22.6'
|
||||
|
||||
# Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1):
|
||||
# https://issuetracker.google.com/issues/144111441
|
||||
|
@ -50,8 +50,8 @@ jobs:
|
|||
echo "${{ secrets.KEY_JKS }}" > release.keystore.asc
|
||||
gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE
|
||||
rm release.keystore.asc
|
||||
flutter build apk --bundle-sksl-path shaders_1.22.5.sksl.json
|
||||
flutter build appbundle --bundle-sksl-path shaders_1.22.5.sksl.json
|
||||
flutter build apk --bundle-sksl-path shaders_1.22.6.sksl.json
|
||||
flutter build appbundle --bundle-sksl-path shaders_1.22.6.sksl.json
|
||||
rm $AVES_STORE_FILE
|
||||
env:
|
||||
AVES_STORE_FILE: ${{ github.workspace }}/key.jks
|
||||
|
|
16
CHANGELOG.md
16
CHANGELOG.md
|
@ -3,6 +3,22 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [v1.3.3] - 2021-01-31
|
||||
### Added
|
||||
- Viewer: support for multi-track HEIF
|
||||
- Viewer: export image (including multipage TIFF/HEIF and images embedded in XMP)
|
||||
- Info: show owner app (Android Q and up)
|
||||
- listen to Media Store changes
|
||||
|
||||
### Changed
|
||||
- upgraded Flutter to stable v1.22.6
|
||||
- check connectivity before using features that need it
|
||||
|
||||
### Fixed
|
||||
- checkerboard background performance
|
||||
- deleting files that no longer exist but are still registered in the Media Store
|
||||
- insets handling on Android 11
|
||||
|
||||
## [v1.3.2] - 2021-01-17
|
||||
### Added
|
||||
Collection: identify multipage TIFF & multitrack HEIC/HEIF
|
||||
|
|
|
@ -12,7 +12,7 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
|
|||
|
||||
## Features
|
||||
|
||||
- support raster images: JPEG, GIF, PNG, HEIC (from Android Pie), WEBP, TIFF, BMP, WBMP, ICO
|
||||
- support raster images: JPEG, GIF, PNG, HEIC/HEIF (including multi-track, from Android Pie), WEBP, TIFF (including multi-page), BMP, WBMP, ICO
|
||||
- support animated images: GIF, WEBP
|
||||
- support raw images: ARW, CR2, DNG, NEF, NRW, ORF, PEF, RAF, RW2, SRW
|
||||
- support vector images: SVG
|
||||
|
@ -36,10 +36,10 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
|
|||
|
||||
| Model | Name | Android Version | API |
|
||||
| ----------- | -------------------------- | --------------- | ---:|
|
||||
| SM-G970N | Samsung Galaxy S10e | 10 (Android10) | 29 |
|
||||
| SM-G981N | Samsung Galaxy S20 5G | 11 | 30 |
|
||||
| SM-G970N | Samsung Galaxy S10e | 10 (Q) | 29 |
|
||||
| SM-P580 | Samsung Galaxy Tab A 10.1 | 8.1.0 (Oreo) | 27 |
|
||||
| SM-G930S | Samsung Galaxy S7 | 8.0.0 (Oreo) | 26 |
|
||||
| E5823 | Sony Xperia Z5 Compact | 7.1.1 (Nougat) | 25 |
|
||||
|
||||
## Project Setup
|
||||
|
||||
|
|
|
@ -98,15 +98,15 @@ repositories {
|
|||
|
||||
dependencies {
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
|
||||
implementation 'androidx.core:core-ktx:1.5.0-alpha05' // v1.5.0-alpha02+ for ShortcutManagerCompat.setDynamicShortcuts
|
||||
implementation 'androidx.core:core-ktx:1.5.0-beta01' // v1.5.0-alpha02+ for ShortcutManagerCompat.setDynamicShortcuts
|
||||
implementation 'androidx.exifinterface:exifinterface:1.3.2'
|
||||
implementation 'com.commonsware.cwac:document:0.4.1'
|
||||
implementation 'com.drewnoakes:metadata-extractor:2.15.0'
|
||||
implementation 'com.github.deckerst:Android-TiffBitmapFactory:f87db4305d' // forked, built by JitPack
|
||||
implementation 'com.github.bumptech.glide:glide:4.11.0'
|
||||
implementation 'com.github.bumptech.glide:glide:4.12.0'
|
||||
|
||||
kapt 'androidx.annotation:annotation:1.1.0'
|
||||
kapt 'com.github.bumptech.glide:compiler:4.11.0'
|
||||
kapt 'com.github.bumptech.glide:compiler:4.12.0'
|
||||
|
||||
compileOnly rootProject.findProject(':streams_channel')
|
||||
}
|
||||
|
|
|
@ -40,7 +40,6 @@
|
|||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
|
|
|
@ -16,24 +16,18 @@ import deckers.thibault.aves.utils.LogUtils
|
|||
import deckers.thibault.aves.utils.PermissionManager
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag(MainActivity::class.java)
|
||||
const val INTENT_CHANNEL = "deckers.thibault/aves/intent"
|
||||
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer"
|
||||
}
|
||||
|
||||
private val intentStreamHandler = IntentStreamHandler()
|
||||
private lateinit var contentStreamHandler: ContentChangeStreamHandler
|
||||
private lateinit var intentStreamHandler: IntentStreamHandler
|
||||
private lateinit var intentDataMap: MutableMap<String, Any?>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
Log.i(LOG_TAG, "onCreate intent=$intent")
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
intentDataMap = extractIntentData(intent)
|
||||
|
||||
val messenger = flutterEngine!!.dartExecutor.binaryMessenger
|
||||
|
||||
MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
|
||||
|
@ -48,59 +42,34 @@ class MainActivity : FlutterActivity() {
|
|||
StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(this, args) }
|
||||
StreamsChannel(messenger, StorageAccessStreamHandler.CHANNEL).setStreamHandlerFactory { args -> StorageAccessStreamHandler(this, args) }
|
||||
|
||||
// Media Store change monitoring
|
||||
contentStreamHandler = ContentChangeStreamHandler(this).apply {
|
||||
EventChannel(messenger, ContentChangeStreamHandler.CHANNEL).setStreamHandler(this)
|
||||
}
|
||||
|
||||
// intent handling
|
||||
intentStreamHandler = IntentStreamHandler().apply {
|
||||
EventChannel(messenger, IntentStreamHandler.CHANNEL).setStreamHandler(this)
|
||||
}
|
||||
intentDataMap = extractIntentData(intent)
|
||||
MethodChannel(messenger, VIEWER_CHANNEL).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"getIntentData" -> {
|
||||
result.success(intentDataMap)
|
||||
intentDataMap.clear()
|
||||
}
|
||||
"pick" -> {
|
||||
val pickedUri = call.argument<String>("uri")
|
||||
if (pickedUri != null) {
|
||||
val intent = Intent().apply {
|
||||
data = Uri.parse(pickedUri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
setResult(RESULT_OK, intent)
|
||||
} else {
|
||||
setResult(RESULT_CANCELED)
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
"pick" -> pick(call)
|
||||
}
|
||||
}
|
||||
EventChannel(messenger, INTENT_CHANNEL).setStreamHandler(intentStreamHandler)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
|
||||
setupShortcuts()
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.N_MR1)
|
||||
private fun setupShortcuts() {
|
||||
// do not use 'route' as extra key, as the Flutter framework acts on it
|
||||
|
||||
val search = ShortcutInfoCompat.Builder(this, "search")
|
||||
.setShortLabel(getString(R.string.search_shortcut_short_label))
|
||||
.setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_search))
|
||||
.setIntent(
|
||||
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
||||
.putExtra("page", "/search")
|
||||
)
|
||||
.build()
|
||||
|
||||
val videos = ShortcutInfoCompat.Builder(this, "videos")
|
||||
.setShortLabel(getString(R.string.videos_shortcut_short_label))
|
||||
.setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_movie))
|
||||
.setIntent(
|
||||
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
||||
.putExtra("page", "/collection")
|
||||
.putExtra("filters", arrayOf("{\"type\":\"mime\",\"mime\":\"video/*\"}"))
|
||||
)
|
||||
.build()
|
||||
|
||||
ShortcutManagerCompat.setDynamicShortcuts(this, listOf(videos, search))
|
||||
override fun onDestroy() {
|
||||
contentStreamHandler.dispose()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
|
@ -109,6 +78,25 @@ class MainActivity : FlutterActivity() {
|
|||
intentStreamHandler.notifyNewIntent(extractIntentData(intent))
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode == PermissionManager.VOLUME_ACCESS_REQUEST_CODE) {
|
||||
val treeUri = data?.data
|
||||
if (resultCode != RESULT_OK || treeUri == null) {
|
||||
PermissionManager.onPermissionResult(requestCode, null)
|
||||
return
|
||||
}
|
||||
|
||||
// save access permissions across reboots
|
||||
val takeFlags = (data.flags
|
||||
and (Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION))
|
||||
contentResolver.takePersistableUriPermission(treeUri, takeFlags)
|
||||
|
||||
// resume pending action
|
||||
PermissionManager.onPermissionResult(requestCode, treeUri)
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
|
||||
when (intent?.action) {
|
||||
Intent.ACTION_MAIN -> {
|
||||
|
@ -138,22 +126,48 @@ class MainActivity : FlutterActivity() {
|
|||
return HashMap()
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode == PermissionManager.VOLUME_ACCESS_REQUEST_CODE) {
|
||||
val treeUri = data?.data
|
||||
if (resultCode != RESULT_OK || treeUri == null) {
|
||||
PermissionManager.onPermissionResult(requestCode, null)
|
||||
return
|
||||
private fun pick(call: MethodCall) {
|
||||
val pickedUri = call.argument<String>("uri")
|
||||
if (pickedUri != null) {
|
||||
val intent = Intent().apply {
|
||||
data = Uri.parse(pickedUri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
|
||||
// save access permissions across reboots
|
||||
val takeFlags = (data.flags
|
||||
and (Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION))
|
||||
contentResolver.takePersistableUriPermission(treeUri, takeFlags)
|
||||
|
||||
// resume pending action
|
||||
PermissionManager.onPermissionResult(requestCode, treeUri)
|
||||
setResult(RESULT_OK, intent)
|
||||
} else {
|
||||
setResult(RESULT_CANCELED)
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.N_MR1)
|
||||
private fun setupShortcuts() {
|
||||
// do not use 'route' as extra key, as the Flutter framework acts on it
|
||||
|
||||
val search = ShortcutInfoCompat.Builder(this, "search")
|
||||
.setShortLabel(getString(R.string.search_shortcut_short_label))
|
||||
.setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_search))
|
||||
.setIntent(
|
||||
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
||||
.putExtra("page", "/search")
|
||||
)
|
||||
.build()
|
||||
|
||||
val videos = ShortcutInfoCompat.Builder(this, "videos")
|
||||
.setShortLabel(getString(R.string.videos_shortcut_short_label))
|
||||
.setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_movie))
|
||||
.setIntent(
|
||||
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
||||
.putExtra("page", "/collection")
|
||||
.putExtra("filters", arrayOf("{\"type\":\"mime\",\"mime\":\"video/*\"}"))
|
||||
)
|
||||
.build()
|
||||
|
||||
ShortcutManagerCompat.setDynamicShortcuts(this, listOf(videos, search))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag(MainActivity::class.java)
|
||||
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer"
|
||||
}
|
||||
}
|
|
@ -12,6 +12,8 @@ import androidx.core.content.FileProvider
|
|||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.DecodeFormat
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
|
@ -28,8 +30,8 @@ import kotlin.math.roundToInt
|
|||
class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getAppIcon" -> GlobalScope.launch(Dispatchers.IO) { getAppIcon(call, Coresult(result)) }
|
||||
"getAppNames" -> GlobalScope.launch(Dispatchers.IO) { getAppNames(Coresult(result)) }
|
||||
"getPackages" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPackages) }
|
||||
"getAppIcon" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getAppIcon) }
|
||||
"edit" -> {
|
||||
val title = call.argument<String>("title")
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
|
@ -61,46 +63,51 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
private fun getAppNames(result: MethodChannel.Result) {
|
||||
val nameMap = HashMap<String, String>()
|
||||
val intent = Intent(Intent.ACTION_MAIN, null)
|
||||
.addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
|
||||
private fun getPackages(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
||||
val packages = HashMap<String, FieldMap>()
|
||||
|
||||
// apps tend to use their name in English when creating folders
|
||||
// so we get their names in English as well as the current locale
|
||||
val englishConfig = Configuration().apply { setLocale(Locale.ENGLISH) }
|
||||
fun addPackageDetails(intent: Intent) {
|
||||
// apps tend to use their name in English when creating folders
|
||||
// so we get their names in English as well as the current locale
|
||||
val englishConfig = Configuration().apply { setLocale(Locale.ENGLISH) }
|
||||
|
||||
val pm = context.packageManager
|
||||
for (resolveInfo in pm.queryIntentActivities(intent, 0)) {
|
||||
val ai = resolveInfo.activityInfo.applicationInfo
|
||||
val isSystemPackage = ai.flags and ApplicationInfo.FLAG_SYSTEM != 0
|
||||
if (!isSystemPackage) {
|
||||
val packageName = ai.packageName
|
||||
|
||||
val currentLabel = pm.getApplicationLabel(ai).toString()
|
||||
nameMap[currentLabel] = packageName
|
||||
|
||||
val labelRes = ai.labelRes
|
||||
if (labelRes != 0) {
|
||||
try {
|
||||
val resources = pm.getResourcesForApplication(ai)
|
||||
// `updateConfiguration` is deprecated but it seems to be the only way
|
||||
// to query resources from another app with a specific locale.
|
||||
// The following methods do not work:
|
||||
// - `resources.getConfiguration().setLocale(...)`
|
||||
// - getting a package manager from a custom context with `context.createConfigurationContext(config)`
|
||||
@Suppress("DEPRECATION")
|
||||
resources.updateConfiguration(englishConfig, resources.displayMetrics)
|
||||
val englishLabel = resources.getString(labelRes)
|
||||
nameMap[englishLabel] = packageName
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
Log.w(LOG_TAG, "failed to get app label in English for packageName=$packageName", e)
|
||||
val pm = context.packageManager
|
||||
for (resolveInfo in pm.queryIntentActivities(intent, 0)) {
|
||||
val appInfo = resolveInfo.activityInfo.applicationInfo
|
||||
val packageName = appInfo.packageName
|
||||
if (!packages.containsKey(packageName)) {
|
||||
val currentLabel = pm.getApplicationLabel(appInfo).toString()
|
||||
val englishLabel: String? = appInfo.labelRes.takeIf { it != 0 }?.let { labelRes ->
|
||||
var englishLabel: String? = null
|
||||
try {
|
||||
val resources = pm.getResourcesForApplication(appInfo)
|
||||
// `updateConfiguration` is deprecated but it seems to be the only way
|
||||
// to query resources from another app with a specific locale.
|
||||
// The following methods do not work:
|
||||
// - `resources.getConfiguration().setLocale(...)`
|
||||
// - getting a package manager from a custom context with `context.createConfigurationContext(config)`
|
||||
@Suppress("DEPRECATION")
|
||||
resources.updateConfiguration(englishConfig, resources.displayMetrics)
|
||||
englishLabel = resources.getString(labelRes)
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
Log.w(LOG_TAG, "failed to get app label in English for packageName=$packageName", e)
|
||||
}
|
||||
englishLabel
|
||||
}
|
||||
packages[packageName] = hashMapOf(
|
||||
"packageName" to packageName,
|
||||
"categoryLauncher" to intent.hasCategory(Intent.CATEGORY_LAUNCHER),
|
||||
"isSystem" to (appInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0),
|
||||
"currentLabel" to currentLabel,
|
||||
"englishLabel" to englishLabel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
result.success(nameMap)
|
||||
|
||||
addPackageDetails(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER))
|
||||
addPackageDetails(Intent(Intent.ACTION_MAIN))
|
||||
result.success(ArrayList(packages.values))
|
||||
}
|
||||
|
||||
private fun getAppIcon(call: MethodCall, result: MethodChannel.Result) {
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.reflect.KSuspendFunction2
|
||||
|
||||
// ensure `result` methods are called on the main looper thread
|
||||
class Coresult internal constructor(private val methodResult: MethodChannel.Result) : MethodChannel.Result {
|
||||
|
@ -20,4 +22,24 @@ class Coresult internal constructor(private val methodResult: MethodChannel.Resu
|
|||
override fun notImplemented() {
|
||||
mainScope.launch { methodResult.notImplemented() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun safe(call: MethodCall, result: MethodChannel.Result, function: (call: MethodCall, result: MethodChannel.Result) -> Unit) {
|
||||
val res = Coresult(result)
|
||||
try {
|
||||
function(call, res)
|
||||
} catch (e: Exception) {
|
||||
res.error("safe-exception", e.message, e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun safesus(call: MethodCall, result: MethodChannel.Result, function: KSuspendFunction2<MethodCall, MethodChannel.Result, Unit>) {
|
||||
val res = Coresult(result)
|
||||
try {
|
||||
function(call, res)
|
||||
} catch (e: Exception) {
|
||||
res.error("safe-exception", e.message, e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,10 +12,11 @@ import android.util.Log
|
|||
import androidx.exifinterface.media.ExifInterface
|
||||
import com.drew.imaging.ImageMetadataReader
|
||||
import com.drew.metadata.file.FileTypeDirectory
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
||||
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
|
||||
import deckers.thibault.aves.metadata.Metadata
|
||||
import deckers.thibault.aves.model.provider.FieldMap
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
|
||||
|
@ -37,12 +38,12 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
when (call.method) {
|
||||
"getContextDirs" -> result.success(getContextDirs())
|
||||
"getEnv" -> result.success(System.getenv())
|
||||
"getBitmapFactoryInfo" -> GlobalScope.launch(Dispatchers.IO) { getBitmapFactoryInfo(call, Coresult(result)) }
|
||||
"getContentResolverMetadata" -> GlobalScope.launch(Dispatchers.IO) { getContentResolverMetadata(call, Coresult(result)) }
|
||||
"getExifInterfaceMetadata" -> GlobalScope.launch(Dispatchers.IO) { getExifInterfaceMetadata(call, Coresult(result)) }
|
||||
"getMediaMetadataRetrieverMetadata" -> GlobalScope.launch(Dispatchers.IO) { getMediaMetadataRetrieverMetadata(call, Coresult(result)) }
|
||||
"getMetadataExtractorSummary" -> GlobalScope.launch(Dispatchers.IO) { getMetadataExtractorSummary(call, Coresult(result)) }
|
||||
"getTiffStructure" -> GlobalScope.launch(Dispatchers.IO) { getTiffStructure(call, Coresult(result)) }
|
||||
"getBitmapFactoryInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getBitmapFactoryInfo) }
|
||||
"getContentResolverMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverMetadata) }
|
||||
"getExifInterfaceMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getExifInterfaceMetadata) }
|
||||
"getMediaMetadataRetrieverMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMediaMetadataRetrieverMetadata) }
|
||||
"getMetadataExtractorSummary" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMetadataExtractorSummary) }
|
||||
"getTiffStructure" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getTiffStructure) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,8 +5,13 @@ import android.graphics.Rect
|
|||
import android.net.Uri
|
||||
import android.util.Size
|
||||
import com.bumptech.glide.Glide
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safesus
|
||||
import deckers.thibault.aves.channel.calls.fetchers.RegionFetcher
|
||||
import deckers.thibault.aves.channel.calls.fetchers.ThumbnailFetcher
|
||||
import deckers.thibault.aves.channel.calls.fetchers.TiffRegionFetcher
|
||||
import deckers.thibault.aves.model.ExifOrientationOp
|
||||
import deckers.thibault.aves.model.provider.FieldMap
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
|
||||
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
|
||||
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
|
||||
|
@ -26,17 +31,14 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
|||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getObsoleteEntries" -> GlobalScope.launch(Dispatchers.IO) { getObsoleteEntries(call, Coresult(result)) }
|
||||
"getImageEntry" -> GlobalScope.launch(Dispatchers.IO) { getImageEntry(call, Coresult(result)) }
|
||||
"getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { getThumbnail(call, Coresult(result)) }
|
||||
"getRegion" -> GlobalScope.launch(Dispatchers.IO) { getRegion(call, Coresult(result)) }
|
||||
"clearSizedThumbnailDiskCache" -> {
|
||||
GlobalScope.launch(Dispatchers.IO) { Glide.get(activity).clearDiskCache() }
|
||||
result.success(null)
|
||||
}
|
||||
"rename" -> GlobalScope.launch(Dispatchers.IO) { rename(call, Coresult(result)) }
|
||||
"rotate" -> GlobalScope.launch(Dispatchers.IO) { rotate(call, Coresult(result)) }
|
||||
"flip" -> GlobalScope.launch(Dispatchers.IO) { flip(call, Coresult(result)) }
|
||||
"getObsoleteEntries" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getObsoleteEntries) }
|
||||
"getEntry" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getEntry) }
|
||||
"getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getThumbnail) }
|
||||
"getRegion" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getRegion) }
|
||||
"clearSizedThumbnailDiskCache" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::clearSizedThumbnailDiskCache) }
|
||||
"rename" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::rename) }
|
||||
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
|
||||
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
@ -58,7 +60,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
|||
val isFlipped = call.argument<Boolean>("isFlipped")
|
||||
val widthDip = call.argument<Double>("widthDip")
|
||||
val heightDip = call.argument<Double>("heightDip")
|
||||
val page = call.argument<Int>("page")
|
||||
val pageId = call.argument<Int>("pageId")
|
||||
val defaultSizeDip = call.argument<Double>("defaultSizeDip")
|
||||
|
||||
if (uri == null || mimeType == null || dateModifiedSecs == null || rotationDegrees == null || isFlipped == null || widthDip == null || heightDip == null || defaultSizeDip == null) {
|
||||
|
@ -76,7 +78,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
|||
isFlipped,
|
||||
width = (widthDip * density).roundToInt(),
|
||||
height = (heightDip * density).roundToInt(),
|
||||
page = page,
|
||||
pageId = pageId,
|
||||
defaultSize = (defaultSizeDip * density).roundToInt(),
|
||||
result,
|
||||
).fetch()
|
||||
|
@ -85,7 +87,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
|||
private fun getRegion(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val page = call.argument<Int>("page")
|
||||
val pageId = call.argument<Int>("pageId")
|
||||
val sampleSize = call.argument<Int>("sampleSize")
|
||||
val x = call.argument<Int>("regionX")
|
||||
val y = call.argument<Int>("regionY")
|
||||
|
@ -102,43 +104,49 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
|||
val regionRect = Rect(x, y, x + width, y + height)
|
||||
when (mimeType) {
|
||||
MimeTypes.TIFF -> TiffRegionFetcher(activity).fetch(
|
||||
uri,
|
||||
sampleSize,
|
||||
regionRect,
|
||||
page = page ?: 0,
|
||||
result,
|
||||
uri = uri,
|
||||
page = pageId ?: 0,
|
||||
sampleSize = sampleSize,
|
||||
regionRect = regionRect,
|
||||
result = result,
|
||||
)
|
||||
else -> regionFetcher.fetch(
|
||||
uri,
|
||||
mimeType,
|
||||
sampleSize,
|
||||
regionRect,
|
||||
Size(imageWidth, imageHeight),
|
||||
result,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
pageId = pageId,
|
||||
sampleSize = sampleSize,
|
||||
regionRect = regionRect,
|
||||
imageSize = Size(imageWidth, imageHeight),
|
||||
result = result,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getImageEntry(call: MethodCall, result: MethodChannel.Result) {
|
||||
private suspend fun getEntry(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType") // MIME type is optional
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (uri == null) {
|
||||
result.error("getImageEntry-args", "failed because of missing arguments", null)
|
||||
result.error("getEntry-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val provider = getProvider(uri)
|
||||
if (provider == null) {
|
||||
result.error("getImageEntry-provider", "failed to find provider for uri=$uri", null)
|
||||
result.error("getEntry-provider", "failed to find provider for uri=$uri", null)
|
||||
return
|
||||
}
|
||||
|
||||
provider.fetchSingle(activity, uri, mimeType, object : ImageOpCallback {
|
||||
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
||||
override fun onFailure(throwable: Throwable) = result.error("getImageEntry-failure", "failed to get entry for uri=$uri", throwable.message)
|
||||
override fun onFailure(throwable: Throwable) = result.error("getEntry-failure", "failed to get entry for uri=$uri", throwable.message)
|
||||
})
|
||||
}
|
||||
|
||||
private fun clearSizedThumbnailDiskCache(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
||||
Glide.get(activity).clearDiskCache()
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private suspend fun rename(call: MethodCall, result: MethodChannel.Result) {
|
||||
val entryMap = call.argument<FieldMap>("entry")
|
||||
val newName = call.argument<String>("newName")
|
||||
|
|
|
@ -1,8 +1,15 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import com.adobe.internal.xmp.XMPException
|
||||
|
@ -18,6 +25,7 @@ import com.drew.metadata.iptc.IptcDirectory
|
|||
import com.drew.metadata.mp4.media.Mp4UuidBoxDirectory
|
||||
import com.drew.metadata.webp.WebpDirectory
|
||||
import com.drew.metadata.xmp.XmpDirectory
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.metadata.*
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
|
||||
|
@ -38,13 +46,14 @@ import deckers.thibault.aves.metadata.MetadataExtractorHelper.isGeoTiff
|
|||
import deckers.thibault.aves.metadata.XMP.getSafeDateMillis
|
||||
import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText
|
||||
import deckers.thibault.aves.metadata.XMP.isPanorama
|
||||
import deckers.thibault.aves.model.provider.FieldMap
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.model.provider.FileImageProvider
|
||||
import deckers.thibault.aves.model.provider.ImageProvider
|
||||
import deckers.thibault.aves.utils.BitmapUtils
|
||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.MimeTypes.isHeifLike
|
||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
||||
import deckers.thibault.aves.utils.MimeTypes.isMultimedia
|
||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
|
||||
|
@ -66,14 +75,15 @@ import kotlin.math.roundToLong
|
|||
class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getAllMetadata" -> GlobalScope.launch(Dispatchers.IO) { getAllMetadata(call, Coresult(result)) }
|
||||
"getCatalogMetadata" -> GlobalScope.launch(Dispatchers.IO) { getCatalogMetadata(call, Coresult(result)) }
|
||||
"getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { getOverlayMetadata(call, Coresult(result)) }
|
||||
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { getMultiPageInfo(call, Coresult(result)) }
|
||||
"getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { getPanoramaInfo(call, Coresult(result)) }
|
||||
"getEmbeddedPictures" -> GlobalScope.launch(Dispatchers.IO) { getEmbeddedPictures(call, Coresult(result)) }
|
||||
"getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { getExifThumbnails(call, Coresult(result)) }
|
||||
"extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { extractXmpDataProp(call, Coresult(result)) }
|
||||
"getAllMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getAllMetadata) }
|
||||
"getCatalogMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getCatalogMetadata) }
|
||||
"getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getOverlayMetadata) }
|
||||
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) }
|
||||
"getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPanoramaInfo) }
|
||||
"getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) }
|
||||
"getEmbeddedPictures" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getEmbeddedPictures) }
|
||||
"getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getExifThumbnails) }
|
||||
"extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractXmpDataProp) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
@ -430,7 +440,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
if (mimeType == MimeTypes.HEIC || mimeType == MimeTypes.HEIF) {
|
||||
if (isHeifLike(mimeType)) {
|
||||
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS) {
|
||||
if (it > 1) flags = flags or MASK_IS_MULTIPAGE
|
||||
}
|
||||
|
@ -521,21 +531,57 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
return
|
||||
}
|
||||
|
||||
val pages = HashMap<Int, Any>()
|
||||
val pages = ArrayList<Map<String, Any>>()
|
||||
if (mimeType == MimeTypes.TIFF) {
|
||||
fun toMap(options: TiffBitmapFactory.Options): Map<String, Any> {
|
||||
fun toMap(page: Int, options: TiffBitmapFactory.Options): HashMap<String, Any> {
|
||||
return hashMapOf(
|
||||
"width" to options.outWidth,
|
||||
"height" to options.outHeight,
|
||||
KEY_PAGE to page,
|
||||
KEY_MIME_TYPE to mimeType,
|
||||
KEY_WIDTH to options.outWidth,
|
||||
KEY_HEIGHT to options.outHeight,
|
||||
)
|
||||
}
|
||||
getTiffPageInfo(uri, 0)?.let { first ->
|
||||
pages[0] = toMap(first)
|
||||
pages.add(toMap(0, first))
|
||||
val pageCount = first.outDirectoryCount
|
||||
for (i in 1 until pageCount) {
|
||||
getTiffPageInfo(uri, i)?.let { pages[i] = toMap(it) }
|
||||
getTiffPageInfo(uri, i)?.let { pages.add(toMap(i, it)) }
|
||||
}
|
||||
}
|
||||
} else if (isHeifLike(mimeType)) {
|
||||
fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) {
|
||||
if (this.containsKey(key)) save(this.getInteger(key))
|
||||
}
|
||||
|
||||
fun MediaFormat.getSafeLong(key: String, save: (value: Long) -> Unit) {
|
||||
if (this.containsKey(key)) save(this.getLong(key))
|
||||
}
|
||||
|
||||
val extractor = MediaExtractor()
|
||||
extractor.setDataSource(context, uri, null)
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
try {
|
||||
val format = extractor.getTrackFormat(i)
|
||||
format.getString(MediaFormat.KEY_MIME)?.let { mime ->
|
||||
val trackMime = if (mime == MediaFormat.MIMETYPE_IMAGE_ANDROID_HEIC) MimeTypes.HEIC else mime
|
||||
val page = hashMapOf<String, Any>(
|
||||
KEY_PAGE to i,
|
||||
KEY_MIME_TYPE to trackMime,
|
||||
)
|
||||
format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { page[KEY_IS_DEFAULT] = it != 0 }
|
||||
format.getSafeInt(MediaFormat.KEY_TRACK_ID) { page[KEY_TRACK_ID] = it }
|
||||
format.getSafeInt(MediaFormat.KEY_WIDTH) { page[KEY_WIDTH] = it }
|
||||
format.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it }
|
||||
if (isVideo(trackMime)) {
|
||||
format.getSafeLong(MediaFormat.KEY_DURATION) { page[KEY_DURATION] = it / 1000 }
|
||||
}
|
||||
pages.add(page)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get track information for uri=$uri, track num=$i", e)
|
||||
}
|
||||
}
|
||||
extractor.release()
|
||||
}
|
||||
result.success(pages)
|
||||
}
|
||||
|
@ -555,14 +601,16 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java)
|
||||
try {
|
||||
fun getProp(propName: String): Int? = xmpDirs.map { it.xmpMeta.getPropertyInteger(XMP.GPANO_SCHEMA_NS, propName) }.firstOrNull { it != null }
|
||||
fun getIntProp(propName: String): Int? = xmpDirs.map { it.xmpMeta.getPropertyInteger(XMP.GPANO_SCHEMA_NS, propName) }.firstOrNull { it != null }
|
||||
fun getStringProp(propName: String): String? = xmpDirs.map { it.xmpMeta.getPropertyString(XMP.GPANO_SCHEMA_NS, propName) }.firstOrNull { it != null }
|
||||
val fields: FieldMap = hashMapOf(
|
||||
"croppedAreaLeft" to getProp(XMP.GPANO_CROPPED_AREA_LEFT_PROP_NAME),
|
||||
"croppedAreaTop" to getProp(XMP.GPANO_CROPPED_AREA_TOP_PROP_NAME),
|
||||
"croppedAreaWidth" to getProp(XMP.GPANO_CROPPED_AREA_WIDTH_PROP_NAME),
|
||||
"croppedAreaHeight" to getProp(XMP.GPANO_CROPPED_AREA_HEIGHT_PROP_NAME),
|
||||
"fullPanoWidth" to getProp(XMP.GPANO_FULL_PANO_WIDTH_PROP_NAME),
|
||||
"fullPanoHeight" to getProp(XMP.GPANO_FULL_PANO_HEIGHT_PROP_NAME),
|
||||
"croppedAreaLeft" to getIntProp(XMP.GPANO_CROPPED_AREA_LEFT_PROP_NAME),
|
||||
"croppedAreaTop" to getIntProp(XMP.GPANO_CROPPED_AREA_TOP_PROP_NAME),
|
||||
"croppedAreaWidth" to getIntProp(XMP.GPANO_CROPPED_AREA_WIDTH_PROP_NAME),
|
||||
"croppedAreaHeight" to getIntProp(XMP.GPANO_CROPPED_AREA_HEIGHT_PROP_NAME),
|
||||
"fullPanoWidth" to getIntProp(XMP.GPANO_FULL_PANO_WIDTH_PROP_NAME),
|
||||
"fullPanoHeight" to getIntProp(XMP.GPANO_FULL_PANO_HEIGHT_PROP_NAME),
|
||||
"projectionType" to (getStringProp(XMP.GPANO_PROJECTION_TYPE_PROP_NAME) ?: XMP.GPANO_PROJECTION_TYPE_DEFAULT),
|
||||
)
|
||||
result.success(fields)
|
||||
return
|
||||
|
@ -580,6 +628,55 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
result.error("getPanoramaInfo-empty", "failed to read XMP from uri=$uri", null)
|
||||
}
|
||||
|
||||
private fun getContentResolverProp(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val prop = call.argument<String>("prop")
|
||||
if (mimeType == null || uri == null || prop == null) {
|
||||
result.error("getContentResolverProp-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
var contentUri: Uri = uri
|
||||
if (uri.scheme == ContentResolver.SCHEME_CONTENT && MediaStore.AUTHORITY.equals(uri.host, ignoreCase = true)) {
|
||||
try {
|
||||
val id = ContentUris.parseId(uri)
|
||||
contentUri = when {
|
||||
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
|
||||
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
|
||||
else -> uri
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
contentUri = MediaStore.setRequireOriginal(contentUri)
|
||||
}
|
||||
} catch (e: NumberFormatException) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
val projection = arrayOf(prop)
|
||||
val cursor = context.contentResolver.query(contentUri, projection, null, null, null)
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
var value: Any? = null
|
||||
try {
|
||||
value = when (cursor.getType(0)) {
|
||||
Cursor.FIELD_TYPE_NULL -> null
|
||||
Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(0)
|
||||
Cursor.FIELD_TYPE_FLOAT -> cursor.getFloat(0)
|
||||
Cursor.FIELD_TYPE_STRING -> cursor.getString(0)
|
||||
Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(0)
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get value for key=$prop", e)
|
||||
}
|
||||
cursor.close()
|
||||
result.success(value?.toString())
|
||||
} else {
|
||||
result.error("getContentResolverProp-null", "failed to get cursor for contentUri=$contentUri", null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (uri == null) {
|
||||
|
@ -619,7 +716,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
|
||||
exif.thumbnailBitmap?.let { bitmap ->
|
||||
TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let {
|
||||
it.getBytes(canHaveAlpha = false, recycle = false)?.let { thumbnails.add(it) }
|
||||
it.getBytes(canHaveAlpha = false, recycle = false)?.let { bytes -> thumbnails.add(bytes) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -733,7 +830,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
private val LOG_TAG = LogUtils.createTag(MetadataHandler::class.java)
|
||||
const val CHANNEL = "deckers.thibault/aves/metadata"
|
||||
|
||||
// catalog metadata
|
||||
// catalog metadata & page info
|
||||
private const val KEY_MIME_TYPE = "mimeType"
|
||||
private const val KEY_DATE_MILLIS = "dateMillis"
|
||||
private const val KEY_FLAGS = "flags"
|
||||
|
@ -742,6 +839,12 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
private const val KEY_LONGITUDE = "longitude"
|
||||
private const val KEY_XMP_SUBJECTS = "xmpSubjects"
|
||||
private const val KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription"
|
||||
private const val KEY_HEIGHT = "height"
|
||||
private const val KEY_WIDTH = "width"
|
||||
private const val KEY_PAGE = "page"
|
||||
private const val KEY_TRACK_ID = "trackId"
|
||||
private const val KEY_IS_DEFAULT = "isDefault"
|
||||
private const val KEY_DURATION = "durationMillis"
|
||||
|
||||
private const val MASK_IS_ANIMATED = 1 shl 0
|
||||
private const val MASK_IS_FLIPPED = 1 shl 1
|
||||
|
|
|
@ -5,7 +5,7 @@ import android.media.MediaScannerConnection
|
|||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.storage.StorageManager
|
||||
import androidx.annotation.RequiresApi
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.utils.PermissionManager
|
||||
import deckers.thibault.aves.utils.StorageUtils.getVolumePaths
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
|
@ -20,27 +20,18 @@ import java.util.*
|
|||
class StorageHandler(private val context: Context) : MethodCallHandler {
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getStorageVolumes" -> {
|
||||
val volumes: List<Map<String, Any>> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
storageVolumes
|
||||
} else {
|
||||
// TODO TLAD find alternative for Android <N
|
||||
emptyList()
|
||||
}
|
||||
result.success(volumes)
|
||||
}
|
||||
"getFreeSpace" -> getFreeSpace(call, result)
|
||||
"getGrantedDirectories" -> result.success(ArrayList(PermissionManager.getGrantedDirs(context)))
|
||||
"getInaccessibleDirectories" -> getInaccessibleDirectories(call, result)
|
||||
"revokeDirectoryAccess" -> revokeDirectoryAccess(call, result)
|
||||
"scanFile" -> GlobalScope.launch(Dispatchers.IO) { scanFile(call, Coresult(result)) }
|
||||
"getStorageVolumes" -> safe(call, result, ::getStorageVolumes)
|
||||
"getFreeSpace" -> safe(call, result, ::getFreeSpace)
|
||||
"getGrantedDirectories" -> safe(call, result, ::getGrantedDirectories)
|
||||
"getInaccessibleDirectories" -> safe(call, result, ::getInaccessibleDirectories)
|
||||
"revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess)
|
||||
"scanFile" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::scanFile) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private val storageVolumes: List<Map<String, Any>>
|
||||
@RequiresApi(api = Build.VERSION_CODES.N)
|
||||
get() {
|
||||
private fun getStorageVolumes(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
||||
val volumes: List<Map<String, Any>> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
val volumes = ArrayList<Map<String, Any>>()
|
||||
val sm = context.getSystemService(StorageManager::class.java)
|
||||
if (sm != null) {
|
||||
|
@ -61,8 +52,13 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
}
|
||||
return volumes
|
||||
volumes
|
||||
} else {
|
||||
// TODO TLAD find alternative for Android <N
|
||||
emptyList()
|
||||
}
|
||||
result.success(volumes)
|
||||
}
|
||||
|
||||
private fun getFreeSpace(call: MethodCall, result: MethodChannel.Result) {
|
||||
val path = call.argument<String>("path")
|
||||
|
@ -93,6 +89,10 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
private fun getGrantedDirectories(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(ArrayList(PermissionManager.getGrantedDirs(context)))
|
||||
}
|
||||
|
||||
private fun getInaccessibleDirectories(call: MethodCall, result: MethodChannel.Result) {
|
||||
val dirPaths = call.argument<List<String>>("dirPaths")
|
||||
if (dirPaths == null) {
|
||||
|
|
|
@ -1,15 +1,22 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
package deckers.thibault.aves.channel.calls.fetchers
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.BitmapRegionDecoder
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import android.util.Size
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.DecodeFormat
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import deckers.thibault.aves.decoder.MultiTrackImage
|
||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.File
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class RegionFetcher internal constructor(
|
||||
|
@ -17,21 +24,42 @@ class RegionFetcher internal constructor(
|
|||
) {
|
||||
private var lastDecoderRef: LastDecoderRef? = null
|
||||
|
||||
private val pageTempUris = HashMap<Pair<Uri, Int>, Uri>()
|
||||
|
||||
private val multiTrackGlideOptions = RequestOptions()
|
||||
.format(DecodeFormat.PREFER_ARGB_8888)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.skipMemoryCache(true)
|
||||
|
||||
fun fetch(
|
||||
uri: Uri,
|
||||
mimeType: String,
|
||||
pageId: Int?,
|
||||
sampleSize: Int,
|
||||
regionRect: Rect,
|
||||
imageSize: Size,
|
||||
result: MethodChannel.Result,
|
||||
) {
|
||||
if (MimeTypes.isHeifLike(mimeType) && pageId != null) {
|
||||
val id = Pair(uri, pageId)
|
||||
fetch(
|
||||
uri = pageTempUris.getOrPut(id) { createJpegForPage(uri, pageId) },
|
||||
mimeType = MimeTypes.JPEG,
|
||||
pageId = null,
|
||||
sampleSize = sampleSize,
|
||||
regionRect = regionRect,
|
||||
imageSize = imageSize,
|
||||
result = result,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inSampleSize = sampleSize
|
||||
}
|
||||
|
||||
var currentDecoderRef = lastDecoderRef
|
||||
if (currentDecoderRef != null && currentDecoderRef.uri != uri) {
|
||||
currentDecoderRef.decoder.recycle()
|
||||
currentDecoderRef = null
|
||||
}
|
||||
|
||||
|
@ -74,6 +102,26 @@ class RegionFetcher internal constructor(
|
|||
result.error("getRegion-read-exception", "failed to initialize region decoder for uri=$uri regionRect=$regionRect", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createJpegForPage(sourceUri: Uri, pageId: Int): Uri {
|
||||
val target = Glide.with(context)
|
||||
.asBitmap()
|
||||
.apply(multiTrackGlideOptions)
|
||||
.load(MultiTrackImage(context, sourceUri, pageId))
|
||||
.submit()
|
||||
try {
|
||||
val bitmap = target.get()
|
||||
val tempFile = File.createTempFile("aves", null, context.cacheDir).apply {
|
||||
deleteOnExit()
|
||||
outputStream().use { outputStream ->
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
|
||||
}
|
||||
}
|
||||
return Uri.fromFile(tempFile)
|
||||
} finally {
|
||||
Glide.with(context).clear(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class LastDecoderRef(
|
|
@ -1,4 +1,4 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
package deckers.thibault.aves.channel.calls.fetchers
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
|
@ -13,11 +13,13 @@ import com.bumptech.glide.load.DecodeFormat
|
|||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import com.bumptech.glide.signature.ObjectKey
|
||||
import deckers.thibault.aves.decoder.TiffThumbnail
|
||||
import deckers.thibault.aves.decoder.MultiTrackImage
|
||||
import deckers.thibault.aves.decoder.TiffImage
|
||||
import deckers.thibault.aves.decoder.VideoThumbnail
|
||||
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.MimeTypes.isHeifLike
|
||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail
|
||||
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
|
||||
|
@ -32,14 +34,16 @@ class ThumbnailFetcher internal constructor(
|
|||
private val isFlipped: Boolean,
|
||||
width: Int?,
|
||||
height: Int?,
|
||||
page: Int?,
|
||||
private val pageId: Int?,
|
||||
private val defaultSize: Int,
|
||||
private val result: MethodChannel.Result,
|
||||
) {
|
||||
private val uri: Uri = Uri.parse(uri)
|
||||
private val width: Int = if (width?.takeIf { it > 0 } != null) width else defaultSize
|
||||
private val height: Int = if (height?.takeIf { it > 0 } != null) height else defaultSize
|
||||
private val page = page ?: 0
|
||||
private val tiffFetch = mimeType == MimeTypes.TIFF
|
||||
private val multiTrackFetch = isHeifLike(mimeType) && pageId != null
|
||||
private val customFetch = tiffFetch || multiTrackFetch
|
||||
|
||||
fun fetch() {
|
||||
var bitmap: Bitmap? = null
|
||||
|
@ -47,7 +51,7 @@ class ThumbnailFetcher internal constructor(
|
|||
var exception: Exception? = null
|
||||
|
||||
try {
|
||||
if (mimeType != MimeTypes.TIFF && (width == defaultSize || height == defaultSize) && !isFlipped) {
|
||||
if (!customFetch && (width == defaultSize || height == defaultSize) && !isFlipped) {
|
||||
// Fetch low quality thumbnails when size is not specified.
|
||||
// As of Android R, the Media Store content resolver may return a thumbnail
|
||||
// that is automatically rotated according to EXIF orientation, but not flipped,
|
||||
|
@ -110,7 +114,7 @@ class ThumbnailFetcher internal constructor(
|
|||
// add signature to ignore cache for images which got modified but kept the same URI
|
||||
var options = RequestOptions()
|
||||
.format(DecodeFormat.PREFER_RGB_565)
|
||||
.signature(ObjectKey("$dateModifiedSecs-$rotationDegrees-$isFlipped-$width-$page"))
|
||||
.signature(ObjectKey("$dateModifiedSecs-$rotationDegrees-$isFlipped-$width-$pageId"))
|
||||
.override(width, height)
|
||||
|
||||
val target = if (isVideo(mimeType)) {
|
||||
|
@ -121,7 +125,11 @@ class ThumbnailFetcher internal constructor(
|
|||
.load(VideoThumbnail(context, uri))
|
||||
.submit(width, height)
|
||||
} else {
|
||||
val model: Any = if (mimeType == MimeTypes.TIFF) TiffThumbnail(context, uri, page) else uri
|
||||
val model: Any = when {
|
||||
tiffFetch -> TiffImage(context, uri, pageId)
|
||||
multiTrackFetch -> MultiTrackImage(context, uri, pageId)
|
||||
else -> uri
|
||||
}
|
||||
Glide.with(context)
|
||||
.asBitmap()
|
||||
.apply(options)
|
|
@ -1,4 +1,4 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
package deckers.thibault.aves.channel.calls.fetchers
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
|
@ -13,9 +13,9 @@ class TiffRegionFetcher internal constructor(
|
|||
) {
|
||||
fun fetch(
|
||||
uri: Uri,
|
||||
page: Int,
|
||||
sampleSize: Int,
|
||||
regionRect: Rect,
|
||||
page: Int = 0,
|
||||
result: MethodChannel.Result,
|
||||
) {
|
||||
try {
|
|
@ -0,0 +1,61 @@
|
|||
package deckers.thibault.aves.channel.streams
|
||||
|
||||
import android.content.Context
|
||||
import android.database.ContentObserver
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.EventChannel.EventSink
|
||||
|
||||
class ContentChangeStreamHandler(private val context: Context) : EventChannel.StreamHandler {
|
||||
private val contentObserver = object : ContentObserver(null) {
|
||||
override fun onChange(selfChange: Boolean) {
|
||||
this.onChange(selfChange, null)
|
||||
}
|
||||
|
||||
override fun onChange(selfChange: Boolean, uri: Uri?) {
|
||||
// warning: querying the content resolver right after a change
|
||||
// sometimes yields obsolete results
|
||||
success(uri?.toString())
|
||||
}
|
||||
}
|
||||
private lateinit var eventSink: EventSink
|
||||
private lateinit var handler: Handler
|
||||
|
||||
init {
|
||||
context.contentResolver.apply {
|
||||
registerContentObserver(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, contentObserver)
|
||||
registerContentObserver(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true, contentObserver)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onListen(arguments: Any?, eventSink: EventSink) {
|
||||
this.eventSink = eventSink
|
||||
handler = Handler(Looper.getMainLooper())
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {}
|
||||
|
||||
fun dispose() {
|
||||
context.contentResolver.unregisterContentObserver(contentObserver)
|
||||
}
|
||||
|
||||
private fun success(uri: String?) {
|
||||
handler.post {
|
||||
try {
|
||||
eventSink.success(uri)
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to use event sink", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag(ContentChangeStreamHandler::class.java)
|
||||
const val CHANNEL = "deckers.thibault/aves/contentchange"
|
||||
}
|
||||
}
|
|
@ -9,11 +9,14 @@ import com.bumptech.glide.Glide
|
|||
import com.bumptech.glide.load.DecodeFormat
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import deckers.thibault.aves.decoder.MultiTrackImage
|
||||
import deckers.thibault.aves.decoder.TiffImage
|
||||
import deckers.thibault.aves.decoder.VideoThumbnail
|
||||
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.MimeTypes.isHeifLike
|
||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByFlutter
|
||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
|
||||
|
@ -23,7 +26,6 @@ import io.flutter.plugin.common.EventChannel.EventSink
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
|
||||
|
@ -84,7 +86,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
val uri = (arguments["uri"] as String?)?.let { Uri.parse(it) }
|
||||
val rotationDegrees = arguments["rotationDegrees"] as Int
|
||||
val isFlipped = arguments["isFlipped"] as Boolean
|
||||
val page = arguments["page"] as Int
|
||||
val pageId = arguments["pageId"] as Int?
|
||||
|
||||
if (mimeType == null || uri == null) {
|
||||
error("streamImage-args", "failed because of missing arguments", null)
|
||||
|
@ -94,11 +96,9 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
|
||||
if (isVideo(mimeType)) {
|
||||
streamVideoByGlide(uri)
|
||||
} else if (mimeType == MimeTypes.TIFF) {
|
||||
streamTiffImage(uri, page)
|
||||
} else if (!isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) {
|
||||
// decode exotic format on platform side, then encode it in portable format for Flutter
|
||||
streamImageByGlide(uri, mimeType, rotationDegrees, isFlipped)
|
||||
streamImageByGlide(uri, pageId, mimeType, rotationDegrees, isFlipped)
|
||||
} else {
|
||||
// to be decoded by Flutter
|
||||
streamImageAsIs(uri)
|
||||
|
@ -114,11 +114,19 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
}
|
||||
}
|
||||
|
||||
private fun streamImageByGlide(uri: Uri, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) {
|
||||
private fun streamImageByGlide(uri: Uri, pageId: Int?, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) {
|
||||
val model: Any = if (isHeifLike(mimeType) && pageId != null) {
|
||||
MultiTrackImage(activity, uri, pageId)
|
||||
} else if (mimeType == MimeTypes.TIFF) {
|
||||
TiffImage(activity, uri, pageId)
|
||||
} else {
|
||||
uri
|
||||
}
|
||||
|
||||
val target = Glide.with(activity)
|
||||
.asBitmap()
|
||||
.apply(glideOptions)
|
||||
.load(uri)
|
||||
.load(model)
|
||||
.submit()
|
||||
try {
|
||||
var bitmap = target.get()
|
||||
|
@ -157,28 +165,6 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
}
|
||||
}
|
||||
|
||||
private fun streamTiffImage(uri: Uri, page: Int = 0) {
|
||||
val resolver = activity.contentResolver
|
||||
try {
|
||||
val fd = resolver.openFileDescriptor(uri, "r")?.detachFd()
|
||||
if (fd == null) {
|
||||
error("streamImage-tiff-fd", "failed to get file descriptor for uri=$uri", null)
|
||||
return
|
||||
}
|
||||
val options = TiffBitmapFactory.Options().apply {
|
||||
inDirectoryNumber = page
|
||||
}
|
||||
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||
if (bitmap != null) {
|
||||
success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
|
||||
} else {
|
||||
error("streamImage-tiff-null", "failed to get tiff image (dir=$page) from uri=$uri", null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
error("streamImage-tiff-exception", "failed to get image from uri=$uri", toErrorDetails(e))
|
||||
}
|
||||
}
|
||||
|
||||
private fun toErrorDetails(e: Exception): String? {
|
||||
val errorDetails = e.message
|
||||
return if (errorDetails?.isNotEmpty() == true) {
|
||||
|
|
|
@ -5,8 +5,8 @@ import android.net.Uri
|
|||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import deckers.thibault.aves.model.AvesImageEntry
|
||||
import deckers.thibault.aves.model.provider.FieldMap
|
||||
import deckers.thibault.aves.model.AvesEntry
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
|
||||
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
|
@ -42,6 +42,7 @@ class ImageOpStreamHandler(private val context: Context, private val arguments:
|
|||
|
||||
when (op) {
|
||||
"delete" -> GlobalScope.launch(Dispatchers.IO) { delete() }
|
||||
"export" -> GlobalScope.launch(Dispatchers.IO) { export() }
|
||||
"move" -> GlobalScope.launch(Dispatchers.IO) { move() }
|
||||
else -> endOfStream()
|
||||
}
|
||||
|
@ -80,36 +81,6 @@ class ImageOpStreamHandler(private val context: Context, private val arguments:
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun move() {
|
||||
if (arguments !is Map<*, *> || entryMapList.isEmpty()) {
|
||||
endOfStream()
|
||||
return
|
||||
}
|
||||
|
||||
// assume same provider for all entries
|
||||
val firstEntry = entryMapList.first()
|
||||
val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(it) }
|
||||
if (provider == null) {
|
||||
error("move-provider", "failed to find provider for entry=$firstEntry", null)
|
||||
return
|
||||
}
|
||||
|
||||
val copy = arguments["copy"] as Boolean?
|
||||
var destinationDir = arguments["destinationPath"] as String?
|
||||
if (copy == null || destinationDir == null) {
|
||||
error("move-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
|
||||
val entries = entryMapList.map(::AvesImageEntry)
|
||||
provider.moveMultiple(context, copy, destinationDir, entries, object : ImageOpCallback {
|
||||
override fun onSuccess(fields: FieldMap) = success(fields)
|
||||
override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable)
|
||||
})
|
||||
endOfStream()
|
||||
}
|
||||
|
||||
private suspend fun delete() {
|
||||
if (entryMapList.isEmpty()) {
|
||||
endOfStream()
|
||||
|
@ -144,6 +115,66 @@ class ImageOpStreamHandler(private val context: Context, private val arguments:
|
|||
endOfStream()
|
||||
}
|
||||
|
||||
private suspend fun export() {
|
||||
if (arguments !is Map<*, *> || entryMapList.isEmpty()) {
|
||||
endOfStream()
|
||||
return
|
||||
}
|
||||
|
||||
var destinationDir = arguments["destinationPath"] as String?
|
||||
val mimeType = arguments["mimeType"] as String?
|
||||
if (destinationDir == null || mimeType == null) {
|
||||
error("export-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
// assume same provider for all entries
|
||||
val firstEntry = entryMapList.first()
|
||||
val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(it) }
|
||||
if (provider == null) {
|
||||
error("export-provider", "failed to find provider for entry=$firstEntry", null)
|
||||
return
|
||||
}
|
||||
|
||||
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
|
||||
val entries = entryMapList.map(::AvesEntry)
|
||||
provider.exportMultiple(context, mimeType, destinationDir, entries, object : ImageOpCallback {
|
||||
override fun onSuccess(fields: FieldMap) = success(fields)
|
||||
override fun onFailure(throwable: Throwable) = error("export-failure", "failed to export entries", throwable)
|
||||
})
|
||||
endOfStream()
|
||||
}
|
||||
|
||||
private suspend fun move() {
|
||||
if (arguments !is Map<*, *> || entryMapList.isEmpty()) {
|
||||
endOfStream()
|
||||
return
|
||||
}
|
||||
|
||||
val copy = arguments["copy"] as Boolean?
|
||||
var destinationDir = arguments["destinationPath"] as String?
|
||||
if (copy == null || destinationDir == null) {
|
||||
error("move-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
// assume same provider for all entries
|
||||
val firstEntry = entryMapList.first()
|
||||
val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(it) }
|
||||
if (provider == null) {
|
||||
error("move-provider", "failed to find provider for entry=$firstEntry", null)
|
||||
return
|
||||
}
|
||||
|
||||
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
|
||||
val entries = entryMapList.map(::AvesEntry)
|
||||
provider.moveMultiple(context, copy, destinationDir, entries, object : ImageOpCallback {
|
||||
override fun onSuccess(fields: FieldMap) = success(fields)
|
||||
override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable)
|
||||
})
|
||||
endOfStream()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag(ImageOpStreamHandler::class.java)
|
||||
const val CHANNEL = "deckers.thibault/aves/imageopstream"
|
||||
|
|
|
@ -18,4 +18,8 @@ class IntentStreamHandler : EventChannel.StreamHandler {
|
|||
fun notifyNewIntent(intentData: MutableMap<String, Any?>?) {
|
||||
eventSink?.success(intentData)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL = "deckers.thibault/aves/intent"
|
||||
}
|
||||
}
|
|
@ -4,7 +4,7 @@ import android.content.Context
|
|||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import deckers.thibault.aves.model.provider.FieldMap
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
package deckers.thibault.aves.decoder
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.Priority
|
||||
import com.bumptech.glide.Registry
|
||||
import com.bumptech.glide.annotation.GlideModule
|
||||
import com.bumptech.glide.load.DataSource
|
||||
import com.bumptech.glide.load.Options
|
||||
import com.bumptech.glide.load.data.DataFetcher
|
||||
import com.bumptech.glide.load.data.DataFetcher.DataCallback
|
||||
import com.bumptech.glide.load.model.ModelLoader
|
||||
import com.bumptech.glide.load.model.ModelLoaderFactory
|
||||
import com.bumptech.glide.load.model.MultiModelLoaderFactory
|
||||
import com.bumptech.glide.module.LibraryGlideModule
|
||||
import com.bumptech.glide.signature.ObjectKey
|
||||
import deckers.thibault.aves.metadata.MultiTrackMedia
|
||||
|
||||
|
||||
@GlideModule
|
||||
class MultiTrackImageGlideModule : LibraryGlideModule() {
|
||||
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
||||
registry.append(MultiTrackImage::class.java, Bitmap::class.java, MultiTrackThumbnailLoader.Factory())
|
||||
}
|
||||
}
|
||||
|
||||
class MultiTrackImage(val context: Context, val uri: Uri, val trackId: Int?)
|
||||
|
||||
internal class MultiTrackThumbnailLoader : ModelLoader<MultiTrackImage, Bitmap> {
|
||||
override fun buildLoadData(model: MultiTrackImage, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> {
|
||||
return ModelLoader.LoadData(ObjectKey(model.uri), MultiTrackImageFetcher(model, width, height))
|
||||
}
|
||||
|
||||
override fun handles(model: MultiTrackImage): Boolean = true
|
||||
|
||||
internal class Factory : ModelLoaderFactory<MultiTrackImage, Bitmap> {
|
||||
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<MultiTrackImage, Bitmap> = MultiTrackThumbnailLoader()
|
||||
|
||||
override fun teardown() {}
|
||||
}
|
||||
}
|
||||
|
||||
internal class MultiTrackImageFetcher(val model: MultiTrackImage, val width: Int, val height: Int) : DataFetcher<Bitmap> {
|
||||
override fun loadData(priority: Priority, callback: DataCallback<in Bitmap>) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
||||
callback.onLoadFailed(Exception("unsupported Android version"))
|
||||
return
|
||||
}
|
||||
|
||||
val context = model.context
|
||||
val uri = model.uri
|
||||
val trackId = model.trackId
|
||||
|
||||
val bitmap = MultiTrackMedia.getImage(context, uri, trackId)
|
||||
if (bitmap == null) {
|
||||
callback.onLoadFailed(Exception("null bitmap"))
|
||||
} else {
|
||||
callback.onDataReady(bitmap)
|
||||
}
|
||||
}
|
||||
|
||||
override fun cleanup() {}
|
||||
|
||||
// cannot cancel
|
||||
override fun cancel() {}
|
||||
|
||||
override fun getDataClass(): Class<Bitmap> = Bitmap::class.java
|
||||
|
||||
override fun getDataSource(): DataSource = DataSource.LOCAL
|
||||
}
|
|
@ -0,0 +1,99 @@
|
|||
package deckers.thibault.aves.decoder
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.Priority
|
||||
import com.bumptech.glide.Registry
|
||||
import com.bumptech.glide.annotation.GlideModule
|
||||
import com.bumptech.glide.load.DataSource
|
||||
import com.bumptech.glide.load.Options
|
||||
import com.bumptech.glide.load.data.DataFetcher
|
||||
import com.bumptech.glide.load.data.DataFetcher.DataCallback
|
||||
import com.bumptech.glide.load.model.ModelLoader
|
||||
import com.bumptech.glide.load.model.ModelLoaderFactory
|
||||
import com.bumptech.glide.load.model.MultiModelLoaderFactory
|
||||
import com.bumptech.glide.module.LibraryGlideModule
|
||||
import com.bumptech.glide.signature.ObjectKey
|
||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||
|
||||
@GlideModule
|
||||
class TiffGlideModule : LibraryGlideModule() {
|
||||
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
||||
registry.append(TiffImage::class.java, Bitmap::class.java, TiffLoader.Factory())
|
||||
}
|
||||
}
|
||||
|
||||
class TiffImage(val context: Context, val uri: Uri, val page: Int?)
|
||||
|
||||
internal class TiffLoader : ModelLoader<TiffImage, Bitmap> {
|
||||
override fun buildLoadData(model: TiffImage, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> {
|
||||
return ModelLoader.LoadData(ObjectKey(model.uri), TiffFetcher(model, width, height))
|
||||
}
|
||||
|
||||
override fun handles(model: TiffImage): Boolean = true
|
||||
|
||||
internal class Factory : ModelLoaderFactory<TiffImage, Bitmap> {
|
||||
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<TiffImage, Bitmap> = TiffLoader()
|
||||
|
||||
override fun teardown() {}
|
||||
}
|
||||
}
|
||||
|
||||
internal class TiffFetcher(val model: TiffImage, val width: Int, val height: Int) : DataFetcher<Bitmap> {
|
||||
override fun loadData(priority: Priority, callback: DataCallback<in Bitmap>) {
|
||||
val context = model.context
|
||||
val uri = model.uri
|
||||
val page = model.page ?: 0
|
||||
|
||||
var sampleSize = 1
|
||||
if (width > 0 && height > 0) {
|
||||
// determine sample size
|
||||
val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
|
||||
if (fd == null) {
|
||||
callback.onLoadFailed(Exception("null file descriptor"))
|
||||
return
|
||||
}
|
||||
val options = TiffBitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = true
|
||||
inDirectoryNumber = page
|
||||
}
|
||||
TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||
val imageWidth = options.outWidth
|
||||
val imageHeight = options.outHeight
|
||||
if (imageHeight > height || imageWidth > width) {
|
||||
while (imageHeight / (sampleSize * 2) > height && imageWidth / (sampleSize * 2) > width) {
|
||||
sampleSize *= 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// decode
|
||||
val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
|
||||
if (fd == null) {
|
||||
callback.onLoadFailed(Exception("null file descriptor"))
|
||||
return
|
||||
}
|
||||
val options = TiffBitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = false
|
||||
inDirectoryNumber = page
|
||||
inSampleSize = sampleSize
|
||||
}
|
||||
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||
if (bitmap == null) {
|
||||
callback.onLoadFailed(Exception("null bitmap"))
|
||||
} else {
|
||||
callback.onDataReady(bitmap)
|
||||
}
|
||||
}
|
||||
|
||||
override fun cleanup() {}
|
||||
|
||||
// cannot cancel
|
||||
override fun cancel() {}
|
||||
|
||||
override fun getDataClass(): Class<Bitmap> = Bitmap::class.java
|
||||
|
||||
override fun getDataSource(): DataSource = DataSource.LOCAL
|
||||
}
|
|
@ -1,99 +0,0 @@
|
|||
package deckers.thibault.aves.decoder
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.Priority
|
||||
import com.bumptech.glide.Registry
|
||||
import com.bumptech.glide.annotation.GlideModule
|
||||
import com.bumptech.glide.load.DataSource
|
||||
import com.bumptech.glide.load.Options
|
||||
import com.bumptech.glide.load.data.DataFetcher
|
||||
import com.bumptech.glide.load.data.DataFetcher.DataCallback
|
||||
import com.bumptech.glide.load.model.ModelLoader
|
||||
import com.bumptech.glide.load.model.ModelLoaderFactory
|
||||
import com.bumptech.glide.load.model.MultiModelLoaderFactory
|
||||
import com.bumptech.glide.module.LibraryGlideModule
|
||||
import com.bumptech.glide.signature.ObjectKey
|
||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||
import java.io.InputStream
|
||||
|
||||
@GlideModule
|
||||
class TiffThumbnailGlideModule : LibraryGlideModule() {
|
||||
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
||||
registry.append(TiffThumbnail::class.java, InputStream::class.java, TiffThumbnailLoader.Factory())
|
||||
}
|
||||
}
|
||||
|
||||
class TiffThumbnail(val context: Context, val uri: Uri, val page: Int)
|
||||
|
||||
internal class TiffThumbnailLoader : ModelLoader<TiffThumbnail, InputStream> {
|
||||
override fun buildLoadData(model: TiffThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<InputStream> {
|
||||
return ModelLoader.LoadData(ObjectKey(model.uri), TiffThumbnailFetcher(model, width, height))
|
||||
}
|
||||
|
||||
override fun handles(tiffThumbnail: TiffThumbnail): Boolean = true
|
||||
|
||||
internal class Factory : ModelLoaderFactory<TiffThumbnail, InputStream> {
|
||||
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<TiffThumbnail, InputStream> = TiffThumbnailLoader()
|
||||
|
||||
override fun teardown() {}
|
||||
}
|
||||
}
|
||||
|
||||
internal class TiffThumbnailFetcher(val model: TiffThumbnail, val width: Int, val height: Int) : DataFetcher<InputStream> {
|
||||
override fun loadData(priority: Priority, callback: DataCallback<in InputStream>) {
|
||||
val context = model.context
|
||||
val uri = model.uri
|
||||
val page = model.page
|
||||
|
||||
// determine sample size
|
||||
var fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
|
||||
if (fd == null) {
|
||||
callback.onLoadFailed(Exception("null file descriptor"))
|
||||
return
|
||||
}
|
||||
var sampleSize = 1
|
||||
var options = TiffBitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = true
|
||||
inDirectoryNumber = page
|
||||
}
|
||||
TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||
val imageWidth = options.outWidth
|
||||
val imageHeight = options.outHeight
|
||||
if (imageHeight > height || imageWidth > width) {
|
||||
while (imageHeight / (sampleSize * 2) > height && imageWidth / (sampleSize * 2) > width) {
|
||||
sampleSize *= 2
|
||||
}
|
||||
}
|
||||
|
||||
// decode
|
||||
fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
|
||||
if (fd == null) {
|
||||
callback.onLoadFailed(Exception("null file descriptor"))
|
||||
return
|
||||
}
|
||||
options = TiffBitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = false
|
||||
inDirectoryNumber = page
|
||||
inSampleSize = sampleSize
|
||||
}
|
||||
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||
if (bitmap == null) {
|
||||
callback.onLoadFailed(Exception("null bitmap"))
|
||||
} else {
|
||||
callback.onDataReady(bitmap.getBytes()?.inputStream())
|
||||
}
|
||||
}
|
||||
|
||||
// already cleaned up in loadData and ByteArrayInputStream will be GC'd
|
||||
override fun cleanup() {}
|
||||
|
||||
// cannot cancel
|
||||
override fun cancel() {}
|
||||
|
||||
override fun getDataClass(): Class<InputStream> = InputStream::class.java
|
||||
|
||||
override fun getDataSource(): DataSource = DataSource.LOCAL
|
||||
}
|
|
@ -1,7 +1,9 @@
|
|||
package deckers.thibault.aves.decoder
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.Priority
|
||||
import com.bumptech.glide.Registry
|
||||
|
@ -34,7 +36,7 @@ internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, InputStream> {
|
|||
return ModelLoader.LoadData(ObjectKey(model.uri), VideoThumbnailFetcher(model))
|
||||
}
|
||||
|
||||
override fun handles(videoThumbnail: VideoThumbnail): Boolean = true
|
||||
override fun handles(model: VideoThumbnail): Boolean = true
|
||||
|
||||
internal class Factory : ModelLoaderFactory<VideoThumbnail, InputStream> {
|
||||
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<VideoThumbnail, InputStream> = VideoThumbnailLoader()
|
||||
|
@ -48,9 +50,29 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail) : DataFe
|
|||
val retriever = openMetadataRetriever(model.context, model.uri)
|
||||
if (retriever != null) {
|
||||
try {
|
||||
val picture = retriever.embeddedPicture ?: retriever.frameAtTime?.getBytes(canHaveAlpha = false, recycle = false)
|
||||
if (picture != null) {
|
||||
callback.onDataReady(ByteArrayInputStream(picture))
|
||||
var bytes = retriever.embeddedPicture
|
||||
if (bytes == null) {
|
||||
// try to match the thumbnails returned by the content resolver / Media Store
|
||||
// the following strategies are from empirical evidence from a few test devices:
|
||||
// - API 29: sync frame closest to the middle
|
||||
// - API 26/27: default representative frame at any time position
|
||||
var timeMillis: Long? = null
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val durationMillis = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull()
|
||||
if (durationMillis != null) {
|
||||
timeMillis = durationMillis / 2
|
||||
}
|
||||
}
|
||||
val frame = if (timeMillis != null) {
|
||||
retriever.getFrameAtTime(timeMillis * 1000)
|
||||
} else {
|
||||
retriever.frameAtTime
|
||||
}
|
||||
bytes = frame?.getBytes(canHaveAlpha = false, recycle = false)
|
||||
}
|
||||
|
||||
if (bytes != null) {
|
||||
callback.onDataReady(ByteArrayInputStream(bytes))
|
||||
} else {
|
||||
callback.onLoadFailed(Exception("failed to get embedded picture or any frame"))
|
||||
}
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
package deckers.thibault.aves.metadata
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
|
||||
object MultiTrackMedia {
|
||||
private val LOG_TAG = LogUtils.createTag(MultiTrackMedia::class.java)
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.P)
|
||||
fun getImage(context: Context, uri: Uri, trackId: Int?): Bitmap? {
|
||||
val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return null
|
||||
try {
|
||||
return if (trackId != null) {
|
||||
val imageIndex = trackIdToImageIndex(context, uri, trackId) ?: return null
|
||||
retriever.getImageAtIndex(imageIndex)
|
||||
} else {
|
||||
retriever.primaryImage
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to extract image from uri=$uri trackId=$trackId", e)
|
||||
} finally {
|
||||
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
|
||||
retriever.release()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun trackIdToImageIndex(context: Context, uri: Uri, trackId: Int): Int? {
|
||||
val extractor = MediaExtractor()
|
||||
try {
|
||||
extractor.setDataSource(context, uri, null)
|
||||
val trackCount = extractor.trackCount
|
||||
var imageIndex = 0
|
||||
for (i in 0 until trackCount) {
|
||||
val trackFormat = extractor.getTrackFormat(i)
|
||||
if (trackId == trackFormat.getInteger(MediaFormat.KEY_TRACK_ID)) {
|
||||
return imageIndex
|
||||
}
|
||||
if (MimeTypes.isImage(trackFormat.getString(MediaFormat.KEY_MIME))) {
|
||||
imageIndex++
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get image index for uri=$uri, trackId=$trackId", e)
|
||||
} finally {
|
||||
extractor.release()
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
package deckers.thibault.aves.metadata
|
||||
|
||||
import android.util.Log
|
||||
import com.adobe.internal.xmp.XMPError
|
||||
import com.adobe.internal.xmp.XMPException
|
||||
import com.adobe.internal.xmp.XMPMeta
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
|
@ -9,6 +10,8 @@ import java.util.*
|
|||
object XMP {
|
||||
private val LOG_TAG = LogUtils.createTag(XMP::class.java)
|
||||
|
||||
// standard namespaces
|
||||
// cf com.adobe.internal.xmp.XMPConst
|
||||
const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/"
|
||||
const val PHOTOSHOP_SCHEMA_NS = "http://ns.adobe.com/photoshop/1.0/"
|
||||
const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/"
|
||||
|
@ -51,27 +54,46 @@ object XMP {
|
|||
const val GPANO_CROPPED_AREA_TOP_PROP_NAME = "GPano:CroppedAreaTopPixels"
|
||||
const val GPANO_FULL_PANO_HEIGHT_PROP_NAME = "GPano:FullPanoHeightPixels"
|
||||
const val GPANO_FULL_PANO_WIDTH_PROP_NAME = "GPano:FullPanoWidthPixels"
|
||||
private const val GPANO_PROJECTION_TYPE_PROP_NAME = "GPano:ProjectionType"
|
||||
const val GPANO_PROJECTION_TYPE_PROP_NAME = "GPano:ProjectionType"
|
||||
const val GPANO_PROJECTION_TYPE_DEFAULT = "equirectangular"
|
||||
|
||||
private const val PMTM_IS_PANO360 = "pmtm:IsPano360"
|
||||
|
||||
// `GPano:ProjectionType` is required by spec but it is sometimes missing, assuming default
|
||||
// `GPano:FullPanoHeightPixels` is required by spec but it is sometimes missing (e.g. Samsung Camera app panorama mode)
|
||||
private val gpanoRequiredProps = listOf(
|
||||
GPANO_CROPPED_AREA_HEIGHT_PROP_NAME,
|
||||
GPANO_CROPPED_AREA_WIDTH_PROP_NAME,
|
||||
GPANO_CROPPED_AREA_LEFT_PROP_NAME,
|
||||
GPANO_CROPPED_AREA_TOP_PROP_NAME,
|
||||
GPANO_FULL_PANO_HEIGHT_PROP_NAME,
|
||||
GPANO_FULL_PANO_WIDTH_PROP_NAME,
|
||||
GPANO_PROJECTION_TYPE_PROP_NAME,
|
||||
)
|
||||
|
||||
// extensions
|
||||
|
||||
fun XMPMeta.isPanorama(): Boolean {
|
||||
// Google
|
||||
if (gpanoRequiredProps.all { doesPropertyExist(GPANO_SCHEMA_NS, it) }) return true
|
||||
try {
|
||||
if (gpanoRequiredProps.all { doesPropertyExist(GPANO_SCHEMA_NS, it) }) return true
|
||||
} catch (e: XMPException) {
|
||||
if (e.errorCode != XMPError.BADSCHEMA) {
|
||||
// `BADSCHEMA` code is reported when we check a property
|
||||
// from a non standard namespace, and that namespace is not declared in the XMP
|
||||
Log.w(LOG_TAG, "failed to check Google panorama props from XMP", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Photomatix
|
||||
if (getPropertyString(PMTM_SCHEMA_NS, PMTM_IS_PANO360) == "Yes") return true
|
||||
try {
|
||||
if (getPropertyString(PMTM_SCHEMA_NS, PMTM_IS_PANO360) == "Yes") return true
|
||||
} catch (e: XMPException) {
|
||||
if (e.errorCode != XMPError.BADSCHEMA) {
|
||||
// `BADSCHEMA` code is reported when we check a property
|
||||
// from a non standard namespace, and that namespace is not declared in the XMP
|
||||
Log.w(LOG_TAG, "failed to check Photomatix panorama props from XMP", e)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
@ -102,7 +124,7 @@ object XMP {
|
|||
}
|
||||
}
|
||||
} catch (e: XMPException) {
|
||||
Log.w(LOG_TAG, "failed to get text for XMP schema=$schema, propName=$propName", e)
|
||||
Log.w(LOG_TAG, "failed to get date for XMP schema=$schema, propName=$propName", e)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,13 +1,15 @@
|
|||
package deckers.thibault.aves.model
|
||||
|
||||
import android.net.Uri
|
||||
import deckers.thibault.aves.model.provider.FieldMap
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
|
||||
class AvesImageEntry(map: FieldMap) {
|
||||
class AvesEntry(map: FieldMap) {
|
||||
val uri: Uri = Uri.parse(map["uri"] as String) // content or file URI
|
||||
val path = map["path"] as String? // best effort to get local path
|
||||
val pageId = map["pageId"] as Int? // null means the main entry
|
||||
val mimeType = map["mimeType"] as String
|
||||
val width = map["width"] as Int
|
||||
val height = map["height"] as Int
|
||||
val rotationDegrees = map["rotationDegrees"] as Int
|
||||
val isFlipped = map["isFlipped"] as Boolean
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
package deckers.thibault.aves.model
|
||||
|
||||
typealias FieldMap = MutableMap<String, Any?>
|
|
@ -25,13 +25,13 @@ import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
|
|||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
|
||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
|
||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeLong
|
||||
import deckers.thibault.aves.model.provider.FieldMap
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||
import java.io.IOException
|
||||
|
||||
class SourceImageEntry {
|
||||
class SourceEntry {
|
||||
val uri: Uri // content or file URI
|
||||
var path: String? = null // best effort to get local path
|
||||
private val sourceMimeType: String
|
||||
|
@ -119,7 +119,7 @@ class SourceImageEntry {
|
|||
// metadata retrieval
|
||||
// expects entry with: uri, mimeType
|
||||
// finds: width, height, orientation/rotation, date, title, duration
|
||||
fun fillPreCatalogMetadata(context: Context): SourceImageEntry {
|
||||
fun fillPreCatalogMetadata(context: Context): SourceEntry {
|
||||
if (isSvg) return this
|
||||
if (isVideo) {
|
||||
fillVideoByMediaMetadataRetriever(context)
|
|
@ -3,7 +3,7 @@ package deckers.thibault.aves.model.provider
|
|||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import deckers.thibault.aves.model.SourceImageEntry
|
||||
import deckers.thibault.aves.model.SourceEntry
|
||||
|
||||
internal class ContentImageProvider : ImageProvider() {
|
||||
override suspend fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) {
|
||||
|
@ -28,7 +28,7 @@ internal class ContentImageProvider : ImageProvider() {
|
|||
return
|
||||
}
|
||||
|
||||
val entry = SourceImageEntry(map).fillPreCatalogMetadata(context)
|
||||
val entry = SourceEntry(map).fillPreCatalogMetadata(context)
|
||||
if (entry.isSized || entry.isSvg) {
|
||||
callback.onSuccess(entry.toMap())
|
||||
} else {
|
||||
|
|
|
@ -2,7 +2,7 @@ package deckers.thibault.aves.model.provider
|
|||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import deckers.thibault.aves.model.SourceImageEntry
|
||||
import deckers.thibault.aves.model.SourceEntry
|
||||
import java.io.File
|
||||
|
||||
internal class FileImageProvider : ImageProvider() {
|
||||
|
@ -12,7 +12,7 @@ internal class FileImageProvider : ImageProvider() {
|
|||
return
|
||||
}
|
||||
|
||||
val entry = SourceImageEntry(uri, mimeType)
|
||||
val entry = SourceEntry(uri, mimeType)
|
||||
|
||||
val path = uri.path
|
||||
if (path != null) {
|
||||
|
|
|
@ -2,18 +2,28 @@ package deckers.thibault.aves.model.provider
|
|||
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.media.MediaScannerConnection
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.DecodeFormat
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import com.commonsware.cwac.document.DocumentFileCompat
|
||||
import deckers.thibault.aves.model.AvesImageEntry
|
||||
import deckers.thibault.aves.decoder.MultiTrackImage
|
||||
import deckers.thibault.aves.decoder.TiffImage
|
||||
import deckers.thibault.aves.model.AvesEntry
|
||||
import deckers.thibault.aves.model.ExifOrientationOp
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.utils.BitmapUtils
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.StorageUtils.copyFileToTemp
|
||||
import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent
|
||||
import deckers.thibault.aves.utils.StorageUtils.getDocumentFile
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
|
@ -32,10 +42,151 @@ abstract class ImageProvider {
|
|||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
open suspend fun moveMultiple(context: Context, copy: Boolean, destinationDir: String, entries: List<AvesImageEntry>, callback: ImageOpCallback) {
|
||||
open suspend fun moveMultiple(context: Context, copy: Boolean, destinationDir: String, entries: List<AvesEntry>, callback: ImageOpCallback) {
|
||||
callback.onFailure(UnsupportedOperationException())
|
||||
}
|
||||
|
||||
suspend fun exportMultiple(
|
||||
context: Context,
|
||||
mimeType: String,
|
||||
destinationDir: String,
|
||||
entries: List<AvesEntry>,
|
||||
callback: ImageOpCallback,
|
||||
) {
|
||||
val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir)
|
||||
if (destinationDirDocFile == null) {
|
||||
callback.onFailure(Exception("failed to create directory at path=$destinationDir"))
|
||||
return
|
||||
}
|
||||
|
||||
for (entry in entries) {
|
||||
val sourceUri = entry.uri
|
||||
val sourcePath = entry.path
|
||||
val pageId = entry.pageId
|
||||
|
||||
val result = hashMapOf<String, Any?>(
|
||||
"uri" to sourceUri.toString(),
|
||||
"pageId" to pageId,
|
||||
"success" to false,
|
||||
)
|
||||
|
||||
try {
|
||||
val newFields = exportSingleByTreeDocAndScan(
|
||||
context = context,
|
||||
sourceEntry = entry,
|
||||
destinationDir = destinationDir,
|
||||
destinationDirDocFile = destinationDirDocFile,
|
||||
exportMimeType = mimeType,
|
||||
)
|
||||
result["newFields"] = newFields
|
||||
result["success"] = true
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to export to destinationDir=$destinationDir entry with sourcePath=$sourcePath pageId=$pageId", e)
|
||||
}
|
||||
callback.onSuccess(result)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun exportSingleByTreeDocAndScan(
|
||||
context: Context,
|
||||
sourceEntry: AvesEntry,
|
||||
destinationDir: String,
|
||||
destinationDirDocFile: DocumentFileCompat,
|
||||
exportMimeType: String,
|
||||
): FieldMap {
|
||||
val sourceMimeType = sourceEntry.mimeType
|
||||
val sourceUri = sourceEntry.uri
|
||||
val pageId = sourceEntry.pageId
|
||||
|
||||
var desiredNameWithoutExtension = if (sourceEntry.path != null) {
|
||||
val sourcePath = sourceEntry.path
|
||||
val sourceFile = File(sourcePath)
|
||||
val sourceFileName = sourceFile.name
|
||||
sourceFileName.replaceFirst("[.][^.]+$".toRegex(), "")
|
||||
} else {
|
||||
sourceUri.lastPathSegment!!
|
||||
}
|
||||
if (pageId != null) {
|
||||
val page = if (sourceMimeType == MimeTypes.TIFF) pageId + 1 else pageId
|
||||
desiredNameWithoutExtension += "_${page.toString().padStart(3, '0')}"
|
||||
}
|
||||
val desiredFileName = desiredNameWithoutExtension + when (exportMimeType) {
|
||||
MimeTypes.JPEG -> ".jpg"
|
||||
MimeTypes.PNG -> ".png"
|
||||
MimeTypes.WEBP -> ".webp"
|
||||
else -> throw Exception("unsupported export MIME type=$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`
|
||||
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
|
||||
// through a document URI, not a tree URI
|
||||
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
val destinationTreeFile = destinationDirDocFile.createFile(exportMimeType, desiredNameWithoutExtension)
|
||||
val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri)
|
||||
|
||||
val model: Any = if (MimeTypes.isHeifLike(sourceMimeType) && pageId != null) {
|
||||
MultiTrackImage(context, sourceUri, pageId)
|
||||
} else if (sourceMimeType == MimeTypes.TIFF) {
|
||||
TiffImage(context, sourceUri, pageId)
|
||||
} else {
|
||||
sourceUri
|
||||
}
|
||||
|
||||
// request a fresh image with the highest quality format
|
||||
val glideOptions = RequestOptions()
|
||||
.format(DecodeFormat.PREFER_ARGB_8888)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.skipMemoryCache(true)
|
||||
|
||||
val target = Glide.with(context)
|
||||
.asBitmap()
|
||||
.apply(glideOptions)
|
||||
.load(model)
|
||||
.submit()
|
||||
try {
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
var bitmap = target.get()
|
||||
if (MimeTypes.needRotationAfterGlide(sourceMimeType)) {
|
||||
bitmap = BitmapUtils.applyExifOrientation(context, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped)
|
||||
}
|
||||
bitmap ?: throw Exception("failed to get image from uri=$sourceUri page=$pageId")
|
||||
|
||||
val quality = 100
|
||||
val format = when (exportMimeType) {
|
||||
MimeTypes.JPEG -> Bitmap.CompressFormat.JPEG
|
||||
MimeTypes.PNG -> Bitmap.CompressFormat.PNG
|
||||
MimeTypes.WEBP -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
if (quality == 100) {
|
||||
Bitmap.CompressFormat.WEBP_LOSSLESS
|
||||
} else {
|
||||
Bitmap.CompressFormat.WEBP_LOSSY
|
||||
}
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
Bitmap.CompressFormat.WEBP
|
||||
}
|
||||
else -> throw Exception("unsupported export MIME type=$exportMimeType")
|
||||
}
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
destinationDocFile.openOutputStream().use {
|
||||
bitmap.compress(format, quality, it)
|
||||
}
|
||||
} finally {
|
||||
Glide.with(context).clear(target)
|
||||
}
|
||||
|
||||
val fileName = destinationDocFile.name
|
||||
val destinationFullPath = destinationDir + fileName
|
||||
|
||||
return scanNewPath(context, destinationFullPath, exportMimeType)
|
||||
}
|
||||
|
||||
suspend fun rename(context: Context, oldPath: String, oldMediaUri: Uri, mimeType: String, newFilename: String, callback: ImageOpCallback) {
|
||||
val oldFile = File(oldPath)
|
||||
val newFile = File(oldFile.parent, newFilename)
|
||||
|
@ -147,9 +298,9 @@ abstract class ImageProvider {
|
|||
// `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")
|
||||
contentId = ContentUris.parseId(newUri)
|
||||
if (isImage(mimeType)) {
|
||||
if (MimeTypes.isImage(mimeType)) {
|
||||
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId)
|
||||
} else if (isVideo(mimeType)) {
|
||||
} else if (MimeTypes.isVideo(mimeType)) {
|
||||
contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId)
|
||||
}
|
||||
}
|
||||
|
@ -198,5 +349,3 @@ abstract class ImageProvider {
|
|||
private val LOG_TAG = LogUtils.createTag(ImageProvider::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
typealias FieldMap = MutableMap<String, Any?>
|
||||
|
|
|
@ -8,8 +8,9 @@ import android.os.Build
|
|||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import com.commonsware.cwac.document.DocumentFileCompat
|
||||
import deckers.thibault.aves.model.AvesImageEntry
|
||||
import deckers.thibault.aves.model.SourceImageEntry
|
||||
import deckers.thibault.aves.model.AvesEntry
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.model.SourceEntry
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
||||
|
@ -158,7 +159,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
// missing some attributes such as width, height, orientation.
|
||||
// Also, the reported size of raw images is inconsistent across devices
|
||||
// and Android versions (sometimes the raw size, sometimes the decoded size).
|
||||
val entry = SourceImageEntry(entryMap).fillPreCatalogMetadata(context)
|
||||
val entry = SourceEntry(entryMap).fillPreCatalogMetadata(context)
|
||||
entryMap = entry.toMap()
|
||||
}
|
||||
|
||||
|
@ -185,7 +186,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
override suspend fun delete(context: Context, uri: Uri, path: String?) {
|
||||
path ?: throw Exception("failed to delete file because path is null")
|
||||
|
||||
if (requireAccessPermission(context, path)) {
|
||||
if (File(path).exists() && requireAccessPermission(context, path)) {
|
||||
// if the file is on SD card, calling the content resolver `delete()` removes the entry from the Media Store
|
||||
// but it doesn't delete the file, even if the app has the permission
|
||||
val df = getDocumentFile(context, path, uri)
|
||||
|
@ -203,7 +204,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
context: Context,
|
||||
copy: Boolean,
|
||||
destinationDir: String,
|
||||
entries: List<AvesImageEntry>,
|
||||
entries: List<AvesEntry>,
|
||||
callback: ImageOpCallback,
|
||||
) {
|
||||
val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir)
|
||||
|
|
|
@ -26,7 +26,7 @@ object BitmapUtils {
|
|||
} catch (e: IllegalStateException) {
|
||||
Log.e(LOG_TAG, "failed to get bytes from bitmap", e)
|
||||
}
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
fun applyExifOrientation(context: Context, bitmap: Bitmap?, rotationDegrees: Int?, isFlipped: Boolean?): Bitmap? {
|
||||
|
|
|
@ -9,10 +9,10 @@ object MimeTypes {
|
|||
private const val BMP = "image/bmp"
|
||||
const val GIF = "image/gif"
|
||||
const val HEIC = "image/heic"
|
||||
const val HEIF = "image/heif"
|
||||
private const val HEIF = "image/heif"
|
||||
private const val ICO = "image/x-icon"
|
||||
private const val JPEG = "image/jpeg"
|
||||
private const val PNG = "image/png"
|
||||
const val JPEG = "image/jpeg"
|
||||
const val PNG = "image/png"
|
||||
const val TIFF = "image/tiff"
|
||||
private const val WBMP = "image/vnd.wap.wbmp"
|
||||
const val WEBP = "image/webp"
|
||||
|
@ -41,10 +41,9 @@ object MimeTypes {
|
|||
|
||||
fun isVideo(mimeType: String?) = mimeType != null && mimeType.startsWith(VIDEO)
|
||||
|
||||
fun isMultimedia(mimeType: String?) = when (mimeType) {
|
||||
HEIC, HEIF -> true
|
||||
else -> isVideo(mimeType)
|
||||
}
|
||||
fun isHeifLike(mimeType: String?) = mimeType != null && (mimeType == HEIC || mimeType == HEIF)
|
||||
|
||||
fun isMultimedia(mimeType: String?) = isVideo(mimeType) || isHeifLike(mimeType)
|
||||
|
||||
fun isRaw(mimeType: String): Boolean {
|
||||
return when (mimeType) {
|
||||
|
|
|
@ -9,7 +9,7 @@ buildscript {
|
|||
// TODO TLAD upgrade AGP to 4+ when this lands on stable: https://github.com/flutter/flutter/pull/70808
|
||||
classpath 'com.android.tools.build:gradle:3.6.4'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
classpath 'com.google.gms:google-services:4.3.4'
|
||||
classpath 'com.google.gms:google-services:4.3.5'
|
||||
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.4.1'
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import 'dart:ui' as ui show Codec;
|
|||
|
||||
import 'package:aves/services/android_app_service.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class AppIconImage extends ImageProvider<AppIconImageKey> {
|
||||
const AppIconImage({
|
||||
|
|
|
@ -2,10 +2,9 @@ import 'dart:async';
|
|||
import 'dart:math';
|
||||
import 'dart:ui' as ui show Codec;
|
||||
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/services/image_file_service.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class RegionProvider extends ImageProvider<RegionProviderKey> {
|
||||
final RegionProviderKey key;
|
||||
|
@ -23,7 +22,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
|
|||
codec: _loadAsync(key, decode),
|
||||
scale: key.scale,
|
||||
informationCollector: () sync* {
|
||||
yield ErrorDescription('uri=${key.uri}, regionRect=${key.regionRect}');
|
||||
yield ErrorDescription('uri=${key.uri}, pageId=${key.pageId}, mimeType=${key.mimeType}, region=${key.region}');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -31,6 +30,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
|
|||
Future<ui.Codec> _loadAsync(RegionProviderKey key, DecoderCallback decode) async {
|
||||
final uri = key.uri;
|
||||
final mimeType = key.mimeType;
|
||||
final pageId = key.pageId;
|
||||
try {
|
||||
final bytes = await ImageFileService.getRegion(
|
||||
uri,
|
||||
|
@ -38,9 +38,9 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
|
|||
key.rotationDegrees,
|
||||
key.isFlipped,
|
||||
key.sampleSize,
|
||||
key.regionRect,
|
||||
key.region,
|
||||
key.imageSize,
|
||||
page: key.page,
|
||||
pageId: pageId,
|
||||
taskKey: key,
|
||||
);
|
||||
if (bytes == null) {
|
||||
|
@ -49,7 +49,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
|
|||
return await decode(bytes);
|
||||
} catch (error) {
|
||||
debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error');
|
||||
throw StateError('$mimeType region decoding failed');
|
||||
throw StateError('$mimeType region decoding failed (page $pageId)');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -63,21 +63,23 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
|
|||
}
|
||||
|
||||
class RegionProviderKey {
|
||||
// do not store the entry as it is, because the key should be constant
|
||||
// but the entry attributes may change over time
|
||||
final String uri, mimeType;
|
||||
final int rotationDegrees, sampleSize, page;
|
||||
final int pageId, rotationDegrees, sampleSize;
|
||||
final bool isFlipped;
|
||||
final Rectangle<int> regionRect;
|
||||
final Rectangle<int> region;
|
||||
final Size imageSize;
|
||||
final double scale;
|
||||
|
||||
const RegionProviderKey({
|
||||
@required this.uri,
|
||||
@required this.mimeType,
|
||||
@required this.pageId,
|
||||
@required this.rotationDegrees,
|
||||
@required this.isFlipped,
|
||||
this.page = 0,
|
||||
@required this.sampleSize,
|
||||
@required this.regionRect,
|
||||
@required this.region,
|
||||
@required this.imageSize,
|
||||
this.scale = 1.0,
|
||||
}) : assert(uri != null),
|
||||
|
@ -85,49 +87,29 @@ class RegionProviderKey {
|
|||
assert(rotationDegrees != null),
|
||||
assert(isFlipped != null),
|
||||
assert(sampleSize != null),
|
||||
assert(regionRect != null),
|
||||
assert(region != null),
|
||||
assert(imageSize != null),
|
||||
assert(scale != null);
|
||||
|
||||
// do not store the entry as it is, because the key should be constant
|
||||
// but the entry attributes may change over time
|
||||
factory RegionProviderKey.fromEntry(
|
||||
ImageEntry entry, {
|
||||
int page = 0,
|
||||
@required int sampleSize,
|
||||
@required Rectangle<int> rect,
|
||||
}) {
|
||||
return RegionProviderKey(
|
||||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
rotationDegrees: entry.rotationDegrees,
|
||||
isFlipped: entry.isFlipped,
|
||||
page: page,
|
||||
sampleSize: sampleSize,
|
||||
regionRect: rect,
|
||||
imageSize: Size(entry.width.toDouble(), entry.height.toDouble()),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is RegionProviderKey && other.uri == uri && other.mimeType == mimeType && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.page == page && other.sampleSize == sampleSize && other.regionRect == regionRect && other.imageSize == imageSize && other.scale == scale;
|
||||
return other is RegionProviderKey && other.uri == uri && other.mimeType == mimeType && other.pageId == pageId && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.sampleSize == sampleSize && other.region == region && other.imageSize == imageSize && other.scale == scale;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(
|
||||
uri,
|
||||
mimeType,
|
||||
pageId,
|
||||
rotationDegrees,
|
||||
isFlipped,
|
||||
page,
|
||||
sampleSize,
|
||||
regionRect,
|
||||
region,
|
||||
imageSize,
|
||||
scale,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, page=$page, sampleSize=$sampleSize, regionRect=$regionRect, imageSize=$imageSize, scale=$scale}';
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, pageId=$pageId, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, region=$region, imageSize=$imageSize, scale=$scale}';
|
||||
}
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import 'dart:ui' as ui show Codec;
|
||||
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/services/image_file_service.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
||||
final ThumbnailProviderKey key;
|
||||
|
@ -24,7 +23,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
|||
codec: _loadAsync(key, decode),
|
||||
scale: key.scale,
|
||||
informationCollector: () sync* {
|
||||
yield ErrorDescription('uri=${key.uri}, extent=${key.extent}');
|
||||
yield ErrorDescription('uri=${key.uri}, pageId=${key.pageId}, mimeType=${key.mimeType}, extent=${key.extent}');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -32,16 +31,16 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
|||
Future<ui.Codec> _loadAsync(ThumbnailProviderKey key, DecoderCallback decode) async {
|
||||
final uri = key.uri;
|
||||
final mimeType = key.mimeType;
|
||||
final pageId = key.pageId;
|
||||
try {
|
||||
final bytes = await ImageFileService.getThumbnail(
|
||||
uri,
|
||||
mimeType,
|
||||
key.dateModifiedSecs,
|
||||
key.rotationDegrees,
|
||||
key.isFlipped,
|
||||
key.extent,
|
||||
key.extent,
|
||||
page: key.page,
|
||||
uri: uri,
|
||||
mimeType: mimeType,
|
||||
pageId: pageId,
|
||||
rotationDegrees: key.rotationDegrees,
|
||||
isFlipped: key.isFlipped,
|
||||
dateModifiedSecs: key.dateModifiedSecs,
|
||||
extent: key.extent,
|
||||
taskKey: key,
|
||||
);
|
||||
if (bytes == null) {
|
||||
|
@ -50,7 +49,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
|||
return await decode(bytes);
|
||||
} catch (error) {
|
||||
debugPrint('$runtimeType _loadAsync failed with uri=$uri, error=$error');
|
||||
throw StateError('$mimeType decoding failed');
|
||||
throw StateError('$mimeType decoding failed (page $pageId)');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -64,61 +63,49 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
|||
}
|
||||
|
||||
class ThumbnailProviderKey {
|
||||
// do not store the entry as it is, because the key should be constant
|
||||
// but the entry attributes may change over time
|
||||
final String uri, mimeType;
|
||||
final int dateModifiedSecs, rotationDegrees, page;
|
||||
final int pageId, rotationDegrees;
|
||||
final bool isFlipped;
|
||||
final int dateModifiedSecs;
|
||||
final double extent, scale;
|
||||
|
||||
const ThumbnailProviderKey({
|
||||
@required this.uri,
|
||||
@required this.mimeType,
|
||||
@required this.dateModifiedSecs,
|
||||
@required this.pageId,
|
||||
@required this.rotationDegrees,
|
||||
@required this.isFlipped,
|
||||
this.page = 0,
|
||||
@required this.dateModifiedSecs,
|
||||
this.extent = 0,
|
||||
this.scale = 1,
|
||||
}) : assert(uri != null),
|
||||
assert(mimeType != null),
|
||||
assert(dateModifiedSecs != null),
|
||||
assert(rotationDegrees != null),
|
||||
assert(isFlipped != null),
|
||||
assert(dateModifiedSecs != null),
|
||||
assert(extent != null),
|
||||
assert(scale != null);
|
||||
|
||||
// do not store the entry as it is, because the key should be constant
|
||||
// but the entry attributes may change over time
|
||||
factory ThumbnailProviderKey.fromEntry(ImageEntry entry, {int page = 0, double extent = 0}) {
|
||||
return ThumbnailProviderKey(
|
||||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
// `dateModifiedSecs` can be missing in viewer mode
|
||||
dateModifiedSecs: entry.dateModifiedSecs ?? -1,
|
||||
rotationDegrees: entry.rotationDegrees,
|
||||
isFlipped: entry.isFlipped,
|
||||
page: page,
|
||||
extent: extent,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is ThumbnailProviderKey && other.uri == uri && other.extent == extent && other.mimeType == mimeType && other.dateModifiedSecs == dateModifiedSecs && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.page == page && other.scale == scale;
|
||||
return other is ThumbnailProviderKey && other.uri == uri && other.mimeType == mimeType && other.pageId == pageId && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.dateModifiedSecs == dateModifiedSecs && other.extent == extent && other.scale == scale;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(
|
||||
uri,
|
||||
mimeType,
|
||||
dateModifiedSecs,
|
||||
pageId,
|
||||
rotationDegrees,
|
||||
isFlipped,
|
||||
page,
|
||||
dateModifiedSecs,
|
||||
extent,
|
||||
scale,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, dateModifiedSecs=$dateModifiedSecs, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, page=$page, extent=$extent, scale=$scale}';
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, pageId=$pageId, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, dateModifiedSecs=$dateModifiedSecs, extent=$extent, scale=$scale}';
|
||||
}
|
||||
|
|
|
@ -3,19 +3,19 @@ import 'dart:ui' as ui show Codec;
|
|||
|
||||
import 'package:aves/services/image_file_service.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:pedantic/pedantic.dart';
|
||||
|
||||
class UriImage extends ImageProvider<UriImage> {
|
||||
final String uri, mimeType;
|
||||
final int page, rotationDegrees, expectedContentLength;
|
||||
final int pageId, rotationDegrees, expectedContentLength;
|
||||
final bool isFlipped;
|
||||
final double scale;
|
||||
|
||||
const UriImage({
|
||||
@required this.uri,
|
||||
@required this.mimeType,
|
||||
this.page = 0,
|
||||
@required this.pageId,
|
||||
@required this.rotationDegrees,
|
||||
@required this.isFlipped,
|
||||
this.expectedContentLength,
|
||||
|
@ -37,7 +37,7 @@ class UriImage extends ImageProvider<UriImage> {
|
|||
scale: key.scale,
|
||||
chunkEvents: chunkEvents.stream,
|
||||
informationCollector: () sync* {
|
||||
yield ErrorDescription('uri=$uri, mimeType=$mimeType');
|
||||
yield ErrorDescription('uri=$uri, pageId=$pageId, mimeType=$mimeType');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -51,7 +51,7 @@ class UriImage extends ImageProvider<UriImage> {
|
|||
mimeType,
|
||||
rotationDegrees,
|
||||
isFlipped,
|
||||
page: page,
|
||||
pageId: pageId,
|
||||
expectedContentLength: expectedContentLength,
|
||||
onBytesReceived: (cumulative, total) {
|
||||
chunkEvents.add(ImageChunkEvent(
|
||||
|
@ -66,7 +66,7 @@ class UriImage extends ImageProvider<UriImage> {
|
|||
return await decode(bytes);
|
||||
} catch (error) {
|
||||
debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error');
|
||||
throw StateError('$mimeType decoding failed');
|
||||
throw StateError('$mimeType decoding failed (page $pageId)');
|
||||
} finally {
|
||||
unawaited(chunkEvents.close());
|
||||
}
|
||||
|
@ -75,7 +75,7 @@ class UriImage extends ImageProvider<UriImage> {
|
|||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is UriImage && other.uri == uri && other.mimeType == mimeType && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.page == page && other.scale == scale;
|
||||
return other is UriImage && other.uri == uri && other.mimeType == mimeType && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.pageId == pageId && other.scale == scale;
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -84,10 +84,10 @@ class UriImage extends ImageProvider<UriImage> {
|
|||
mimeType,
|
||||
rotationDegrees,
|
||||
isFlipped,
|
||||
page,
|
||||
pageId,
|
||||
scale,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, page=$page, scale=$scale}';
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, pageId=$pageId, scale=$scale}';
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import 'package:aves/services/image_file_service.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:pedantic/pedantic.dart';
|
||||
|
||||
|
@ -30,7 +30,7 @@ class UriPicture extends PictureProvider<UriPicture> {
|
|||
Future<PictureInfo> _loadAsync(UriPicture key, {PictureErrorListener onError}) async {
|
||||
assert(key == this);
|
||||
|
||||
final data = await ImageFileService.getImage(uri, mimeType, 0, false);
|
||||
final data = await ImageFileService.getSvg(uri, mimeType);
|
||||
if (data == null || data.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
|
109
lib/main.dart
109
lib/main.dart
|
@ -2,10 +2,13 @@ import 'dart:isolate';
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/media_store_source.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/debouncer.dart';
|
||||
import 'package:aves/widgets/common/behaviour/route_tracker.dart';
|
||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||
import 'package:aves/widgets/common/providers/settings_provider.dart';
|
||||
import 'package:aves/widgets/home_page.dart';
|
||||
import 'package:aves/widgets/welcome_page.dart';
|
||||
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||
|
@ -16,6 +19,7 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:overlay_support/overlay_support.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
void main() {
|
||||
// HttpClient.enableTimelineLogging = true; // enable network traffic logging
|
||||
|
@ -43,12 +47,16 @@ class AvesApp extends StatefulWidget {
|
|||
|
||||
class _AvesAppState extends State<AvesApp> {
|
||||
Future<void> _appSetup;
|
||||
final _mediaStoreSource = MediaStoreSource();
|
||||
final Debouncer _contentChangeDebouncer = Debouncer(delay: Durations.contentChangeDebounceDelay);
|
||||
final List<String> changedUris = [];
|
||||
|
||||
// observers are not registered when using the same list object with different items
|
||||
// the list itself needs to be reassigned
|
||||
List<NavigatorObserver> _navigatorObservers = [];
|
||||
final _newIntentChannel = EventChannel('deckers.thibault/aves/intent');
|
||||
final _navigatorKey = GlobalKey<NavigatorState>();
|
||||
final EventChannel _contentChangeChannel = EventChannel('deckers.thibault/aves/contentchange');
|
||||
final EventChannel _newIntentChannel = EventChannel('deckers.thibault/aves/intent');
|
||||
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator');
|
||||
|
||||
static const accentColor = Colors.indigoAccent;
|
||||
|
||||
|
@ -94,9 +102,57 @@ class _AvesAppState extends State<AvesApp> {
|
|||
void initState() {
|
||||
super.initState();
|
||||
_appSetup = _setup();
|
||||
_contentChangeChannel.receiveBroadcastStream().listen((event) => _onContentChange(event as String));
|
||||
_newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// place the settings provider above `MaterialApp`
|
||||
// so it can be used during navigation transitions
|
||||
return ChangeNotifierProvider<Settings>.value(
|
||||
value: settings,
|
||||
child: Provider<CollectionSource>.value(
|
||||
value: _mediaStoreSource,
|
||||
child: OverlaySupport(
|
||||
child: FutureBuilder<void>(
|
||||
future: _appSetup,
|
||||
builder: (context, snapshot) {
|
||||
final home = (!snapshot.hasError && snapshot.connectionState == ConnectionState.done)
|
||||
? getFirstPage()
|
||||
: Scaffold(
|
||||
body: snapshot.hasError ? _buildError(snapshot.error) : SizedBox.shrink(),
|
||||
);
|
||||
return MaterialApp(
|
||||
navigatorKey: _navigatorKey,
|
||||
home: home,
|
||||
navigatorObservers: _navigatorObservers,
|
||||
title: 'Aves',
|
||||
darkTheme: darkTheme,
|
||||
themeMode: ThemeMode.dark,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildError(Object error) {
|
||||
return Container(
|
||||
alignment: Alignment.center,
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(AIcons.error),
|
||||
SizedBox(height: 16),
|
||||
Text(error.toString()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _setup() async {
|
||||
await Firebase.initializeApp().then((app) {
|
||||
final crashlytics = FirebaseCrashlytics.instance;
|
||||
|
@ -133,46 +189,11 @@ class _AvesAppState extends State<AvesApp> {
|
|||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// place the settings provider above `MaterialApp`
|
||||
// so it can be used during navigation transitions
|
||||
return SettingsProvider(
|
||||
child: OverlaySupport(
|
||||
child: FutureBuilder<void>(
|
||||
future: _appSetup,
|
||||
builder: (context, snapshot) {
|
||||
final home = (!snapshot.hasError && snapshot.connectionState == ConnectionState.done)
|
||||
? getFirstPage()
|
||||
: Scaffold(
|
||||
body: snapshot.hasError ? _buildError(snapshot.error) : SizedBox.shrink(),
|
||||
);
|
||||
return MaterialApp(
|
||||
navigatorKey: _navigatorKey,
|
||||
home: home,
|
||||
navigatorObservers: _navigatorObservers,
|
||||
title: 'Aves',
|
||||
darkTheme: darkTheme,
|
||||
themeMode: ThemeMode.dark,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildError(Object error) {
|
||||
return Container(
|
||||
alignment: Alignment.center,
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(AIcons.error),
|
||||
SizedBox(height: 16),
|
||||
Text(error.toString()),
|
||||
],
|
||||
),
|
||||
);
|
||||
void _onContentChange(String uri) {
|
||||
changedUris.add(uri);
|
||||
_contentChangeDebouncer(() {
|
||||
_mediaStoreSource.refreshUris(List.of(changedUris));
|
||||
changedUris.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart';
|
|||
enum EntryAction {
|
||||
delete,
|
||||
edit,
|
||||
export,
|
||||
flip,
|
||||
info,
|
||||
open,
|
||||
|
@ -31,6 +32,7 @@ class EntryActions {
|
|||
EntryAction.share,
|
||||
EntryAction.delete,
|
||||
EntryAction.rename,
|
||||
EntryAction.export,
|
||||
EntryAction.print,
|
||||
EntryAction.viewSource,
|
||||
];
|
||||
|
@ -52,6 +54,8 @@ extension ExtraEntryAction on EntryAction {
|
|||
return null;
|
||||
case EntryAction.delete:
|
||||
return 'Delete';
|
||||
case EntryAction.export:
|
||||
return 'Export';
|
||||
case EntryAction.info:
|
||||
return 'Info';
|
||||
case EntryAction.rename:
|
||||
|
@ -91,6 +95,8 @@ extension ExtraEntryAction on EntryAction {
|
|||
return null;
|
||||
case EntryAction.delete:
|
||||
return AIcons.delete;
|
||||
case EntryAction.export:
|
||||
return AIcons.export;
|
||||
case EntryAction.info:
|
||||
return AIcons.info;
|
||||
case EntryAction.rename:
|
||||
|
|
1
lib/model/actions/move_type.dart
Normal file
1
lib/model/actions/move_type.dart
Normal file
|
@ -0,0 +1 @@
|
|||
enum MoveType { copy, move, export }
|
28
lib/model/connectivity.dart
Normal file
28
lib/model/connectivity.dart
Normal file
|
@ -0,0 +1,28 @@
|
|||
import 'package:connectivity/connectivity.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
final AvesConnectivity connectivity = AvesConnectivity._private();
|
||||
|
||||
class AvesConnectivity {
|
||||
bool _isConnected;
|
||||
|
||||
AvesConnectivity._private() {
|
||||
Connectivity().onConnectivityChanged.listen(_updateFromResult);
|
||||
}
|
||||
|
||||
void onResume() => _isConnected = null;
|
||||
|
||||
Future<bool> get isConnected async {
|
||||
if (_isConnected != null) return SynchronousFuture(_isConnected);
|
||||
final result = await (Connectivity().checkConnectivity());
|
||||
_updateFromResult(result);
|
||||
return _isConnected;
|
||||
}
|
||||
|
||||
Future<bool> get canGeolocate => isConnected;
|
||||
|
||||
void _updateFromResult(ConnectivityResult result) {
|
||||
_isConnected = result != ConnectivityResult.none;
|
||||
debugPrint('Device is connected=$_isConnected');
|
||||
}
|
||||
}
|
|
@ -2,7 +2,7 @@ import 'dart:async';
|
|||
|
||||
import 'package:aves/model/entry_cache.dart';
|
||||
import 'package:aves/model/favourite_repo.dart';
|
||||
import 'package:aves/model/image_metadata.dart';
|
||||
import 'package:aves/model/metadata.dart';
|
||||
import 'package:aves/model/metadata_db.dart';
|
||||
import 'package:aves/model/multipage.dart';
|
||||
import 'package:aves/services/image_file_service.dart';
|
||||
|
@ -21,16 +21,18 @@ import 'package:path/path.dart' as ppath;
|
|||
|
||||
import '../ref/mime_types.dart';
|
||||
|
||||
class ImageEntry {
|
||||
class AvesEntry {
|
||||
String uri;
|
||||
String _path, _directory, _filename, _extension;
|
||||
int contentId;
|
||||
int pageId, contentId;
|
||||
final String sourceMimeType;
|
||||
int width;
|
||||
int height;
|
||||
int sourceRotationDegrees;
|
||||
final int sizeBytes;
|
||||
String sourceTitle;
|
||||
|
||||
// `dateModifiedSecs` can be missing in viewer mode
|
||||
int _dateModifiedSecs;
|
||||
final int sourceDateTakenMillis;
|
||||
final int durationMillis;
|
||||
|
@ -43,10 +45,11 @@ class ImageEntry {
|
|||
// TODO TLAD make it dynamic if it depends on OS/lib versions
|
||||
static const List<String> undecodable = [MimeTypes.crw, MimeTypes.psd];
|
||||
|
||||
ImageEntry({
|
||||
AvesEntry({
|
||||
this.uri,
|
||||
String path,
|
||||
this.contentId,
|
||||
this.pageId,
|
||||
this.sourceMimeType,
|
||||
@required this.width,
|
||||
@required this.height,
|
||||
|
@ -66,14 +69,14 @@ class ImageEntry {
|
|||
|
||||
bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType);
|
||||
|
||||
ImageEntry copyWith({
|
||||
AvesEntry copyWith({
|
||||
@required String uri,
|
||||
@required String path,
|
||||
@required int contentId,
|
||||
@required int dateModifiedSecs,
|
||||
}) {
|
||||
final copyContentId = contentId ?? this.contentId;
|
||||
final copied = ImageEntry(
|
||||
final copied = AvesEntry(
|
||||
uri: uri ?? uri,
|
||||
path: path ?? this.path,
|
||||
contentId: copyContentId,
|
||||
|
@ -93,9 +96,39 @@ class ImageEntry {
|
|||
return copied;
|
||||
}
|
||||
|
||||
AvesEntry getPageEntry(SinglePageInfo pageInfo, {bool eraseDefaultPageId = true}) {
|
||||
if (pageInfo == null) return this;
|
||||
|
||||
// do not provide the page ID for the default page,
|
||||
// so that we can treat this page like the main entry
|
||||
// and retrieve cached images for it
|
||||
final pageId = eraseDefaultPageId && pageInfo.isDefault ? null : pageInfo.pageId;
|
||||
|
||||
return AvesEntry(
|
||||
uri: uri,
|
||||
path: path,
|
||||
contentId: contentId,
|
||||
pageId: pageId,
|
||||
sourceMimeType: pageInfo.mimeType ?? sourceMimeType,
|
||||
width: pageInfo.width ?? width,
|
||||
height: pageInfo.height ?? height,
|
||||
sourceRotationDegrees: sourceRotationDegrees,
|
||||
sizeBytes: sizeBytes,
|
||||
sourceTitle: sourceTitle,
|
||||
dateModifiedSecs: dateModifiedSecs,
|
||||
sourceDateTakenMillis: sourceDateTakenMillis,
|
||||
durationMillis: pageInfo.durationMillis ?? durationMillis,
|
||||
)
|
||||
..catalogMetadata = _catalogMetadata?.copyWith(
|
||||
mimeType: pageInfo.mimeType,
|
||||
isMultipage: false,
|
||||
)
|
||||
..addressDetails = _addressDetails?.copyWith();
|
||||
}
|
||||
|
||||
// from DB or platform source entry
|
||||
factory ImageEntry.fromMap(Map map) {
|
||||
return ImageEntry(
|
||||
factory AvesEntry.fromMap(Map map) {
|
||||
return AvesEntry(
|
||||
uri: map['uri'] as String,
|
||||
path: map['path'] as String,
|
||||
contentId: map['contentId'] as int,
|
||||
|
@ -136,7 +169,7 @@ class ImageEntry {
|
|||
}
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, path=$path}';
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, path=$path, pageId=$pageId}';
|
||||
|
||||
set path(String path) {
|
||||
_path = path;
|
||||
|
@ -196,7 +229,11 @@ class ImageEntry {
|
|||
].contains(mimeType) &&
|
||||
!isAnimated;
|
||||
|
||||
bool get canTile => _supportedByBitmapRegionDecoder || mimeType == MimeTypes.tiff;
|
||||
bool get supportTiling => _supportedByBitmapRegionDecoder || mimeType == MimeTypes.tiff;
|
||||
|
||||
// as of panorama v0.3.1, the `Panorama` widget throws on initialization when the image is already resolved
|
||||
// so we use tiles for panoramas as a workaround to not collide with the `panorama` package resolution
|
||||
bool get useTiles => supportTiling && (width > 4096 || height > 4096 || is360);
|
||||
|
||||
bool get isRaw => MimeTypes.rawImages.contains(mimeType);
|
||||
|
||||
|
@ -216,8 +253,6 @@ class ImageEntry {
|
|||
|
||||
bool get canEdit => path != null;
|
||||
|
||||
bool get canPrint => !isVideo;
|
||||
|
||||
bool get canRotateAndFlip => canEdit && canEditExif;
|
||||
|
||||
// support for writing EXIF
|
||||
|
@ -233,29 +268,21 @@ class ImageEntry {
|
|||
}
|
||||
}
|
||||
|
||||
// The additional comparison of width to height is a workaround for badly registered entries.
|
||||
// e.g. a portrait FHD video should be registered as width=1920, height=1080, orientation=90,
|
||||
// but is incorrectly registered in the Media Store as width=1080, height=1920, orientation=0
|
||||
// Double-checking the width/height during loading or cataloguing is the proper solution,
|
||||
// but it would take space and time, so a basic workaround will do.
|
||||
bool get isPortrait => rotationDegrees % 180 == 90 && (catalogMetadata?.rotationDegrees == null || width > height);
|
||||
// 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,
|
||||
// but is incorrectly registered as width=1080, height=1920, orientation=0.
|
||||
// Double-checking the width/height during loading or cataloguing is the proper solution, but it would take space and time.
|
||||
// Comparing width and height can help with the portrait FHD video example,
|
||||
// but it fails for a portrait screenshot rotated, which is landscape with width=1080, height=1920, orientation=90
|
||||
bool get isRotated => rotationDegrees % 180 == 90;
|
||||
|
||||
static const ratioSeparator = '\u2236';
|
||||
static const resolutionSeparator = ' \u00D7 ';
|
||||
|
||||
String getResolutionText({MultiPageInfo multiPageInfo, int page}) {
|
||||
int w;
|
||||
int h;
|
||||
if (multiPageInfo != null && page != null) {
|
||||
final pageInfo = multiPageInfo.pages[page];
|
||||
w = pageInfo?.width;
|
||||
h = pageInfo?.height;
|
||||
}
|
||||
w ??= width;
|
||||
h ??= height;
|
||||
final ws = w ?? '?';
|
||||
final hs = h ?? '?';
|
||||
return isPortrait ? '$hs$resolutionSeparator$ws' : '$ws$resolutionSeparator$hs';
|
||||
String get resolutionText {
|
||||
final ws = width ?? '?';
|
||||
final hs = height ?? '?';
|
||||
return isRotated ? '$hs$resolutionSeparator$ws' : '$ws$resolutionSeparator$hs';
|
||||
}
|
||||
|
||||
String get aspectRatioText {
|
||||
|
@ -263,7 +290,7 @@ class ImageEntry {
|
|||
final gcd = width.gcd(height);
|
||||
final w = width ~/ gcd;
|
||||
final h = height ~/ gcd;
|
||||
return isPortrait ? '$h$ratioSeparator$w' : '$w$ratioSeparator$h';
|
||||
return isRotated ? '$h$ratioSeparator$w' : '$w$ratioSeparator$h';
|
||||
} else {
|
||||
return '?$ratioSeparator?';
|
||||
}
|
||||
|
@ -271,20 +298,13 @@ class ImageEntry {
|
|||
|
||||
double get displayAspectRatio {
|
||||
if (width == 0 || height == 0) return 1;
|
||||
return isPortrait ? height / width : width / height;
|
||||
return isRotated ? height / width : width / height;
|
||||
}
|
||||
|
||||
Size getDisplaySize({MultiPageInfo multiPageInfo, int page}) {
|
||||
int w;
|
||||
int h;
|
||||
if (multiPageInfo != null && page != null) {
|
||||
final pageInfo = multiPageInfo.pages[page];
|
||||
w = pageInfo?.width;
|
||||
h = pageInfo?.height;
|
||||
}
|
||||
w ??= width;
|
||||
h ??= height;
|
||||
return isPortrait ? Size(h.toDouble(), w.toDouble()) : Size(w.toDouble(), h.toDouble());
|
||||
Size get displaySize {
|
||||
final w = width.toDouble();
|
||||
final h = height.toDouble();
|
||||
return isRotated ? Size(h, w) : Size(w, h);
|
||||
}
|
||||
|
||||
int get megaPixels => width != null && height != null ? (width * height / 1000000).round() : null;
|
||||
|
@ -598,7 +618,7 @@ class ImageEntry {
|
|||
// compare by:
|
||||
// 1) title ascending
|
||||
// 2) extension ascending
|
||||
static int compareByName(ImageEntry a, ImageEntry b) {
|
||||
static int compareByName(AvesEntry a, AvesEntry b) {
|
||||
final c = compareAsciiUpperCase(a.bestTitle, b.bestTitle);
|
||||
return c != 0 ? c : compareAsciiUpperCase(a.extension, b.extension);
|
||||
}
|
||||
|
@ -606,7 +626,7 @@ class ImageEntry {
|
|||
// compare by:
|
||||
// 1) size descending
|
||||
// 2) name ascending
|
||||
static int compareBySize(ImageEntry a, ImageEntry b) {
|
||||
static int compareBySize(AvesEntry a, AvesEntry b) {
|
||||
final c = b.sizeBytes.compareTo(a.sizeBytes);
|
||||
return c != 0 ? c : compareByName(a, b);
|
||||
}
|
||||
|
@ -615,9 +635,12 @@ class ImageEntry {
|
|||
|
||||
// compare by:
|
||||
// 1) date descending
|
||||
// 2) name ascending
|
||||
static int compareByDate(ImageEntry a, ImageEntry b) {
|
||||
final c = (b.bestDate ?? _epoch).compareTo(a.bestDate ?? _epoch);
|
||||
return c != 0 ? c : compareByName(a, b);
|
||||
// 2) name descending
|
||||
static int compareByDate(AvesEntry a, AvesEntry b) {
|
||||
var c = (b.bestDate ?? _epoch).compareTo(a.bestDate ?? _epoch);
|
||||
if (c != 0) return c;
|
||||
c = (b.dateModifiedSecs ?? 0).compareTo(a.dateModifiedSecs ?? 0);
|
||||
if (c != 0) return c;
|
||||
return -compareByName(a, b);
|
||||
}
|
||||
}
|
|
@ -12,14 +12,14 @@ class EntryCache {
|
|||
int oldRotationDegrees,
|
||||
bool oldIsFlipped,
|
||||
) async {
|
||||
// TODO TLAD revisit this for multipage items, if someday image editing features are added for them
|
||||
const page = 0;
|
||||
// TODO TLAD provide pageId parameter for multipage items, if someday image editing features are added for them
|
||||
int pageId;
|
||||
|
||||
// evict fullscreen image
|
||||
await UriImage(
|
||||
uri: uri,
|
||||
mimeType: mimeType,
|
||||
page: page,
|
||||
pageId: pageId,
|
||||
rotationDegrees: oldRotationDegrees,
|
||||
isFlipped: oldIsFlipped,
|
||||
).evict();
|
||||
|
@ -28,10 +28,10 @@ class EntryCache {
|
|||
await ThumbnailProvider(ThumbnailProviderKey(
|
||||
uri: uri,
|
||||
mimeType: mimeType,
|
||||
pageId: pageId,
|
||||
dateModifiedSecs: dateModifiedSecs,
|
||||
rotationDegrees: oldRotationDegrees,
|
||||
isFlipped: oldIsFlipped,
|
||||
page: page,
|
||||
)).evict();
|
||||
|
||||
// evict higher quality thumbnails (with powers of 2 from 32 to 1024 as specified extents)
|
||||
|
@ -41,10 +41,10 @@ class EntryCache {
|
|||
(extent) => ThumbnailProvider(ThumbnailProviderKey(
|
||||
uri: uri,
|
||||
mimeType: mimeType,
|
||||
pageId: pageId,
|
||||
dateModifiedSecs: dateModifiedSecs,
|
||||
rotationDegrees: oldRotationDegrees,
|
||||
isFlipped: oldIsFlipped,
|
||||
page: page,
|
||||
extent: extent,
|
||||
)).evict());
|
||||
}
|
||||
|
|
67
lib/model/entry_images.dart
Normal file
67
lib/model/entry_images.dart
Normal file
|
@ -0,0 +1,67 @@
|
|||
import 'dart:math';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/image_providers/region_provider.dart';
|
||||
import 'package:aves/image_providers/thumbnail_provider.dart';
|
||||
import 'package:aves/image_providers/uri_image_provider.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
extension ExtraAvesEntry on AvesEntry {
|
||||
ThumbnailProvider getThumbnail({double extent = 0}) {
|
||||
return ThumbnailProvider(_getThumbnailProviderKey(extent));
|
||||
}
|
||||
|
||||
ThumbnailProviderKey _getThumbnailProviderKey(double extent) {
|
||||
// we standardize the thumbnail loading dimension by taking the nearest larger power of 2
|
||||
// so that there are less variants of the thumbnails to load and cache
|
||||
// it increases the chance of cache hit when loading similarly sized columns (e.g. on orientation change)
|
||||
final requestExtent = extent == 0 ? .0 : pow(2, (log(extent) / log(2)).ceil()).toDouble();
|
||||
|
||||
return ThumbnailProviderKey(
|
||||
uri: uri,
|
||||
mimeType: mimeType,
|
||||
pageId: pageId,
|
||||
rotationDegrees: rotationDegrees,
|
||||
isFlipped: isFlipped,
|
||||
dateModifiedSecs: dateModifiedSecs ?? -1,
|
||||
extent: requestExtent,
|
||||
);
|
||||
}
|
||||
|
||||
RegionProvider getRegion({@required int sampleSize, Rectangle<int> region}) {
|
||||
return RegionProvider(_getRegionProviderKey(sampleSize, region));
|
||||
}
|
||||
|
||||
RegionProviderKey _getRegionProviderKey(int sampleSize, Rectangle<int> region) {
|
||||
return RegionProviderKey(
|
||||
uri: uri,
|
||||
mimeType: mimeType,
|
||||
pageId: pageId,
|
||||
rotationDegrees: rotationDegrees,
|
||||
isFlipped: isFlipped,
|
||||
sampleSize: sampleSize,
|
||||
region: region ?? Rectangle<int>(0, 0, width, height),
|
||||
imageSize: Size(width.toDouble(), height.toDouble()),
|
||||
);
|
||||
}
|
||||
|
||||
UriImage get uriImage => UriImage(
|
||||
uri: uri,
|
||||
mimeType: mimeType,
|
||||
pageId: pageId,
|
||||
rotationDegrees: rotationDegrees,
|
||||
isFlipped: isFlipped,
|
||||
expectedContentLength: sizeBytes,
|
||||
);
|
||||
|
||||
bool _isReady(Object providerKey) => imageCache.statusForKey(providerKey).keepAlive;
|
||||
|
||||
ImageProvider getBestThumbnail(double extent) {
|
||||
final sizedThumbnailKey = _getThumbnailProviderKey(extent);
|
||||
if (_isReady(sizedThumbnailKey)) return ThumbnailProvider(sizedThumbnailKey);
|
||||
|
||||
return getThumbnail();
|
||||
}
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/image_metadata.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/metadata.dart';
|
||||
import 'package:aves/model/metadata_db.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
|
||||
|
@ -18,25 +18,25 @@ class FavouriteRepo {
|
|||
|
||||
int get count => _rows.length;
|
||||
|
||||
bool isFavourite(ImageEntry entry) => _rows.any((row) => row.contentId == entry.contentId);
|
||||
bool isFavourite(AvesEntry entry) => _rows.any((row) => row.contentId == entry.contentId);
|
||||
|
||||
FavouriteRow _entryToRow(ImageEntry entry) => FavouriteRow(contentId: entry.contentId, path: entry.path);
|
||||
FavouriteRow _entryToRow(AvesEntry entry) => FavouriteRow(contentId: entry.contentId, path: entry.path);
|
||||
|
||||
Future<void> add(Iterable<ImageEntry> entries) async {
|
||||
Future<void> add(Iterable<AvesEntry> entries) async {
|
||||
final newRows = entries.map(_entryToRow);
|
||||
await metadataDb.addFavourites(newRows);
|
||||
_rows.addAll(newRows);
|
||||
changeNotifier.notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> remove(Iterable<ImageEntry> entries) async {
|
||||
Future<void> remove(Iterable<AvesEntry> entries) async {
|
||||
final removedRows = entries.map(_entryToRow);
|
||||
await metadataDb.removeFavourites(removedRows);
|
||||
removedRows.forEach(_rows.remove);
|
||||
changeNotifier.notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> move(int oldContentId, ImageEntry entry) async {
|
||||
Future<void> move(int oldContentId, AvesEntry entry) async {
|
||||
final oldRow = _rows.firstWhere((row) => row.contentId == oldContentId, orElse: () => null);
|
||||
if (oldRow != null) {
|
||||
_rows.remove(oldRow);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import 'package:aves/image_providers/app_icon_image_provider.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_icons.dart';
|
||||
|
@ -33,7 +33,7 @@ class AlbumFilter extends CollectionFilter {
|
|||
};
|
||||
|
||||
@override
|
||||
bool filter(ImageEntry entry) => entry.directory == album;
|
||||
bool filter(AvesEntry entry) => entry.directory == album;
|
||||
|
||||
@override
|
||||
String get label => uniqueName ?? album.split(separator).last;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
@ -13,7 +13,7 @@ class FavouriteFilter extends CollectionFilter {
|
|||
};
|
||||
|
||||
@override
|
||||
bool filter(ImageEntry entry) => entry.isFavourite;
|
||||
bool filter(AvesEntry entry) => entry.isFavourite;
|
||||
|
||||
@override
|
||||
String get label => 'Favourite';
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/filters/favourite.dart';
|
||||
import 'package:aves/model/filters/location.dart';
|
||||
import 'package:aves/model/filters/mime.dart';
|
||||
import 'package:aves/model/filters/query.dart';
|
||||
import 'package:aves/model/filters/tag.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/utils/color_utils.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
@ -49,7 +49,7 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
|
|||
|
||||
String toJson() => jsonEncode(toMap());
|
||||
|
||||
bool filter(ImageEntry entry);
|
||||
bool filter(AvesEntry entry);
|
||||
|
||||
bool get isUnique => true;
|
||||
|
||||
|
@ -78,7 +78,7 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
|
|||
// TODO TLAD replace this by adding getters to CollectionFilter, with cached entry/count coming from Source
|
||||
class FilterGridItem<T extends CollectionFilter> {
|
||||
final T filter;
|
||||
final ImageEntry entry;
|
||||
final AvesEntry entry;
|
||||
|
||||
const FilterGridItem(this.filter, this.entry);
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
@ -35,7 +35,7 @@ class LocationFilter extends CollectionFilter {
|
|||
String get countryNameAndCode => '$_location$locationSeparator$_countryCode';
|
||||
|
||||
@override
|
||||
bool filter(ImageEntry entry) => _location.isEmpty ? !entry.isLocated : entry.isLocated && ((level == LocationLevel.country && entry.addressDetails.countryCode == _countryCode) || (level == LocationLevel.place && entry.addressDetails.place == _location));
|
||||
bool filter(AvesEntry entry) => _location.isEmpty ? !entry.isLocated : entry.isLocated && ((level == LocationLevel.country && entry.addressDetails.countryCode == _countryCode) || (level == LocationLevel.place && entry.addressDetails.place == _location));
|
||||
|
||||
@override
|
||||
String get label => _location.isEmpty ? emptyLabel : _location;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/mime_utils.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
@ -15,7 +15,7 @@ class MimeFilter extends CollectionFilter {
|
|||
static const geotiff = 'aves/geotiff'; // subset of `image/tiff`
|
||||
|
||||
final String mime;
|
||||
bool Function(ImageEntry) _filter;
|
||||
bool Function(AvesEntry) _filter;
|
||||
String _label;
|
||||
IconData _icon;
|
||||
|
||||
|
@ -67,7 +67,7 @@ class MimeFilter extends CollectionFilter {
|
|||
};
|
||||
|
||||
@override
|
||||
bool filter(ImageEntry entry) => _filter(entry);
|
||||
bool filter(AvesEntry entry) => _filter(entry);
|
||||
|
||||
@override
|
||||
String get label => _label;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -12,7 +12,7 @@ class QueryFilter extends CollectionFilter {
|
|||
|
||||
final String query;
|
||||
final bool colorful;
|
||||
bool Function(ImageEntry) _filter;
|
||||
bool Function(AvesEntry) _filter;
|
||||
|
||||
QueryFilter(this.query, {this.colorful = true}) {
|
||||
var upQuery = query.toUpperCase();
|
||||
|
@ -44,7 +44,7 @@ class QueryFilter extends CollectionFilter {
|
|||
};
|
||||
|
||||
@override
|
||||
bool filter(ImageEntry entry) => _filter(entry);
|
||||
bool filter(AvesEntry entry) => _filter(entry);
|
||||
|
||||
@override
|
||||
bool get isUnique => false;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
@ -24,7 +24,7 @@ class TagFilter extends CollectionFilter {
|
|||
};
|
||||
|
||||
@override
|
||||
bool filter(ImageEntry entry) => tag.isEmpty ? entry.xmpSubjects.isEmpty : entry.xmpSubjects.contains(tag);
|
||||
bool filter(AvesEntry entry) => tag.isEmpty ? entry.xmpSubjects.isEmpty : entry.xmpSubjects.contains(tag);
|
||||
|
||||
@override
|
||||
bool get isUnique => false;
|
||||
|
|
|
@ -68,17 +68,19 @@ class CatalogMetadata {
|
|||
}
|
||||
|
||||
CatalogMetadata copyWith({
|
||||
@required int contentId,
|
||||
int contentId,
|
||||
String mimeType,
|
||||
bool isMultipage,
|
||||
}) {
|
||||
return CatalogMetadata(
|
||||
contentId: contentId ?? this.contentId,
|
||||
mimeType: mimeType,
|
||||
mimeType: mimeType ?? this.mimeType,
|
||||
dateMillis: dateMillis,
|
||||
isAnimated: isAnimated,
|
||||
isFlipped: isFlipped,
|
||||
isGeotiff: isGeotiff,
|
||||
is360: is360,
|
||||
isMultipage: isMultipage,
|
||||
isMultipage: isMultipage ?? this.isMultipage,
|
||||
rotationDegrees: rotationDegrees,
|
||||
xmpSubjects: xmpSubjects,
|
||||
xmpTitleDescription: xmpTitleDescription,
|
||||
|
@ -169,7 +171,7 @@ class AddressDetails {
|
|||
});
|
||||
|
||||
AddressDetails copyWith({
|
||||
@required int contentId,
|
||||
int contentId,
|
||||
}) {
|
||||
return AddressDetails(
|
||||
contentId: contentId ?? this.contentId,
|
|
@ -1,7 +1,7 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/image_metadata.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/metadata.dart';
|
||||
import 'package:aves/model/metadata_db_upgrade.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path/path.dart';
|
||||
|
@ -116,16 +116,16 @@ class MetadataDb {
|
|||
debugPrint('$runtimeType clearEntries deleted $count entries');
|
||||
}
|
||||
|
||||
Future<List<ImageEntry>> loadEntries() async {
|
||||
Future<List<AvesEntry>> loadEntries() async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final db = await _database;
|
||||
final maps = await db.query(entryTable);
|
||||
final entries = maps.map((map) => ImageEntry.fromMap(map)).toList();
|
||||
final entries = maps.map((map) => AvesEntry.fromMap(map)).toList();
|
||||
debugPrint('$runtimeType loadEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries');
|
||||
return entries;
|
||||
}
|
||||
|
||||
Future<void> saveEntries(Iterable<ImageEntry> entries) async {
|
||||
Future<void> saveEntries(Iterable<AvesEntry> entries) async {
|
||||
if (entries == null || entries.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final db = await _database;
|
||||
|
@ -135,7 +135,7 @@ class MetadataDb {
|
|||
debugPrint('$runtimeType saveEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries');
|
||||
}
|
||||
|
||||
Future<void> updateEntryId(int oldId, ImageEntry entry) async {
|
||||
Future<void> updateEntryId(int oldId, AvesEntry entry) async {
|
||||
final db = await _database;
|
||||
final batch = db.batch();
|
||||
batch.delete(entryTable, where: 'contentId = ?', whereArgs: [oldId]);
|
||||
|
@ -143,7 +143,7 @@ class MetadataDb {
|
|||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
void _batchInsertEntry(Batch batch, ImageEntry entry) {
|
||||
void _batchInsertEntry(Batch batch, AvesEntry entry) {
|
||||
if (entry == null) return;
|
||||
batch.insert(
|
||||
entryTable,
|
||||
|
|
|
@ -1,42 +1,83 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class SinglePageInfo {
|
||||
final int width, height;
|
||||
|
||||
SinglePageInfo({
|
||||
this.width,
|
||||
this.height,
|
||||
});
|
||||
|
||||
factory SinglePageInfo.fromMap(Map map) {
|
||||
return SinglePageInfo(
|
||||
width: map['width'] as int,
|
||||
height: map['height'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{width=$width, height=$height}';
|
||||
}
|
||||
|
||||
class MultiPageInfo {
|
||||
final Map<int, SinglePageInfo> pages;
|
||||
final List<SinglePageInfo> pages;
|
||||
|
||||
int get pageCount => pages.length;
|
||||
|
||||
MultiPageInfo({
|
||||
this.pages,
|
||||
});
|
||||
|
||||
factory MultiPageInfo.fromMap(Map map) {
|
||||
final pages = <int, SinglePageInfo>{};
|
||||
map.keys.forEach((key) {
|
||||
final index = key as int;
|
||||
pages.putIfAbsent(index, () => SinglePageInfo.fromMap(map[key]));
|
||||
});
|
||||
return MultiPageInfo(pages: pages);
|
||||
}) {
|
||||
if (pages.isNotEmpty) {
|
||||
pages.sort();
|
||||
// make sure there is a page marked as default
|
||||
if (defaultPage == null) {
|
||||
final firstPage = pages.removeAt(0);
|
||||
pages.insert(0, firstPage.copyWith(isDefault: true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
factory MultiPageInfo.fromPageMaps(List<Map> pageMaps) {
|
||||
return MultiPageInfo(pages: pageMaps.map((page) => SinglePageInfo.fromMap(page)).toList());
|
||||
}
|
||||
|
||||
SinglePageInfo get defaultPage => pages.firstWhere((page) => page.isDefault, orElse: () => null);
|
||||
|
||||
SinglePageInfo getByIndex(int index) => pages.firstWhere((page) => page.index == index, orElse: () => null);
|
||||
|
||||
SinglePageInfo getById(int pageId) => pages.firstWhere((page) => page.pageId == pageId, orElse: () => null);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{pages=$pages}';
|
||||
}
|
||||
|
||||
class SinglePageInfo implements Comparable<SinglePageInfo> {
|
||||
final int index, pageId;
|
||||
final String mimeType;
|
||||
final bool isDefault;
|
||||
final int width, height, durationMillis;
|
||||
|
||||
const SinglePageInfo({
|
||||
this.index,
|
||||
this.pageId,
|
||||
this.mimeType,
|
||||
this.isDefault,
|
||||
this.width,
|
||||
this.height,
|
||||
this.durationMillis,
|
||||
});
|
||||
|
||||
SinglePageInfo copyWith({
|
||||
bool isDefault,
|
||||
}) {
|
||||
return SinglePageInfo(
|
||||
index: index,
|
||||
pageId: pageId,
|
||||
mimeType: mimeType,
|
||||
isDefault: isDefault ?? this.isDefault,
|
||||
width: width,
|
||||
height: height,
|
||||
durationMillis: durationMillis,
|
||||
);
|
||||
}
|
||||
|
||||
factory SinglePageInfo.fromMap(Map map) {
|
||||
final index = map['page'] as int;
|
||||
return SinglePageInfo(
|
||||
index: index,
|
||||
pageId: map['trackId'] as int ?? index,
|
||||
mimeType: map['mimeType'] as String,
|
||||
isDefault: map['isDefault'] as bool ?? false,
|
||||
width: map['width'] as int ?? 0,
|
||||
height: map['height'] as int ?? 0,
|
||||
durationMillis: map['durationMillis'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{index=$index, pageId=$pageId, mimeType=$mimeType, isDefault=$isDefault, width=$width, height=$height, durationMillis=$durationMillis}';
|
||||
|
||||
@override
|
||||
int compareTo(SinglePageInfo other) => index.compareTo(other.index);
|
||||
}
|
||||
|
|
|
@ -4,24 +4,38 @@ import 'package:flutter/widgets.dart';
|
|||
class PanoramaInfo {
|
||||
final Rect croppedAreaRect;
|
||||
final Size fullPanoSize;
|
||||
final String projectionType;
|
||||
|
||||
PanoramaInfo({
|
||||
this.croppedAreaRect,
|
||||
this.fullPanoSize,
|
||||
this.projectionType,
|
||||
});
|
||||
|
||||
factory PanoramaInfo.fromMap(Map map) {
|
||||
final cLeft = map['croppedAreaLeft'] as int;
|
||||
final cTop = map['croppedAreaTop'] as int;
|
||||
var cLeft = map['croppedAreaLeft'] as int;
|
||||
var cTop = map['croppedAreaTop'] as int;
|
||||
final cWidth = map['croppedAreaWidth'] as int;
|
||||
final cHeight = map['croppedAreaHeight'] as int;
|
||||
var fWidth = map['fullPanoWidth'] as int;
|
||||
var fHeight = map['fullPanoHeight'] as int;
|
||||
final projectionType = map['projectionType'] as String;
|
||||
|
||||
// handle missing `fullPanoHeight` (e.g. Samsung camera app panorama mode)
|
||||
if (fHeight == null && cWidth != null && cHeight != null) {
|
||||
// assume the cropped area is actually covering 360 degrees horizontally
|
||||
// even when `croppedAreaLeft` is non zero
|
||||
fWidth = cWidth;
|
||||
fHeight = (fWidth / 2).round();
|
||||
cTop = ((fHeight - cHeight) / 2).round();
|
||||
cLeft = 0;
|
||||
}
|
||||
|
||||
Rect croppedAreaRect;
|
||||
if (cLeft != null && cTop != null && cWidth != null && cHeight != null) {
|
||||
croppedAreaRect = Rect.fromLTWH(cLeft.toDouble(), cTop.toDouble(), cWidth.toDouble(), cHeight.toDouble());
|
||||
}
|
||||
|
||||
final fWidth = map['fullPanoWidth'] as int;
|
||||
final fHeight = map['fullPanoHeight'] as int;
|
||||
Size fullPanoSize;
|
||||
if (fWidth != null && fHeight != null) {
|
||||
fullPanoSize = Size(fWidth.toDouble(), fHeight.toDouble());
|
||||
|
@ -30,11 +44,12 @@ class PanoramaInfo {
|
|||
return PanoramaInfo(
|
||||
croppedAreaRect: croppedAreaRect,
|
||||
fullPanoSize: fullPanoSize,
|
||||
projectionType: projectionType,
|
||||
);
|
||||
}
|
||||
|
||||
bool get hasCroppedArea => croppedAreaRect != null && fullPanoSize != null;
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{croppedAreaRect=$croppedAreaRect, fullPanoSize=$fullPanoSize}';
|
||||
String toString() => '$runtimeType#${shortHash(this)}{croppedAreaRect=$croppedAreaRect, fullPanoSize=$fullPanoSize, projectionType=$projectionType}';
|
||||
}
|
||||
|
|
34
lib/model/settings/map_style.dart
Normal file
34
lib/model/settings/map_style.dart
Normal file
|
@ -0,0 +1,34 @@
|
|||
// browse providers at https://leaflet-extras.github.io/leaflet-providers/preview/
|
||||
enum EntryMapStyle { googleNormal, googleHybrid, googleTerrain, osmHot, stamenToner, stamenWatercolor }
|
||||
|
||||
extension ExtraEntryMapStyle on EntryMapStyle {
|
||||
String get name {
|
||||
switch (this) {
|
||||
case EntryMapStyle.googleNormal:
|
||||
return 'Google Maps';
|
||||
case EntryMapStyle.googleHybrid:
|
||||
return 'Google Maps (Hybrid)';
|
||||
case EntryMapStyle.googleTerrain:
|
||||
return 'Google Maps (Terrain)';
|
||||
case EntryMapStyle.osmHot:
|
||||
return 'Humanitarian OSM';
|
||||
case EntryMapStyle.stamenToner:
|
||||
return 'Stamen Toner';
|
||||
case EntryMapStyle.stamenWatercolor:
|
||||
return 'Stamen Watercolor';
|
||||
default:
|
||||
return toString();
|
||||
}
|
||||
}
|
||||
|
||||
bool get isGoogleMaps {
|
||||
switch (this) {
|
||||
case EntryMapStyle.googleNormal:
|
||||
case EntryMapStyle.googleHybrid:
|
||||
case EntryMapStyle.googleTerrain:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,8 +2,8 @@ import 'package:aves/model/filters/filters.dart';
|
|||
import 'package:aves/model/settings/coordinate_format.dart';
|
||||
import 'package:aves/model/settings/entry_background.dart';
|
||||
import 'package:aves/model/settings/home_page.dart';
|
||||
import 'package:aves/model/settings/map_style.dart';
|
||||
import 'package:aves/model/settings/screen_on.dart';
|
||||
import 'package:aves/widgets/viewer/info/location_section.dart';
|
||||
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
|
@ -50,10 +50,10 @@ mixin AlbumMixin on SourceBase {
|
|||
}
|
||||
}
|
||||
|
||||
Map<String, ImageEntry> getAlbumEntries() {
|
||||
Map<String, AvesEntry> getAlbumEntries() {
|
||||
final entries = sortedEntriesForFilterList;
|
||||
final regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[];
|
||||
for (var album in sortedAlbums) {
|
||||
for (final album in sortedAlbums) {
|
||||
switch (androidFileUtils.getAlbumType(album)) {
|
||||
case AlbumType.regular:
|
||||
regularAlbums.add(album);
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/section_keys.dart';
|
||||
import 'package:aves/model/source/tag.dart';
|
||||
|
@ -19,24 +19,28 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
|||
EntryGroupFactor groupFactor;
|
||||
EntrySortFactor sortFactor;
|
||||
final AChangeNotifier filterChangeNotifier = AChangeNotifier();
|
||||
bool listenToSource;
|
||||
|
||||
List<ImageEntry> _filteredEntries;
|
||||
List<AvesEntry> _filteredEntries;
|
||||
List<StreamSubscription> _subscriptions = [];
|
||||
|
||||
Map<SectionKey, List<ImageEntry>> sections = Map.unmodifiable({});
|
||||
Map<SectionKey, List<AvesEntry>> sections = Map.unmodifiable({});
|
||||
|
||||
CollectionLens({
|
||||
@required this.source,
|
||||
Iterable<CollectionFilter> filters,
|
||||
@required EntryGroupFactor groupFactor,
|
||||
@required EntrySortFactor sortFactor,
|
||||
this.listenToSource = true,
|
||||
}) : filters = {if (filters != null) ...filters.where((f) => f != null)},
|
||||
groupFactor = groupFactor ?? EntryGroupFactor.month,
|
||||
sortFactor = sortFactor ?? EntrySortFactor.date {
|
||||
_subscriptions.add(source.eventBus.on<EntryAddedEvent>().listen((e) => _refresh()));
|
||||
_subscriptions.add(source.eventBus.on<EntryRemovedEvent>().listen((e) => onEntryRemoved(e.entries)));
|
||||
_subscriptions.add(source.eventBus.on<EntryMovedEvent>().listen((e) => _refresh()));
|
||||
_subscriptions.add(source.eventBus.on<CatalogMetadataChangedEvent>().listen((e) => _refresh()));
|
||||
if (listenToSource) {
|
||||
_subscriptions.add(source.eventBus.on<EntryAddedEvent>().listen((e) => _refresh()));
|
||||
_subscriptions.add(source.eventBus.on<EntryRemovedEvent>().listen((e) => onEntryRemoved(e.entries)));
|
||||
_subscriptions.add(source.eventBus.on<EntryMovedEvent>().listen((e) => _refresh()));
|
||||
_subscriptions.add(source.eventBus.on<CatalogMetadataChangedEvent>().listen((e) => _refresh()));
|
||||
}
|
||||
_refresh();
|
||||
}
|
||||
|
||||
|
@ -49,23 +53,14 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
CollectionLens derive(CollectionFilter filter) {
|
||||
return CollectionLens(
|
||||
source: source,
|
||||
filters: filters,
|
||||
groupFactor: groupFactor,
|
||||
sortFactor: sortFactor,
|
||||
)..addFilter(filter);
|
||||
}
|
||||
|
||||
bool get isEmpty => _filteredEntries.isEmpty;
|
||||
|
||||
int get entryCount => _filteredEntries.length;
|
||||
|
||||
// sorted as displayed to the user, i.e. sorted then grouped, not an absolute order on all entries
|
||||
List<ImageEntry> _sortedEntries;
|
||||
List<AvesEntry> _sortedEntries;
|
||||
|
||||
List<ImageEntry> get sortedEntries {
|
||||
List<AvesEntry> get sortedEntries {
|
||||
_sortedEntries ??= List.of(sections.entries.expand((e) => e.value));
|
||||
return _sortedEntries;
|
||||
}
|
||||
|
@ -82,7 +77,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
|||
return true;
|
||||
}
|
||||
|
||||
Object heroTag(ImageEntry entry) => '$hashCode${entry.uri}';
|
||||
Object heroTag(AvesEntry entry) => entry.uri;
|
||||
|
||||
void addFilter(CollectionFilter filter) {
|
||||
if (filter == null || filters.contains(filter)) return;
|
||||
|
@ -123,13 +118,13 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
|||
void _applySort() {
|
||||
switch (sortFactor) {
|
||||
case EntrySortFactor.date:
|
||||
_filteredEntries.sort(ImageEntry.compareByDate);
|
||||
_filteredEntries.sort(AvesEntry.compareByDate);
|
||||
break;
|
||||
case EntrySortFactor.size:
|
||||
_filteredEntries.sort(ImageEntry.compareBySize);
|
||||
_filteredEntries.sort(AvesEntry.compareBySize);
|
||||
break;
|
||||
case EntrySortFactor.name:
|
||||
_filteredEntries.sort(ImageEntry.compareByName);
|
||||
_filteredEntries.sort(AvesEntry.compareByName);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -139,13 +134,13 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
|||
case EntrySortFactor.date:
|
||||
switch (groupFactor) {
|
||||
case EntryGroupFactor.album:
|
||||
sections = groupBy<ImageEntry, EntryAlbumSectionKey>(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory));
|
||||
sections = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory));
|
||||
break;
|
||||
case EntryGroupFactor.month:
|
||||
sections = groupBy<ImageEntry, EntryDateSectionKey>(_filteredEntries, (entry) => EntryDateSectionKey(entry.monthTaken));
|
||||
sections = groupBy<AvesEntry, EntryDateSectionKey>(_filteredEntries, (entry) => EntryDateSectionKey(entry.monthTaken));
|
||||
break;
|
||||
case EntryGroupFactor.day:
|
||||
sections = groupBy<ImageEntry, EntryDateSectionKey>(_filteredEntries, (entry) => EntryDateSectionKey(entry.dayTaken));
|
||||
sections = groupBy<AvesEntry, EntryDateSectionKey>(_filteredEntries, (entry) => EntryDateSectionKey(entry.dayTaken));
|
||||
break;
|
||||
case EntryGroupFactor.none:
|
||||
sections = Map.fromEntries([
|
||||
|
@ -160,8 +155,8 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
|||
]);
|
||||
break;
|
||||
case EntrySortFactor.name:
|
||||
final byAlbum = groupBy<ImageEntry, EntryAlbumSectionKey>(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory));
|
||||
sections = SplayTreeMap<EntryAlbumSectionKey, List<ImageEntry>>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.folderPath, b.folderPath));
|
||||
final byAlbum = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory));
|
||||
sections = SplayTreeMap<EntryAlbumSectionKey, List<AvesEntry>>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.folderPath, b.folderPath));
|
||||
break;
|
||||
}
|
||||
sections = Map.unmodifiable(sections);
|
||||
|
@ -177,7 +172,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
|||
_applyGroup();
|
||||
}
|
||||
|
||||
void onEntryRemoved(Iterable<ImageEntry> entries) {
|
||||
void onEntryRemoved(Iterable<AvesEntry> entries) {
|
||||
// we should remove obsolete entries and sections
|
||||
// but do not apply sort/group
|
||||
// as section order change would surprise the user while browsing
|
||||
|
@ -207,18 +202,18 @@ mixin CollectionActivityMixin {
|
|||
mixin CollectionSelectionMixin on CollectionActivityMixin {
|
||||
final AChangeNotifier selectionChangeNotifier = AChangeNotifier();
|
||||
|
||||
final Set<ImageEntry> _selection = {};
|
||||
final Set<AvesEntry> _selection = {};
|
||||
|
||||
Set<ImageEntry> get selection => _selection;
|
||||
Set<AvesEntry> get selection => _selection;
|
||||
|
||||
bool isSelected(Iterable<ImageEntry> entries) => entries.every(selection.contains);
|
||||
bool isSelected(Iterable<AvesEntry> entries) => entries.every(selection.contains);
|
||||
|
||||
void addToSelection(Iterable<ImageEntry> entries) {
|
||||
void addToSelection(Iterable<AvesEntry> entries) {
|
||||
_selection.addAll(entries);
|
||||
selectionChangeNotifier.notifyListeners();
|
||||
}
|
||||
|
||||
void removeFromSelection(Iterable<ImageEntry> entries) {
|
||||
void removeFromSelection(Iterable<AvesEntry> entries) {
|
||||
_selection.removeAll(entries);
|
||||
selectionChangeNotifier.notifyListeners();
|
||||
}
|
||||
|
@ -228,7 +223,7 @@ mixin CollectionSelectionMixin on CollectionActivityMixin {
|
|||
selectionChangeNotifier.notifyListeners();
|
||||
}
|
||||
|
||||
void toggleSelection(ImageEntry entry) {
|
||||
void toggleSelection(AvesEntry entry) {
|
||||
if (_selection.isEmpty) select();
|
||||
if (!_selection.remove(entry)) _selection.add(entry);
|
||||
selectionChangeNotifier.notifyListeners();
|
||||
|
|
|
@ -1,30 +1,30 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/favourite_repo.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/image_metadata.dart';
|
||||
import 'package:aves/model/metadata.dart';
|
||||
import 'package:aves/model/metadata_db.dart';
|
||||
import 'package:aves/model/source/album.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/location.dart';
|
||||
import 'package:aves/model/source/tag.dart';
|
||||
import 'package:aves/services/image_file_service.dart';
|
||||
import 'package:aves/services/image_op_events.dart';
|
||||
import 'package:event_bus/event_bus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'enums.dart';
|
||||
|
||||
mixin SourceBase {
|
||||
final List<ImageEntry> _rawEntries = [];
|
||||
final List<AvesEntry> _rawEntries = [];
|
||||
|
||||
List<ImageEntry> get rawEntries => List.unmodifiable(_rawEntries);
|
||||
List<AvesEntry> get rawEntries => List.unmodifiable(_rawEntries);
|
||||
|
||||
final EventBus _eventBus = EventBus();
|
||||
|
||||
EventBus get eventBus => _eventBus;
|
||||
|
||||
List<ImageEntry> get sortedEntriesForFilterList;
|
||||
List<AvesEntry> get sortedEntriesForFilterList;
|
||||
|
||||
final Map<CollectionFilter, int> _filterEntryCountMap = {};
|
||||
|
||||
|
@ -39,7 +39,7 @@ mixin SourceBase {
|
|||
|
||||
abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin {
|
||||
@override
|
||||
List<ImageEntry> get sortedEntriesForFilterList => CollectionLens(
|
||||
List<AvesEntry> get sortedEntriesForFilterList => CollectionLens(
|
||||
source: this,
|
||||
groupFactor: EntryGroupFactor.none,
|
||||
sortFactor: EntrySortFactor.date,
|
||||
|
@ -55,7 +55,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
debugPrint('$runtimeType loadDates complete in ${stopwatch.elapsed.inMilliseconds}ms for ${_savedDates.length} entries');
|
||||
}
|
||||
|
||||
void addAll(Iterable<ImageEntry> entries) {
|
||||
void addAll(Iterable<AvesEntry> entries) {
|
||||
if (entries.isEmpty) return;
|
||||
if (_rawEntries.isNotEmpty) {
|
||||
final newContentIds = entries.map((entry) => entry.contentId).toList();
|
||||
_rawEntries.removeWhere((entry) => newContentIds.contains(entry.contentId));
|
||||
|
@ -70,7 +71,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
eventBus.fire(EntryAddedEvent());
|
||||
}
|
||||
|
||||
void removeEntries(List<ImageEntry> entries) {
|
||||
void removeEntries(List<AvesEntry> entries) {
|
||||
entries.forEach((entry) => entry.removeFromFavourites());
|
||||
_rawEntries.removeWhere(entries.contains);
|
||||
cleanEmptyAlbums(entries.map((entry) => entry.directory).toSet());
|
||||
|
@ -91,7 +92,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
|
||||
// `dateModifiedSecs` changes when moving entries to another directory,
|
||||
// but it does not change when renaming the containing directory
|
||||
Future<void> moveEntry(ImageEntry entry, Map newFields) async {
|
||||
Future<void> moveEntry(AvesEntry entry, Map newFields) async {
|
||||
final oldContentId = entry.contentId;
|
||||
final newContentId = newFields['contentId'] as int;
|
||||
final newDateModifiedSecs = newFields['dateModifiedSecs'] as int;
|
||||
|
@ -109,7 +110,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
}
|
||||
|
||||
void updateAfterMove({
|
||||
@required Set<ImageEntry> selection,
|
||||
@required Set<AvesEntry> selection,
|
||||
@required bool copy,
|
||||
@required String destinationAlbum,
|
||||
@required Iterable<MoveOpEvent> movedOps,
|
||||
|
@ -117,7 +118,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
if (movedOps.isEmpty) return;
|
||||
|
||||
final fromAlbums = <String>{};
|
||||
final movedEntries = <ImageEntry>[];
|
||||
final movedEntries = <AvesEntry>[];
|
||||
if (copy) {
|
||||
movedOps.forEach((movedOp) {
|
||||
final sourceUri = movedOp.uri;
|
||||
|
@ -164,27 +165,31 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
return _filterEntryCountMap.putIfAbsent(filter, () => _rawEntries.where((entry) => filter.filter(entry)).length);
|
||||
}
|
||||
|
||||
bool get initialized => false;
|
||||
|
||||
Future<void> init();
|
||||
|
||||
Future<void> refresh();
|
||||
|
||||
Future<void> refreshMetadata(Set<ImageEntry> entries);
|
||||
Future<void> refreshMetadata(Set<AvesEntry> entries);
|
||||
}
|
||||
|
||||
enum SourceState { loading, cataloguing, locating, ready }
|
||||
|
||||
class EntryAddedEvent {
|
||||
final ImageEntry entry;
|
||||
final AvesEntry entry;
|
||||
|
||||
const EntryAddedEvent([this.entry]);
|
||||
}
|
||||
|
||||
class EntryRemovedEvent {
|
||||
final Iterable<ImageEntry> entries;
|
||||
final Iterable<AvesEntry> entries;
|
||||
|
||||
const EntryRemovedEvent(this.entries);
|
||||
}
|
||||
|
||||
class EntryMovedEvent {
|
||||
final Iterable<ImageEntry> entries;
|
||||
final Iterable<AvesEntry> entries;
|
||||
|
||||
const EntryMovedEvent(this.entries);
|
||||
}
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/connectivity.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/location.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/image_metadata.dart';
|
||||
import 'package:aves/model/metadata.dart';
|
||||
import 'package:aves/model/metadata_db.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
@ -27,8 +28,10 @@ mixin LocationMixin on SourceBase {
|
|||
}
|
||||
|
||||
Future<void> locateEntries() async {
|
||||
if (!(await connectivity.canGeolocate)) return;
|
||||
|
||||
// final stopwatch = Stopwatch()..start();
|
||||
final byLocated = groupBy<ImageEntry, bool>(rawEntries.where((entry) => entry.hasGps), (entry) => entry.isLocated);
|
||||
final byLocated = groupBy<AvesEntry, bool>(rawEntries.where((entry) => entry.hasGps), (entry) => entry.isLocated);
|
||||
final todo = byLocated[false] ?? [];
|
||||
if (todo.isEmpty) return;
|
||||
|
||||
|
@ -42,7 +45,7 @@ mixin LocationMixin on SourceBase {
|
|||
// - 652 calls (22%) when approximating to 2 decimal places (~1km - town or village)
|
||||
// cf https://en.wikipedia.org/wiki/Decimal_degrees#Precision
|
||||
final latLngFactor = pow(10, 2);
|
||||
Tuple2 approximateLatLng(ImageEntry entry) {
|
||||
Tuple2 approximateLatLng(AvesEntry entry) {
|
||||
final lat = entry.catalogMetadata?.latitude;
|
||||
final lng = entry.catalogMetadata?.longitude;
|
||||
if (lat == null || lng == null) return null;
|
||||
|
@ -57,7 +60,7 @@ mixin LocationMixin on SourceBase {
|
|||
setProgress(done: progressDone, total: progressTotal);
|
||||
|
||||
final newAddresses = <AddressDetails>[];
|
||||
await Future.forEach<ImageEntry>(todo, (entry) async {
|
||||
await Future.forEach<AvesEntry>(todo, (entry) async {
|
||||
final latLng = approximateLatLng(entry);
|
||||
if (knownLocations.containsKey(latLng)) {
|
||||
entry.addressDetails = knownLocations[latLng]?.copyWith(contentId: entry.contentId);
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/favourite_repo.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/metadata_db.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
|
@ -13,6 +14,12 @@ import 'package:flutter_native_timezone/flutter_native_timezone.dart';
|
|||
import 'package:pedantic/pedantic.dart';
|
||||
|
||||
class MediaStoreSource extends CollectionSource {
|
||||
bool _initialized = false;
|
||||
|
||||
@override
|
||||
bool get initialized => _initialized;
|
||||
|
||||
@override
|
||||
Future<void> init() async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
stateNotifier.value = SourceState.loading;
|
||||
|
@ -28,11 +35,13 @@ class MediaStoreSource extends CollectionSource {
|
|||
settings.catalogTimeZone = currentTimeZone;
|
||||
}
|
||||
await loadDates(); // 100ms for 5400 entries
|
||||
_initialized = true;
|
||||
debugPrint('$runtimeType init done, elapsed=${stopwatch.elapsed}');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> refresh() async {
|
||||
assert(_initialized);
|
||||
debugPrint('$runtimeType refresh start');
|
||||
final stopwatch = Stopwatch()..start();
|
||||
stateNotifier.value = SourceState.loading;
|
||||
|
@ -40,8 +49,8 @@ class MediaStoreSource extends CollectionSource {
|
|||
|
||||
final oldEntries = await metadataDb.loadEntries(); // 400ms for 5500 entries
|
||||
final knownEntryMap = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId, entry.dateModifiedSecs)));
|
||||
final obsoleteEntries = (await ImageFileService.getObsoleteEntries(knownEntryMap.keys.toList())).toSet();
|
||||
oldEntries.removeWhere((entry) => obsoleteEntries.contains(entry.contentId));
|
||||
final obsoleteContentIds = (await ImageFileService.getObsoleteEntries(knownEntryMap.keys.toList())).toSet();
|
||||
oldEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId));
|
||||
|
||||
// show known entries
|
||||
addAll(oldEntries);
|
||||
|
@ -50,19 +59,20 @@ class MediaStoreSource extends CollectionSource {
|
|||
debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}');
|
||||
|
||||
// clean up obsolete entries
|
||||
metadataDb.removeIds(obsoleteEntries, updateFavourites: true);
|
||||
metadataDb.removeIds(obsoleteContentIds, updateFavourites: true);
|
||||
|
||||
// fetch new entries
|
||||
// refresh after the first 10 entries, then after 100 more, then every 1000 entries
|
||||
var refreshCount = 10;
|
||||
const refreshCountMax = 1000;
|
||||
final allNewEntries = <ImageEntry>[], pendingNewEntries = <ImageEntry>[];
|
||||
final allNewEntries = <AvesEntry>[], pendingNewEntries = <AvesEntry>[];
|
||||
void addPendingEntries() {
|
||||
allNewEntries.addAll(pendingNewEntries);
|
||||
addAll(pendingNewEntries);
|
||||
pendingNewEntries.clear();
|
||||
}
|
||||
|
||||
ImageFileService.getImageEntries(knownEntryMap).listen(
|
||||
ImageFileService.getEntries(knownEntryMap).listen(
|
||||
(entry) {
|
||||
pendingNewEntries.add(entry);
|
||||
if (pendingNewEntries.length >= refreshCount) {
|
||||
|
@ -95,8 +105,48 @@ class MediaStoreSource extends CollectionSource {
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> refreshUris(List<String> changedUris) async {
|
||||
if (!_initialized) return;
|
||||
|
||||
final uriByContentId = Map.fromEntries(changedUris.map((uri) {
|
||||
if (uri == null) return null;
|
||||
final idString = Uri.parse(uri).pathSegments.last;
|
||||
final contentId = int.tryParse(idString);
|
||||
if (contentId == null) return null;
|
||||
return MapEntry(contentId, uri);
|
||||
}).where((kv) => kv != null));
|
||||
|
||||
// clean up obsolete entries
|
||||
final obsoleteContentIds = (await ImageFileService.getObsoleteEntries(uriByContentId.keys.toList())).toSet();
|
||||
uriByContentId.removeWhere((contentId, _) => obsoleteContentIds.contains(contentId));
|
||||
metadataDb.removeIds(obsoleteContentIds, updateFavourites: true);
|
||||
|
||||
// add new entries
|
||||
final newEntries = <AvesEntry>[];
|
||||
for (final kv in uriByContentId.entries) {
|
||||
final contentId = kv.key;
|
||||
final uri = kv.value;
|
||||
final sourceEntry = await ImageFileService.getEntry(uri, null);
|
||||
final existingEntry = rawEntries.firstWhere((entry) => entry.contentId == contentId, orElse: () => null);
|
||||
if (existingEntry == null || sourceEntry.dateModifiedSecs > existingEntry.dateModifiedSecs) {
|
||||
newEntries.add(sourceEntry);
|
||||
}
|
||||
}
|
||||
addAll(newEntries);
|
||||
await metadataDb.saveEntries(newEntries);
|
||||
updateAlbums();
|
||||
|
||||
stateNotifier.value = SourceState.cataloguing;
|
||||
await catalogEntries();
|
||||
|
||||
stateNotifier.value = SourceState.locating;
|
||||
await locateEntries();
|
||||
|
||||
stateNotifier.value = SourceState.ready;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> refreshMetadata(Set<ImageEntry> entries) {
|
||||
Future<void> refreshMetadata(Set<AvesEntry> entries) {
|
||||
final contentIds = entries.map((entry) => entry.contentId).toSet();
|
||||
metadataDb.removeIds(contentIds, updateFavourites: false);
|
||||
return refresh();
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/image_metadata.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/metadata.dart';
|
||||
import 'package:aves/model/metadata_db.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
@ -31,7 +31,7 @@ mixin TagMixin on SourceBase {
|
|||
setProgress(done: progressDone, total: progressTotal);
|
||||
|
||||
final newMetadata = <CatalogMetadata>[];
|
||||
await Future.forEach<ImageEntry>(todo, (entry) async {
|
||||
await Future.forEach<AvesEntry>(todo, (entry) async {
|
||||
await entry.catalog(background: true);
|
||||
if (entry.isCatalogued) {
|
||||
newMetadata.add(entry.catalogMetadata);
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
@ -8,12 +9,18 @@ import 'package:flutter/services.dart';
|
|||
class AndroidAppService {
|
||||
static const platform = MethodChannel('deckers.thibault/aves/app');
|
||||
|
||||
static Future<Map> getAppNames() async {
|
||||
static Future<Set<Package>> getPackages() async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getAppNames');
|
||||
return result as Map;
|
||||
final result = await platform.invokeMethod('getPackages');
|
||||
final packages = (result as List).cast<Map>().map((map) => Package.fromMap(map)).toSet();
|
||||
// additional info for known directories
|
||||
final kakaoTalk = packages.firstWhere((package) => package.packageName == 'com.kakao.talk', orElse: () => null);
|
||||
if (kakaoTalk != null) {
|
||||
kakaoTalk.ownedDirs.add('KakaoTalkDownload');
|
||||
}
|
||||
return packages;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getAppNames failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
debugPrint('getPackages failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -81,10 +88,10 @@ class AndroidAppService {
|
|||
return false;
|
||||
}
|
||||
|
||||
static Future<bool> shareEntries(Iterable<ImageEntry> entries) async {
|
||||
static Future<bool> shareEntries(Iterable<AvesEntry> entries) async {
|
||||
// loosen mime type to a generic one, so we can share with badly defined apps
|
||||
// e.g. Google Lens declares receiving "image/jpeg" only, but it can actually handle more formats
|
||||
final urisByMimeType = groupBy<ImageEntry, String>(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList()));
|
||||
final urisByMimeType = groupBy<AvesEntry, String>(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList()));
|
||||
try {
|
||||
return await platform.invokeMethod('share', <String, dynamic>{
|
||||
'title': 'Share via:',
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
@ -26,7 +26,7 @@ class AndroidDebugService {
|
|||
return {};
|
||||
}
|
||||
|
||||
static Future<Map> getBitmapFactoryInfo(ImageEntry entry) async {
|
||||
static Future<Map> getBitmapFactoryInfo(AvesEntry entry) async {
|
||||
try {
|
||||
// return map with all data available when decoding image bounds with `BitmapFactory`
|
||||
final result = await platform.invokeMethod('getBitmapFactoryInfo', <String, dynamic>{
|
||||
|
@ -39,7 +39,7 @@ class AndroidDebugService {
|
|||
return {};
|
||||
}
|
||||
|
||||
static Future<Map> getContentResolverMetadata(ImageEntry entry) async {
|
||||
static Future<Map> getContentResolverMetadata(AvesEntry entry) async {
|
||||
try {
|
||||
// return map with all data available from the content resolver
|
||||
final result = await platform.invokeMethod('getContentResolverMetadata', <String, dynamic>{
|
||||
|
@ -53,7 +53,7 @@ class AndroidDebugService {
|
|||
return {};
|
||||
}
|
||||
|
||||
static Future<Map> getExifInterfaceMetadata(ImageEntry entry) async {
|
||||
static Future<Map> getExifInterfaceMetadata(AvesEntry entry) async {
|
||||
try {
|
||||
// return map with all data available from the `ExifInterface` library
|
||||
final result = await platform.invokeMethod('getExifInterfaceMetadata', <String, dynamic>{
|
||||
|
@ -68,7 +68,7 @@ class AndroidDebugService {
|
|||
return {};
|
||||
}
|
||||
|
||||
static Future<Map> getMediaMetadataRetrieverMetadata(ImageEntry entry) async {
|
||||
static Future<Map> getMediaMetadataRetrieverMetadata(AvesEntry entry) async {
|
||||
try {
|
||||
// return map with all data available from `MediaMetadataRetriever`
|
||||
final result = await platform.invokeMethod('getMediaMetadataRetrieverMetadata', <String, dynamic>{
|
||||
|
@ -81,7 +81,7 @@ class AndroidDebugService {
|
|||
return {};
|
||||
}
|
||||
|
||||
static Future<Map> getMetadataExtractorSummary(ImageEntry entry) async {
|
||||
static Future<Map> getMetadataExtractorSummary(AvesEntry entry) async {
|
||||
try {
|
||||
// return map with the mime type and tag count for each directory found by `metadata-extractor`
|
||||
final result = await platform.invokeMethod('getMetadataExtractorSummary', <String, dynamic>{
|
||||
|
@ -96,7 +96,7 @@ class AndroidDebugService {
|
|||
return {};
|
||||
}
|
||||
|
||||
static Future<Map> getTiffStructure(ImageEntry entry) async {
|
||||
static Future<Map> getTiffStructure(AvesEntry entry) async {
|
||||
if (entry.mimeType != MimeTypes.tiff) return {};
|
||||
|
||||
try {
|
||||
|
|
|
@ -9,14 +9,14 @@ class AndroidFileService {
|
|||
static const platform = MethodChannel('deckers.thibault/aves/storage');
|
||||
static final StreamsChannel storageAccessChannel = StreamsChannel('deckers.thibault/aves/storageaccessstream');
|
||||
|
||||
static Future<List<Map>> getStorageVolumes() async {
|
||||
static Future<Set<StorageVolume>> getStorageVolumes() async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getStorageVolumes');
|
||||
return (result as List).cast<Map>();
|
||||
return (result as List).cast<Map>().map((map) => StorageVolume.fromMap(map)).toSet();
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getStorageVolumes failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
}
|
||||
return [];
|
||||
return {};
|
||||
}
|
||||
|
||||
static Future<int> getFreeSpace(StorageVolume volume) async {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/services/image_file_service.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
@ -26,18 +26,18 @@ class AppShortcutService {
|
|||
return false;
|
||||
}
|
||||
|
||||
static Future<void> pin(String label, ImageEntry iconEntry, Set<CollectionFilter> filters) async {
|
||||
static Future<void> pin(String label, AvesEntry entry, Set<CollectionFilter> filters) async {
|
||||
Uint8List iconBytes;
|
||||
if (iconEntry != null) {
|
||||
final size = iconEntry.isVideo ? 0.0 : 256.0;
|
||||
if (entry != null) {
|
||||
final size = entry.isVideo ? 0.0 : 256.0;
|
||||
iconBytes = await ImageFileService.getThumbnail(
|
||||
iconEntry.uri,
|
||||
iconEntry.mimeType,
|
||||
iconEntry.dateModifiedSecs,
|
||||
iconEntry.rotationDegrees,
|
||||
iconEntry.isFlipped,
|
||||
size,
|
||||
size,
|
||||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
pageId: entry.pageId,
|
||||
rotationDegrees: entry.rotationDegrees,
|
||||
isFlipped: entry.isFlipped,
|
||||
dateModifiedSecs: entry.dateModifiedSecs,
|
||||
extent: size,
|
||||
);
|
||||
}
|
||||
try {
|
||||
|
|
|
@ -3,12 +3,12 @@ import 'dart:convert';
|
|||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/services/image_op_events.dart';
|
||||
import 'package:aves/services/service_policy.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:streams_channel/streams_channel.dart';
|
||||
|
||||
class ImageFileService {
|
||||
|
@ -18,10 +18,11 @@ class ImageFileService {
|
|||
static final StreamsChannel opChannel = StreamsChannel('deckers.thibault/aves/imageopstream');
|
||||
static const double thumbnailDefaultSize = 64.0;
|
||||
|
||||
static Map<String, dynamic> _toPlatformEntryMap(ImageEntry entry) {
|
||||
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,
|
||||
|
@ -32,13 +33,13 @@ class ImageFileService {
|
|||
}
|
||||
|
||||
// knownEntries: map of contentId -> dateModifiedSecs
|
||||
static Stream<ImageEntry> getImageEntries(Map<int, int> knownEntries) {
|
||||
static Stream<AvesEntry> getEntries(Map<int, int> knownEntries) {
|
||||
try {
|
||||
return mediaStoreChannel.receiveBroadcastStream(<String, dynamic>{
|
||||
'knownEntries': knownEntries,
|
||||
}).map((event) => ImageEntry.fromMap(event));
|
||||
}).map((event) => AvesEntry.fromMap(event));
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getImageEntries failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
debugPrint('getEntries failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
return Stream.error(e);
|
||||
}
|
||||
}
|
||||
|
@ -55,26 +56,40 @@ class ImageFileService {
|
|||
return [];
|
||||
}
|
||||
|
||||
static Future<ImageEntry> getImageEntry(String uri, String mimeType) async {
|
||||
debugPrint('getImageEntry for uri=$uri, mimeType=$mimeType');
|
||||
static Future<AvesEntry> getEntry(String uri, String mimeType) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getImageEntry', <String, dynamic>{
|
||||
final result = await platform.invokeMethod('getEntry', <String, dynamic>{
|
||||
'uri': uri,
|
||||
'mimeType': mimeType,
|
||||
}) as Map;
|
||||
return ImageEntry.fromMap(result);
|
||||
return AvesEntry.fromMap(result);
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getImageEntry failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
debugPrint('getEntry failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<Uint8List> getSvg(
|
||||
String uri,
|
||||
String mimeType, {
|
||||
int expectedContentLength,
|
||||
BytesReceivedCallback onBytesReceived,
|
||||
}) =>
|
||||
getImage(
|
||||
uri,
|
||||
mimeType,
|
||||
0,
|
||||
false,
|
||||
expectedContentLength: expectedContentLength,
|
||||
onBytesReceived: onBytesReceived,
|
||||
);
|
||||
|
||||
static Future<Uint8List> getImage(
|
||||
String uri,
|
||||
String mimeType,
|
||||
int rotationDegrees,
|
||||
bool isFlipped, {
|
||||
int page = 0,
|
||||
int pageId,
|
||||
int expectedContentLength,
|
||||
BytesReceivedCallback onBytesReceived,
|
||||
}) {
|
||||
|
@ -87,7 +102,7 @@ class ImageFileService {
|
|||
'mimeType': mimeType,
|
||||
'rotationDegrees': rotationDegrees ?? 0,
|
||||
'isFlipped': isFlipped ?? false,
|
||||
'page': page ?? 0,
|
||||
'pageId': pageId,
|
||||
}).listen(
|
||||
(data) {
|
||||
final chunk = data as Uint8List;
|
||||
|
@ -125,7 +140,7 @@ class ImageFileService {
|
|||
int sampleSize,
|
||||
Rectangle<int> regionRect,
|
||||
Size imageSize, {
|
||||
int page = 0,
|
||||
int pageId,
|
||||
Object taskKey,
|
||||
int priority,
|
||||
}) {
|
||||
|
@ -135,7 +150,7 @@ class ImageFileService {
|
|||
final result = await platform.invokeMethod('getRegion', <String, dynamic>{
|
||||
'uri': uri,
|
||||
'mimeType': mimeType,
|
||||
'page': page,
|
||||
'pageId': pageId,
|
||||
'sampleSize': sampleSize,
|
||||
'regionX': regionRect.left,
|
||||
'regionY': regionRect.top,
|
||||
|
@ -155,15 +170,14 @@ class ImageFileService {
|
|||
);
|
||||
}
|
||||
|
||||
static Future<Uint8List> getThumbnail(
|
||||
String uri,
|
||||
String mimeType,
|
||||
int dateModifiedSecs,
|
||||
int rotationDegrees,
|
||||
bool isFlipped,
|
||||
double width,
|
||||
double height, {
|
||||
int page,
|
||||
static Future<Uint8List> getThumbnail({
|
||||
@required String uri,
|
||||
@required String mimeType,
|
||||
@required int rotationDegrees,
|
||||
@required int pageId,
|
||||
@required bool isFlipped,
|
||||
@required int dateModifiedSecs,
|
||||
@required double extent,
|
||||
Object taskKey,
|
||||
int priority,
|
||||
}) {
|
||||
|
@ -179,9 +193,9 @@ class ImageFileService {
|
|||
'dateModifiedSecs': dateModifiedSecs,
|
||||
'rotationDegrees': rotationDegrees,
|
||||
'isFlipped': isFlipped,
|
||||
'widthDip': width,
|
||||
'heightDip': height,
|
||||
'page': page,
|
||||
'widthDip': extent,
|
||||
'heightDip': extent,
|
||||
'pageId': pageId,
|
||||
'defaultSizeDip': thumbnailDefaultSize,
|
||||
});
|
||||
return result as Uint8List;
|
||||
|
@ -191,7 +205,7 @@ class ImageFileService {
|
|||
return null;
|
||||
},
|
||||
// debugLabel: 'getThumbnail width=$width, height=$height entry=${entry.filenameWithoutExtension}',
|
||||
priority: priority ?? (width == 0 || height == 0 ? ServiceCallPriority.getFastThumbnail : ServiceCallPriority.getSizedThumbnail),
|
||||
priority: priority ?? (extent == 0 ? ServiceCallPriority.getFastThumbnail : ServiceCallPriority.getSizedThumbnail),
|
||||
key: taskKey,
|
||||
);
|
||||
}
|
||||
|
@ -210,7 +224,7 @@ class ImageFileService {
|
|||
|
||||
static Future<T> resumeLoading<T>(Object taskKey) => servicePolicy.resume<T>(taskKey);
|
||||
|
||||
static Stream<ImageOpEvent> delete(Iterable<ImageEntry> entries) {
|
||||
static Stream<ImageOpEvent> delete(Iterable<AvesEntry> entries) {
|
||||
try {
|
||||
return opChannel.receiveBroadcastStream(<String, dynamic>{
|
||||
'op': 'delete',
|
||||
|
@ -222,7 +236,11 @@ class ImageFileService {
|
|||
}
|
||||
}
|
||||
|
||||
static Stream<MoveOpEvent> move(Iterable<ImageEntry> entries, {@required bool copy, @required String destinationAlbum}) {
|
||||
static Stream<MoveOpEvent> move(
|
||||
Iterable<AvesEntry> entries, {
|
||||
@required bool copy,
|
||||
@required String destinationAlbum,
|
||||
}) {
|
||||
try {
|
||||
return opChannel.receiveBroadcastStream(<String, dynamic>{
|
||||
'op': 'move',
|
||||
|
@ -236,7 +254,25 @@ class ImageFileService {
|
|||
}
|
||||
}
|
||||
|
||||
static Future<Map> rename(ImageEntry entry, String newName) async {
|
||||
static Stream<ExportOpEvent> export(
|
||||
Iterable<AvesEntry> entries, {
|
||||
String mimeType = MimeTypes.jpeg,
|
||||
@required String destinationAlbum,
|
||||
}) {
|
||||
try {
|
||||
return opChannel.receiveBroadcastStream(<String, dynamic>{
|
||||
'op': 'export',
|
||||
'entries': entries.map(_toPlatformEntryMap).toList(),
|
||||
'mimeType': mimeType,
|
||||
'destinationPath': destinationAlbum,
|
||||
}).map((event) => ExportOpEvent.fromMap(event));
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('export failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
return Stream.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map> rename(AvesEntry entry, String newName) async {
|
||||
try {
|
||||
// return map with: 'contentId' 'path' 'title' 'uri' (all optional)
|
||||
final result = await platform.invokeMethod('rename', <String, dynamic>{
|
||||
|
@ -250,7 +286,7 @@ class ImageFileService {
|
|||
return {};
|
||||
}
|
||||
|
||||
static Future<Map> rotate(ImageEntry entry, {@required bool clockwise}) async {
|
||||
static Future<Map> rotate(AvesEntry entry, {@required bool clockwise}) async {
|
||||
try {
|
||||
// return map with: 'rotationDegrees' 'isFlipped'
|
||||
final result = await platform.invokeMethod('rotate', <String, dynamic>{
|
||||
|
@ -264,7 +300,7 @@ class ImageFileService {
|
|||
return {};
|
||||
}
|
||||
|
||||
static Future<Map> flip(ImageEntry entry) async {
|
||||
static Future<Map> flip(AvesEntry entry) async {
|
||||
try {
|
||||
// return map with: 'rotationDegrees' 'isFlipped'
|
||||
final result = await platform.invokeMethod('flip', <String, dynamic>{
|
||||
|
@ -278,57 +314,6 @@ class ImageFileService {
|
|||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ImageOpEvent {
|
||||
final bool success;
|
||||
final String uri;
|
||||
|
||||
const ImageOpEvent({
|
||||
this.success,
|
||||
this.uri,
|
||||
});
|
||||
|
||||
factory ImageOpEvent.fromMap(Map map) {
|
||||
return ImageOpEvent(
|
||||
success: map['success'] ?? false,
|
||||
uri: map['uri'],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is ImageOpEvent && other.success == success && other.uri == uri;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(success, uri);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri}';
|
||||
}
|
||||
|
||||
class MoveOpEvent extends ImageOpEvent {
|
||||
final Map newFields;
|
||||
|
||||
const MoveOpEvent({bool success, String uri, this.newFields})
|
||||
: super(
|
||||
success: success,
|
||||
uri: uri,
|
||||
);
|
||||
|
||||
factory MoveOpEvent.fromMap(Map map) {
|
||||
return MoveOpEvent(
|
||||
success: map['success'] ?? false,
|
||||
uri: map['uri'],
|
||||
newFields: map['newFields'],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri, newFields=$newFields}';
|
||||
}
|
||||
|
||||
// cf flutter/foundation `consolidateHttpClientResponseBytes`
|
||||
typedef BytesReceivedCallback = void Function(int cumulative, int total);
|
||||
|
||||
|
|
85
lib/services/image_op_events.dart
Normal file
85
lib/services/image_op_events.dart
Normal file
|
@ -0,0 +1,85 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
@immutable
|
||||
class ImageOpEvent {
|
||||
final bool success;
|
||||
final String uri;
|
||||
|
||||
const ImageOpEvent({
|
||||
this.success,
|
||||
this.uri,
|
||||
});
|
||||
|
||||
factory ImageOpEvent.fromMap(Map map) {
|
||||
return ImageOpEvent(
|
||||
success: map['success'] ?? false,
|
||||
uri: map['uri'],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is ImageOpEvent && other.success == success && other.uri == uri;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(success, uri);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri}';
|
||||
}
|
||||
|
||||
class MoveOpEvent extends ImageOpEvent {
|
||||
final Map newFields;
|
||||
|
||||
const MoveOpEvent({bool success, String uri, this.newFields})
|
||||
: super(
|
||||
success: success,
|
||||
uri: uri,
|
||||
);
|
||||
|
||||
factory MoveOpEvent.fromMap(Map map) {
|
||||
return MoveOpEvent(
|
||||
success: map['success'] ?? false,
|
||||
uri: map['uri'],
|
||||
newFields: map['newFields'],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri, newFields=$newFields}';
|
||||
}
|
||||
|
||||
class ExportOpEvent extends MoveOpEvent {
|
||||
final int pageId;
|
||||
|
||||
const ExportOpEvent({bool success, String uri, this.pageId, Map newFields})
|
||||
: super(
|
||||
success: success,
|
||||
uri: uri,
|
||||
newFields: newFields,
|
||||
);
|
||||
|
||||
factory ExportOpEvent.fromMap(Map map) {
|
||||
return ExportOpEvent(
|
||||
success: map['success'] ?? false,
|
||||
uri: map['uri'],
|
||||
pageId: map['pageId'],
|
||||
newFields: map['newFields'],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is ExportOpEvent && other.success == success && other.uri == uri && other.pageId == pageId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(success, uri, pageId);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri, pageId=$pageId, newFields=$newFields}';
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/image_metadata.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/metadata.dart';
|
||||
import 'package:aves/model/multipage.dart';
|
||||
import 'package:aves/model/panorama.dart';
|
||||
import 'package:aves/services/service_policy.dart';
|
||||
|
@ -12,7 +12,7 @@ class MetadataService {
|
|||
static const platform = MethodChannel('deckers.thibault/aves/metadata');
|
||||
|
||||
// return Map<Map<Key, Value>> (map of directories, each directory being a map of metadata label and value description)
|
||||
static Future<Map> getAllMetadata(ImageEntry entry) async {
|
||||
static Future<Map> getAllMetadata(AvesEntry entry) async {
|
||||
if (entry.isSvg) return null;
|
||||
|
||||
try {
|
||||
|
@ -28,7 +28,7 @@ class MetadataService {
|
|||
return {};
|
||||
}
|
||||
|
||||
static Future<CatalogMetadata> getCatalogMetadata(ImageEntry entry, {bool background = false}) async {
|
||||
static Future<CatalogMetadata> getCatalogMetadata(AvesEntry entry, {bool background = false}) async {
|
||||
if (entry.isSvg) return null;
|
||||
|
||||
Future<CatalogMetadata> call() async {
|
||||
|
@ -65,7 +65,7 @@ class MetadataService {
|
|||
: call();
|
||||
}
|
||||
|
||||
static Future<OverlayMetadata> getOverlayMetadata(ImageEntry entry) async {
|
||||
static Future<OverlayMetadata> getOverlayMetadata(AvesEntry entry) async {
|
||||
if (entry.isSvg) return null;
|
||||
|
||||
try {
|
||||
|
@ -82,20 +82,21 @@ class MetadataService {
|
|||
return null;
|
||||
}
|
||||
|
||||
static Future<MultiPageInfo> getMultiPageInfo(ImageEntry entry) async {
|
||||
static Future<MultiPageInfo> getMultiPageInfo(AvesEntry entry) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getMultiPageInfo', <String, dynamic>{
|
||||
'mimeType': entry.mimeType,
|
||||
'uri': entry.uri,
|
||||
}) as Map;
|
||||
return MultiPageInfo.fromMap(result);
|
||||
});
|
||||
final pageMaps = (result as List).cast<Map>();
|
||||
return MultiPageInfo.fromPageMaps(pageMaps);
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getMultiPageInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<PanoramaInfo> getPanoramaInfo(ImageEntry entry) async {
|
||||
static Future<PanoramaInfo> getPanoramaInfo(AvesEntry entry) async {
|
||||
try {
|
||||
// return map with values for:
|
||||
// 'croppedAreaLeft' (int), 'croppedAreaTop' (int), 'croppedAreaWidth' (int), 'croppedAreaHeight' (int),
|
||||
|
@ -112,6 +113,19 @@ class MetadataService {
|
|||
return null;
|
||||
}
|
||||
|
||||
static Future<String> getContentResolverProp(AvesEntry entry, String prop) async {
|
||||
try {
|
||||
return await platform.invokeMethod('getContentResolverProp', <String, dynamic>{
|
||||
'mimeType': entry.mimeType,
|
||||
'uri': entry.uri,
|
||||
'prop': prop,
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getContentResolverProp failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<List<Uint8List>> getEmbeddedPictures(String uri) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getEmbeddedPictures', <String, dynamic>{
|
||||
|
@ -124,7 +138,7 @@ class MetadataService {
|
|||
return [];
|
||||
}
|
||||
|
||||
static Future<List<Uint8List>> getExifThumbnails(ImageEntry entry) async {
|
||||
static Future<List<Uint8List>> getExifThumbnails(AvesEntry entry) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getExifThumbnails', <String, dynamic>{
|
||||
'mimeType': entry.mimeType,
|
||||
|
@ -138,7 +152,7 @@ class MetadataService {
|
|||
return [];
|
||||
}
|
||||
|
||||
static Future<Map> extractXmpDataProp(ImageEntry entry, String propPath, String propMimeType) async {
|
||||
static Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('extractXmpDataProp', <String, dynamic>{
|
||||
'mimeType': entry.mimeType,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/services/image_file_service.dart';
|
||||
import 'package:aves/utils/string_utils.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
@ -15,9 +15,9 @@ class SvgMetadataService {
|
|||
static const _textElements = ['title', 'desc'];
|
||||
static const _metadataElement = 'metadata';
|
||||
|
||||
static Future<Size> getSize(ImageEntry entry) async {
|
||||
static Future<Size> getSize(AvesEntry entry) async {
|
||||
try {
|
||||
final data = await ImageFileService.getImage(entry.uri, entry.mimeType, 0, false);
|
||||
final data = await ImageFileService.getSvg(entry.uri, entry.mimeType);
|
||||
|
||||
final document = XmlDocument.parse(utf8.decode(data));
|
||||
final root = document.rootElement;
|
||||
|
@ -48,7 +48,7 @@ class SvgMetadataService {
|
|||
return null;
|
||||
}
|
||||
|
||||
static Future<Map<String, Map<String, String>>> getAllMetadata(ImageEntry entry) async {
|
||||
static Future<Map<String, Map<String, String>>> getAllMetadata(AvesEntry entry) async {
|
||||
String formatKey(String key) {
|
||||
switch (key) {
|
||||
case 'desc':
|
||||
|
@ -59,7 +59,7 @@ class SvgMetadataService {
|
|||
}
|
||||
|
||||
try {
|
||||
final data = await ImageFileService.getImage(entry.uri, entry.mimeType, 0, false);
|
||||
final data = await ImageFileService.getSvg(entry.uri, entry.mimeType);
|
||||
|
||||
final document = XmlDocument.parse(utf8.decode(data));
|
||||
final root = document.rootElement;
|
||||
|
|
|
@ -48,4 +48,11 @@ class Durations {
|
|||
static const doubleBackTimerDelay = Duration(milliseconds: 1000);
|
||||
static const softKeyboardDisplayDelay = Duration(milliseconds: 300);
|
||||
static const searchDebounceDelay = Duration(milliseconds: 250);
|
||||
|
||||
// Content change monitoring delay should be large enough,
|
||||
// so that querying the Media Store yields final entries.
|
||||
// For example, when taking a picture with a Galaxy S10e default camera app,
|
||||
// querying the Media Store just 1 second after sometimes yields an entry with
|
||||
// its temporary path: `/data/sec/camera/!@#$%^..._temp.jpg`
|
||||
static const contentChangeDebounceDelay = Duration(milliseconds: 1500);
|
||||
}
|
||||
|
|
|
@ -31,6 +31,7 @@ class AIcons {
|
|||
static const IconData createAlbum = Icons.add_circle_outline;
|
||||
static const IconData debug = Icons.whatshot_outlined;
|
||||
static const IconData delete = Icons.delete_outlined;
|
||||
static const IconData export = Icons.save_alt_outlined;
|
||||
static const IconData flip = Icons.flip_outlined;
|
||||
static const IconData favourite = Icons.favorite_border;
|
||||
static const IconData favouriteActive = Icons.favorite;
|
||||
|
@ -38,7 +39,7 @@ class AIcons {
|
|||
static const IconData group = Icons.group_work_outlined;
|
||||
static const IconData info = Icons.info_outlined;
|
||||
static const IconData layers = Icons.layers_outlined;
|
||||
static const IconData openInNew = Icons.open_in_new_outlined;
|
||||
static const IconData openOutside = Icons.open_in_new_outlined;
|
||||
static const IconData pin = Icons.push_pin_outlined;
|
||||
static const IconData print = Icons.print_outlined;
|
||||
static const IconData refresh = Icons.refresh_outlined;
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:aves/services/android_app_service.dart';
|
||||
import 'package:aves/services/android_file_service.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path/path.dart';
|
||||
|
||||
final AndroidFileUtils androidFileUtils = AndroidFileUtils._private();
|
||||
|
@ -8,14 +9,17 @@ final AndroidFileUtils androidFileUtils = AndroidFileUtils._private();
|
|||
class AndroidFileUtils {
|
||||
String primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath;
|
||||
Set<StorageVolume> storageVolumes = {};
|
||||
Map appNameMap = {};
|
||||
Set<Package> _packages = {};
|
||||
List<String> _potentialAppDirs = [];
|
||||
|
||||
AChangeNotifier appNameChangeNotifier = AChangeNotifier();
|
||||
|
||||
Iterable<Package> get _launcherPackages => _packages.where((package) => package.categoryLauncher);
|
||||
|
||||
AndroidFileUtils._private();
|
||||
|
||||
Future<void> init() async {
|
||||
storageVolumes = (await AndroidFileService.getStorageVolumes()).map((map) => StorageVolume.fromMap(map)).toSet();
|
||||
storageVolumes = await AndroidFileService.getStorageVolumes();
|
||||
// path_provider getExternalStorageDirectory() gives '/storage/emulated/0/Android/data/deckers.thibault.aves/files'
|
||||
primaryStorage = storageVolumes.firstWhere((volume) => volume.isPrimary).path;
|
||||
dcimPath = join(primaryStorage, 'DCIM');
|
||||
|
@ -25,8 +29,8 @@ class AndroidFileUtils {
|
|||
}
|
||||
|
||||
Future<void> initAppNames() async {
|
||||
appNameMap = await AndroidAppService.getAppNames()
|
||||
..addAll({'KakaoTalkDownload': 'com.kakao.talk'});
|
||||
_packages = await AndroidAppService.getPackages();
|
||||
_potentialAppDirs = _launcherPackages.expand((package) => package.potentialDirs).toList();
|
||||
appNameChangeNotifier.notifyListeners();
|
||||
}
|
||||
|
||||
|
@ -42,27 +46,67 @@ class AndroidFileUtils {
|
|||
|
||||
bool isOnRemovableStorage(String path) => getStorageVolume(path)?.isRemovable ?? false;
|
||||
|
||||
AlbumType getAlbumType(String albumDirectory) {
|
||||
if (albumDirectory != null) {
|
||||
if (isCameraPath(albumDirectory)) return AlbumType.camera;
|
||||
if (isDownloadPath(albumDirectory)) return AlbumType.download;
|
||||
if (isScreenRecordingsPath(albumDirectory)) return AlbumType.screenRecordings;
|
||||
if (isScreenshotsPath(albumDirectory)) return AlbumType.screenshots;
|
||||
AlbumType getAlbumType(String albumPath) {
|
||||
if (albumPath != null) {
|
||||
if (isCameraPath(albumPath)) return AlbumType.camera;
|
||||
if (isDownloadPath(albumPath)) return AlbumType.download;
|
||||
if (isScreenRecordingsPath(albumPath)) return AlbumType.screenRecordings;
|
||||
if (isScreenshotsPath(albumPath)) return AlbumType.screenshots;
|
||||
|
||||
final parts = albumDirectory.split(separator);
|
||||
if (albumDirectory.startsWith(primaryStorage) && appNameMap.keys.contains(parts.last)) return AlbumType.app;
|
||||
final dir = albumPath.split(separator).last;
|
||||
if (albumPath.startsWith(primaryStorage) && _potentialAppDirs.contains(dir)) return AlbumType.app;
|
||||
}
|
||||
return AlbumType.regular;
|
||||
}
|
||||
|
||||
String getAlbumAppPackageName(String albumDirectory) {
|
||||
final parts = albumDirectory.split(separator);
|
||||
return appNameMap[parts.last];
|
||||
String getAlbumAppPackageName(String albumPath) {
|
||||
if (albumPath == null) return null;
|
||||
final dir = albumPath.split(separator).last;
|
||||
final package = _launcherPackages.firstWhere((package) => package.potentialDirs.contains(dir), orElse: () => null);
|
||||
return package?.packageName;
|
||||
}
|
||||
|
||||
String getCurrentAppName(String packageName) {
|
||||
final package = _packages.firstWhere((package) => package.packageName == packageName, orElse: () => null);
|
||||
return package?.currentLabel;
|
||||
}
|
||||
}
|
||||
|
||||
enum AlbumType { regular, app, camera, download, screenRecordings, screenshots }
|
||||
|
||||
class Package {
|
||||
final String packageName, currentLabel, englishLabel;
|
||||
final bool categoryLauncher, isSystem;
|
||||
final Set<String> ownedDirs = {};
|
||||
|
||||
Package({
|
||||
this.packageName,
|
||||
this.currentLabel,
|
||||
this.englishLabel,
|
||||
this.categoryLauncher,
|
||||
this.isSystem,
|
||||
});
|
||||
|
||||
factory Package.fromMap(Map map) {
|
||||
return Package(
|
||||
packageName: map['packageName'],
|
||||
currentLabel: map['currentLabel'],
|
||||
englishLabel: map['englishLabel'],
|
||||
categoryLauncher: map['categoryLauncher'],
|
||||
isSystem: map['isSystem'],
|
||||
);
|
||||
}
|
||||
|
||||
Set<String> get potentialDirs => [
|
||||
currentLabel,
|
||||
englishLabel,
|
||||
...ownedDirs,
|
||||
].where((dir) => dir != null).toSet();
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{packageName=$packageName, categoryLauncher=$categoryLauncher, isSystem=$isSystem, currentLabel=$currentLabel, englishLabel=$englishLabel, ownedDirs=$ownedDirs}';
|
||||
}
|
||||
|
||||
class StorageVolume {
|
||||
final String description, path, state;
|
||||
final bool isEmulated, isPrimary, isRemovable;
|
||||
|
|
|
@ -29,14 +29,14 @@ class Constants {
|
|||
Dependency(
|
||||
name: 'AndroidX Core-KTX',
|
||||
license: 'Apache 2.0',
|
||||
licenseUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/LICENSE.txt',
|
||||
sourceUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/core/core-ktx',
|
||||
licenseUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt',
|
||||
sourceUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/core/core-ktx',
|
||||
),
|
||||
Dependency(
|
||||
name: 'AndroidX Exifinterface',
|
||||
license: 'Apache 2.0',
|
||||
licenseUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/LICENSE.txt',
|
||||
sourceUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/exifinterface/exifinterface',
|
||||
licenseUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt',
|
||||
sourceUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/exifinterface/exifinterface',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Android-TiffBitmapFactory',
|
||||
|
@ -83,18 +83,18 @@ class Constants {
|
|||
licenseUrl: 'https://github.com/dart-lang/collection/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/dart-lang/collection',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Connectivity',
|
||||
license: 'BSD 3-Clause',
|
||||
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/connectivity/connectivity/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/plugins/blob/master/packages/connectivity/connectivity',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Decorated Icon',
|
||||
license: 'MIT',
|
||||
licenseUrl: 'https://github.com/benPesso/flutter_decorated_icon/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/benPesso/flutter_decorated_icon',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Draggable Scrollbar',
|
||||
license: 'MIT',
|
||||
licenseUrl: 'https://github.com/fluttercommunity/flutter-draggable-scrollbar/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/fluttercommunity/flutter-draggable-scrollbar',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Event Bus',
|
||||
license: 'MIT',
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail_collection.dart';
|
||||
import 'package:aves/widgets/common/basic/insets.dart';
|
||||
import 'package:aves/widgets/common/behaviour/double_back_pop.dart';
|
||||
import 'package:aves/widgets/common/gesture_area_protector.dart';
|
||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/drawer/app_drawer.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
|
|
@ -2,11 +2,13 @@ import 'dart:async';
|
|||
|
||||
import 'package:aves/model/actions/collection_actions.dart';
|
||||
import 'package:aves/model/actions/entry_actions.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/actions/move_type.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/services/android_app_service.dart';
|
||||
import 'package:aves/services/image_file_service.dart';
|
||||
import 'package:aves/services/image_op_events.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/size_aware.dart';
|
||||
|
@ -22,7 +24,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
|
||||
CollectionSource get source => collection.source;
|
||||
|
||||
Set<ImageEntry> get selection => collection.selection;
|
||||
Set<AvesEntry> get selection => collection.selection;
|
||||
|
||||
EntrySetActionDelegate({
|
||||
@required this.collection,
|
||||
|
@ -46,10 +48,10 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
void onCollectionActionSelected(BuildContext context, CollectionAction action) {
|
||||
switch (action) {
|
||||
case CollectionAction.copy:
|
||||
_moveSelection(context, copy: true);
|
||||
_moveSelection(context, moveType: MoveType.copy);
|
||||
break;
|
||||
case CollectionAction.move:
|
||||
_moveSelection(context, copy: false);
|
||||
_moveSelection(context, moveType: MoveType.move);
|
||||
break;
|
||||
case CollectionAction.refreshMetadata:
|
||||
source.refreshMetadata(selection);
|
||||
|
@ -61,12 +63,12 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _moveSelection(BuildContext context, {@required bool copy}) async {
|
||||
Future<void> _moveSelection(BuildContext context, {@required MoveType moveType}) async {
|
||||
final destinationAlbum = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<String>(
|
||||
settings: RouteSettings(name: AlbumPickPage.routeName),
|
||||
builder: (context) => AlbumPickPage(source: source, copy: copy),
|
||||
builder: (context) => AlbumPickPage(source: source, moveType: moveType),
|
||||
),
|
||||
);
|
||||
if (destinationAlbum == null || destinationAlbum.isEmpty) return;
|
||||
|
@ -74,16 +76,17 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
|
||||
if (!await checkStoragePermission(context, selection)) return;
|
||||
|
||||
if (!await checkFreeSpaceForMove(context, selection, destinationAlbum, copy)) return;
|
||||
if (!await checkFreeSpaceForMove(context, selection, destinationAlbum, moveType)) return;
|
||||
|
||||
final copy = moveType == MoveType.copy;
|
||||
final selectionCount = selection.length;
|
||||
showOpReport<MoveOpEvent>(
|
||||
context: context,
|
||||
selection: selection,
|
||||
opStream: ImageFileService.move(selection, copy: copy, destinationAlbum: destinationAlbum),
|
||||
itemCount: selectionCount,
|
||||
onDone: (processed) async {
|
||||
final movedOps = processed.where((e) => e.success);
|
||||
final movedCount = movedOps.length;
|
||||
final selectionCount = selection.length;
|
||||
if (movedCount < selectionCount) {
|
||||
final count = selectionCount - movedCount;
|
||||
showFeedback(context, 'Failed to ${copy ? 'copy' : 'move'} ${Intl.plural(count, one: '$count item', other: '$count items')}');
|
||||
|
@ -129,14 +132,14 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
|
||||
if (!await checkStoragePermission(context, selection)) return;
|
||||
|
||||
final selectionCount = selection.length;
|
||||
showOpReport<ImageOpEvent>(
|
||||
context: context,
|
||||
selection: selection,
|
||||
opStream: ImageFileService.delete(selection),
|
||||
itemCount: selectionCount,
|
||||
onDone: (processed) {
|
||||
final deletedUris = processed.where((e) => e.success).map((e) => e.uri).toList();
|
||||
final deletedCount = deletedUris.length;
|
||||
final selectionCount = selection.length;
|
||||
if (deletedCount < selectionCount) {
|
||||
final count = selectionCount - deletedCount;
|
||||
showFeedback(context, 'Failed to delete ${Intl.plural(count, one: '$count item', other: '$count items')}');
|
||||
|
|
|
@ -25,7 +25,7 @@ class FilterBar extends StatefulWidget implements PreferredSizeWidget {
|
|||
}
|
||||
|
||||
class _FilterBarState extends State<FilterBar> {
|
||||
final GlobalKey<AnimatedListState> _animatedListKey = GlobalKey();
|
||||
final GlobalKey<AnimatedListState> _animatedListKey = GlobalKey(debugLabel: 'filter-bar-animated-list');
|
||||
CollectionFilter _userRemovedFilter;
|
||||
|
||||
@override
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/section_keys.dart';
|
||||
import 'package:aves/widgets/collection/grid/headers/any.dart';
|
||||
|
@ -6,7 +6,7 @@ import 'package:aves/widgets/common/grid/section_layout.dart';
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider<ImageEntry> {
|
||||
class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider<AvesEntry> {
|
||||
final CollectionLens collection;
|
||||
|
||||
const SectionedEntryListLayoutProvider({
|
||||
|
@ -14,7 +14,7 @@ class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider<Image
|
|||
@required double scrollableWidth,
|
||||
@required int columnCount,
|
||||
@required double tileExtent,
|
||||
@required Widget Function(ImageEntry entry) tileBuilder,
|
||||
@required Widget Function(AvesEntry entry) tileBuilder,
|
||||
@required Widget child,
|
||||
}) : super(
|
||||
scrollableWidth: scrollableWidth,
|
||||
|
@ -28,7 +28,7 @@ class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider<Image
|
|||
bool get showHeaders => collection.showHeaders;
|
||||
|
||||
@override
|
||||
Map<SectionKey, List<ImageEntry>> get sections => collection.sections;
|
||||
Map<SectionKey, List<AvesEntry>> get sections => collection.sections;
|
||||
|
||||
@override
|
||||
double getHeaderExtent(BuildContext context, SectionKey sectionKey) {
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/utils/math_utils.dart';
|
||||
import 'package:aves/widgets/common/extensions/media_query.dart';
|
||||
import 'package:aves/widgets/common/grid/section_layout.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
@ -38,7 +39,7 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
|
|||
|
||||
CollectionLens get collection => widget.collection;
|
||||
|
||||
List<ImageEntry> get entries => collection.sortedEntries;
|
||||
List<AvesEntry> get entries => collection.sortedEntries;
|
||||
|
||||
ScrollController get scrollController => widget.scrollController;
|
||||
|
||||
|
@ -62,7 +63,7 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
|
|||
_lastToIndex = _fromIndex;
|
||||
_scrollableInsets = EdgeInsets.only(
|
||||
top: appBarHeight,
|
||||
bottom: context.read<MediaQueryData>().viewInsets.bottom,
|
||||
bottom: context.read<MediaQueryData>().effectiveBottomPadding,
|
||||
);
|
||||
_scrollSpeedFactor = 0;
|
||||
_pressing = true;
|
||||
|
@ -130,12 +131,12 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
|
|||
}
|
||||
}
|
||||
|
||||
ImageEntry _getEntryAt(Offset localPosition) {
|
||||
AvesEntry _getEntryAt(Offset localPosition) {
|
||||
// as of Flutter v1.22.5, `hitTest` on the `ScrollView` render object works fine when it is static,
|
||||
// but when it is scrolling (through controller animation), result is incomplete and children are missing,
|
||||
// so we use custom layout computation instead to find the entry.
|
||||
final offset = Offset(0, scrollController.offset - appBarHeight) + localPosition;
|
||||
final sectionedListLayout = context.read<SectionedListLayout<ImageEntry>>();
|
||||
final sectionedListLayout = context.read<SectionedListLayout<AvesEntry>>();
|
||||
return sectionedListLayout.getItemAt(offset);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import 'package:aves/main.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/services/viewer_service.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
||||
|
@ -10,7 +10,7 @@ import 'package:flutter/material.dart';
|
|||
|
||||
class InteractiveThumbnail extends StatelessWidget {
|
||||
final CollectionLens collection;
|
||||
final ImageEntry entry;
|
||||
final AvesEntry entry;
|
||||
final double tileExtent;
|
||||
final ValueNotifier<bool> isScrollingNotifier;
|
||||
|
||||
|
@ -53,9 +53,15 @@ class InteractiveThumbnail extends StatelessWidget {
|
|||
Navigator.push(
|
||||
context,
|
||||
TransparentMaterialPageRoute(
|
||||
settings: RouteSettings(name: MultiEntryViewerPage.routeName),
|
||||
pageBuilder: (c, a, sa) => MultiEntryViewerPage(
|
||||
collection: collection,
|
||||
settings: RouteSettings(name: EntryViewerPage.routeName),
|
||||
pageBuilder: (c, a, sa) => EntryViewerPage(
|
||||
collection: CollectionLens(
|
||||
source: collection.source,
|
||||
filters: collection.filters,
|
||||
groupFactor: collection.groupFactor,
|
||||
sortFactor: collection.sortFactor,
|
||||
listenToSource: false,
|
||||
),
|
||||
initialEntry: entry,
|
||||
),
|
||||
),
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/overlay.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/raster.dart';
|
||||
|
@ -6,7 +6,7 @@ import 'package:aves/widgets/collection/thumbnail/vector.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class DecoratedThumbnail extends StatelessWidget {
|
||||
final ImageEntry entry;
|
||||
final AvesEntry entry;
|
||||
final double extent;
|
||||
final CollectionLens collection;
|
||||
final ValueNotifier<bool> isScrollingNotifier;
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/utils/mime_utils.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ErrorThumbnail extends StatelessWidget {
|
||||
final ImageEntry entry;
|
||||
final AvesEntry entry;
|
||||
final double extent;
|
||||
final String tooltip;
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/highlight.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
|
@ -14,7 +14,7 @@ import 'package:provider/provider.dart';
|
|||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class ThumbnailEntryOverlay extends StatelessWidget {
|
||||
final ImageEntry entry;
|
||||
final AvesEntry entry;
|
||||
final double extent;
|
||||
|
||||
const ThumbnailEntryOverlay({
|
||||
|
@ -61,7 +61,7 @@ class ThumbnailEntryOverlay extends StatelessWidget {
|
|||
}
|
||||
|
||||
class ThumbnailSelectionOverlay extends StatelessWidget {
|
||||
final ImageEntry entry;
|
||||
final AvesEntry entry;
|
||||
final double extent;
|
||||
|
||||
const ThumbnailSelectionOverlay({
|
||||
|
@ -121,7 +121,7 @@ class ThumbnailSelectionOverlay extends StatelessWidget {
|
|||
}
|
||||
|
||||
class ThumbnailHighlightOverlay extends StatefulWidget {
|
||||
final ImageEntry entry;
|
||||
final AvesEntry entry;
|
||||
final double extent;
|
||||
|
||||
const ThumbnailHighlightOverlay({
|
||||
|
@ -137,7 +137,7 @@ class ThumbnailHighlightOverlay extends StatefulWidget {
|
|||
class _ThumbnailHighlightOverlayState extends State<ThumbnailHighlightOverlay> {
|
||||
final ValueNotifier<bool> _highlightedNotifier = ValueNotifier(false);
|
||||
|
||||
ImageEntry get entry => widget.entry;
|
||||
AvesEntry get entry => widget.entry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
|
@ -1,17 +1,14 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/image_providers/thumbnail_provider.dart';
|
||||
import 'package:aves/image_providers/uri_image_provider.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/entry_images.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/error.dart';
|
||||
import 'package:aves/widgets/common/fx/transition_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class RasterImageThumbnail extends StatefulWidget {
|
||||
final ImageEntry entry;
|
||||
final AvesEntry entry;
|
||||
final double extent;
|
||||
final int page;
|
||||
final ValueNotifier<bool> isScrollingNotifier;
|
||||
final Object heroTag;
|
||||
|
||||
|
@ -19,7 +16,6 @@ class RasterImageThumbnail extends StatefulWidget {
|
|||
Key key,
|
||||
@required this.entry,
|
||||
@required this.extent,
|
||||
this.page = 0,
|
||||
this.isScrollingNotifier,
|
||||
this.heroTag,
|
||||
}) : super(key: key);
|
||||
|
@ -31,19 +27,12 @@ class RasterImageThumbnail extends StatefulWidget {
|
|||
class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
|
||||
ThumbnailProvider _fastThumbnailProvider, _sizedThumbnailProvider;
|
||||
|
||||
ImageEntry get entry => widget.entry;
|
||||
|
||||
int get page => widget.page;
|
||||
AvesEntry get entry => widget.entry;
|
||||
|
||||
double get extent => widget.extent;
|
||||
|
||||
Object get heroTag => widget.heroTag;
|
||||
|
||||
// we standardize the thumbnail loading dimension by taking the nearest larger power of 2
|
||||
// so that there are less variants of the thumbnails to load and cache
|
||||
// it increases the chance of cache hit when loading similarly sized columns (e.g. on orientation change)
|
||||
double get requestExtent => pow(2, (log(extent) / log(2)).ceil()).toDouble();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
@ -78,14 +67,8 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
|
|||
void _initProvider() {
|
||||
if (!entry.canDecode) return;
|
||||
|
||||
_fastThumbnailProvider = ThumbnailProvider(
|
||||
ThumbnailProviderKey.fromEntry(entry, page: page),
|
||||
);
|
||||
if (!entry.isVideo) {
|
||||
_sizedThumbnailProvider = ThumbnailProvider(
|
||||
ThumbnailProviderKey.fromEntry(entry, page: page, extent: requestExtent),
|
||||
);
|
||||
}
|
||||
_fastThumbnailProvider = entry.getThumbnail();
|
||||
_sizedThumbnailProvider = entry.getThumbnail(extent: extent);
|
||||
}
|
||||
|
||||
void _pauseProvider() {
|
||||
|
@ -148,22 +131,8 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
|
|||
: Hero(
|
||||
tag: heroTag,
|
||||
flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) {
|
||||
ImageProvider heroImageProvider = _fastThumbnailProvider;
|
||||
if (!entry.isVideo) {
|
||||
final imageProvider = UriImage(
|
||||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
page: page,
|
||||
rotationDegrees: entry.rotationDegrees,
|
||||
isFlipped: entry.isFlipped,
|
||||
expectedContentLength: entry.sizeBytes,
|
||||
);
|
||||
if (imageCache.statusForKey(imageProvider).keepAlive) {
|
||||
heroImageProvider = imageProvider;
|
||||
}
|
||||
}
|
||||
return TransitionImage(
|
||||
image: heroImageProvider,
|
||||
image: entry.getBestThumbnail(extent),
|
||||
animation: animation,
|
||||
);
|
||||
},
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import 'package:aves/image_providers/uri_picture_provider.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/settings/entry_background.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/widgets/common/fx/checkered_decoration.dart';
|
||||
|
@ -8,7 +8,7 @@ import 'package:flutter_svg/flutter_svg.dart';
|
|||
import 'package:provider/provider.dart';
|
||||
|
||||
class VectorImageThumbnail extends StatelessWidget {
|
||||
final ImageEntry entry;
|
||||
final AvesEntry entry;
|
||||
final double extent;
|
||||
final Object heroTag;
|
||||
|
||||
|
@ -29,10 +29,10 @@ class VectorImageThumbnail extends StatelessWidget {
|
|||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final availableSize = constraints.biggest;
|
||||
final fitSize = applyBoxFit(fit, entry.getDisplaySize(), availableSize).destination;
|
||||
final fitSize = applyBoxFit(fit, entry.displaySize, availableSize).destination;
|
||||
final offset = fitSize / 2 - availableSize / 2;
|
||||
final child = DecoratedBox(
|
||||
decoration: CheckeredDecoration(checkSize: extent / 8, offset: offset),
|
||||
final child = CustomPaint(
|
||||
painter: CheckeredPainter(checkSize: extent / 8, offset: offset),
|
||||
child: SvgPicture(
|
||||
UriPicture(
|
||||
uri: entry.uri,
|
||||
|
|
|
@ -4,7 +4,7 @@ import 'package:aves/main.dart';
|
|||
import 'package:aves/model/filters/favourite.dart';
|
||||
import 'package:aves/model/filters/mime.dart';
|
||||
import 'package:aves/model/highlight.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
|
@ -16,15 +16,17 @@ import 'package:aves/widgets/collection/grid/section_layout.dart';
|
|||
import 'package:aves/widgets/collection/grid/selector.dart';
|
||||
import 'package:aves/widgets/collection/grid/thumbnail.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||
import 'package:aves/widgets/common/basic/draggable_scrollbar.dart';
|
||||
import 'package:aves/widgets/common/basic/insets.dart';
|
||||
import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/extensions/media_query.dart';
|
||||
import 'package:aves/widgets/common/grid/section_layout.dart';
|
||||
import 'package:aves/widgets/common/grid/sliver.dart';
|
||||
import 'package:aves/widgets/common/identity/scroll_thumb.dart';
|
||||
import 'package:aves/widgets/common/providers/highlight_info_provider.dart';
|
||||
import 'package:aves/widgets/common/scaling.dart';
|
||||
import 'package:aves/widgets/common/tile_extent_manager.dart';
|
||||
import 'package:draggable_scrollbar/draggable_scrollbar.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||||
|
@ -34,7 +36,7 @@ class ThumbnailCollection extends StatelessWidget {
|
|||
final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0);
|
||||
final ValueNotifier<double> _tileExtentNotifier = ValueNotifier(0);
|
||||
final ValueNotifier<bool> _isScrollingNotifier = ValueNotifier(false);
|
||||
final GlobalKey _scrollableKey = GlobalKey();
|
||||
final GlobalKey _scrollableKey = GlobalKey(debugLabel: 'thumbnail-collection-scrollable');
|
||||
|
||||
static const columnCountDefault = 4;
|
||||
static const extentMin = 46.0;
|
||||
|
@ -44,6 +46,7 @@ class ThumbnailCollection extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return HighlightInfoProvider(
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final viewportSize = constraints.biggest;
|
||||
|
@ -79,7 +82,7 @@ class ThumbnailCollection extends StatelessWidget {
|
|||
),
|
||||
);
|
||||
|
||||
final scaler = GridScaleGestureDetector<ImageEntry>(
|
||||
final scaler = GridScaleGestureDetector<AvesEntry>(
|
||||
tileExtentManager: tileExtentManager,
|
||||
scrollableKey: _scrollableKey,
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
|
@ -103,7 +106,7 @@ class ThumbnailCollection extends StatelessWidget {
|
|||
highlightable: false,
|
||||
),
|
||||
getScaledItemTileRect: (context, entry) {
|
||||
final sectionedListLayout = context.read<SectionedListLayout<ImageEntry>>();
|
||||
final sectionedListLayout = context.read<SectionedListLayout<AvesEntry>>();
|
||||
return sectionedListLayout.getTileRect(entry) ?? Rect.zero;
|
||||
},
|
||||
onScaled: (entry) => Provider.of<HighlightInfo>(context, listen: false).add(entry),
|
||||
|
@ -192,10 +195,12 @@ class _CollectionScrollViewState extends State<CollectionScrollView> {
|
|||
}
|
||||
|
||||
void _registerWidget(CollectionScrollView widget) {
|
||||
widget.collection.filterChangeNotifier.addListener(_onFilterChange);
|
||||
widget.scrollController.addListener(_onScrollChange);
|
||||
}
|
||||
|
||||
void _unregisterWidget(CollectionScrollView widget) {
|
||||
widget.collection.filterChangeNotifier.removeListener(_onFilterChange);
|
||||
widget.scrollController.removeListener(_onScrollChange);
|
||||
}
|
||||
|
||||
|
@ -220,15 +225,8 @@ class _CollectionScrollViewState extends State<CollectionScrollView> {
|
|||
child: _buildEmptyCollectionPlaceholder(collection),
|
||||
hasScrollBody: false,
|
||||
)
|
||||
: SectionedListSliver<ImageEntry>(),
|
||||
SliverToBoxAdapter(
|
||||
child: Selector<MediaQueryData, double>(
|
||||
selector: (context, mq) => mq.viewInsets.bottom,
|
||||
builder: (context, mqViewInsetsBottom, child) {
|
||||
return SizedBox(height: mqViewInsetsBottom);
|
||||
},
|
||||
),
|
||||
),
|
||||
: SectionedListSliver<AvesEntry>(),
|
||||
BottomPaddingSliver(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
@ -237,10 +235,10 @@ class _CollectionScrollViewState extends State<CollectionScrollView> {
|
|||
return ValueListenableBuilder<double>(
|
||||
valueListenable: widget.appBarHeightNotifier,
|
||||
builder: (context, appBarHeight, child) => Selector<MediaQueryData, double>(
|
||||
selector: (context, mq) => mq.viewInsets.bottom,
|
||||
builder: (context, mqViewInsetsBottom, child) => DraggableScrollbar(
|
||||
heightScrollThumb: avesScrollThumbHeight,
|
||||
selector: (context, mq) => mq.effectiveBottomPadding,
|
||||
builder: (context, mqPaddingBottom, child) => DraggableScrollbar(
|
||||
backgroundColor: Colors.white,
|
||||
scrollThumbHeight: avesScrollThumbHeight,
|
||||
scrollThumbBuilder: avesScrollThumbBuilder(
|
||||
height: avesScrollThumbHeight,
|
||||
backgroundColor: Colors.white,
|
||||
|
@ -249,7 +247,7 @@ class _CollectionScrollViewState extends State<CollectionScrollView> {
|
|||
padding: EdgeInsets.only(
|
||||
// padding to keep scroll thumb between app bar above and nav bar below
|
||||
top: appBarHeight,
|
||||
bottom: mqViewInsetsBottom,
|
||||
bottom: mqPaddingBottom,
|
||||
),
|
||||
child: scrollView,
|
||||
),
|
||||
|
@ -285,6 +283,8 @@ class _CollectionScrollViewState extends State<CollectionScrollView> {
|
|||
);
|
||||
}
|
||||
|
||||
void _onFilterChange() => widget.scrollController.jumpTo(0);
|
||||
|
||||
void _onScrollChange() {
|
||||
widget.isScrollingNotifier.value = true;
|
||||
_stopScrollMonitoringTimer();
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/services/image_file_service.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:flushbar/flushbar.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -27,19 +25,66 @@ mixin FeedbackMixin {
|
|||
|
||||
// report overlay for multiple operations
|
||||
|
||||
OverlayEntry _opReportOverlayEntry;
|
||||
|
||||
void showOpReport<T extends ImageOpEvent>({
|
||||
void showOpReport<T>({
|
||||
@required BuildContext context,
|
||||
@required Set<ImageEntry> selection,
|
||||
@required Stream<T> opStream,
|
||||
@required void Function(Set<T> processed) onDone,
|
||||
@required int itemCount,
|
||||
void Function(Set<T> processed) onDone,
|
||||
}) {
|
||||
final processed = <T>{};
|
||||
OverlayEntry _opReportOverlayEntry;
|
||||
_opReportOverlayEntry = OverlayEntry(
|
||||
builder: (context) => ReportOverlay<T>(
|
||||
opStream: opStream,
|
||||
itemCount: itemCount,
|
||||
onDone: (processed) {
|
||||
_opReportOverlayEntry.remove();
|
||||
onDone?.call(processed);
|
||||
},
|
||||
),
|
||||
);
|
||||
Overlay.of(context).insert(_opReportOverlayEntry);
|
||||
}
|
||||
}
|
||||
|
||||
class ReportOverlay<T> extends StatefulWidget {
|
||||
final Stream<T> opStream;
|
||||
final int itemCount;
|
||||
final void Function(Set<T> processed) onDone;
|
||||
|
||||
const ReportOverlay({
|
||||
@required this.opStream,
|
||||
@required this.itemCount,
|
||||
@required this.onDone,
|
||||
});
|
||||
|
||||
@override
|
||||
_ReportOverlayState createState() => _ReportOverlayState<T>();
|
||||
}
|
||||
|
||||
class _ReportOverlayState<T> extends State<ReportOverlay<T>> with SingleTickerProviderStateMixin {
|
||||
final processed = <T>{};
|
||||
AnimationController _animationController;
|
||||
Animation<double> _animation;
|
||||
|
||||
Stream<T> get opStream => widget.opStream;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_animationController = AnimationController(
|
||||
duration: Durations.collectionOpOverlayAnimation,
|
||||
vsync: this,
|
||||
);
|
||||
_animation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOutQuad,
|
||||
);
|
||||
_animationController.forward();
|
||||
|
||||
// do not handle completion inside `StreamBuilder`
|
||||
// as it could be called multiple times
|
||||
Future<void> onComplete() => _hideOpReportOverlay().then((_) => onDone(processed));
|
||||
Future<void> onComplete() => _animationController.reverse().then((_) => widget.onDone(processed));
|
||||
opStream.listen(
|
||||
processed.add,
|
||||
onError: (error) {
|
||||
|
@ -48,17 +93,34 @@ mixin FeedbackMixin {
|
|||
},
|
||||
onDone: onComplete,
|
||||
);
|
||||
}
|
||||
|
||||
_opReportOverlayEntry = OverlayEntry(
|
||||
builder: (context) {
|
||||
return AbsorbPointer(
|
||||
child: StreamBuilder<T>(
|
||||
stream: opStream,
|
||||
builder: (context, snapshot) {
|
||||
Widget child = SizedBox.shrink();
|
||||
if (!snapshot.hasError) {
|
||||
final percent = processed.length.toDouble() / selection.length;
|
||||
child = CircularPercentIndicator(
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AbsorbPointer(
|
||||
child: StreamBuilder<T>(
|
||||
stream: opStream,
|
||||
builder: (context, snapshot) {
|
||||
final percent = processed.length.toDouble() / widget.itemCount;
|
||||
return FadeTransition(
|
||||
opacity: _animation,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
Colors.black,
|
||||
Colors.black54,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: CircularPercentIndicator(
|
||||
percent: percent,
|
||||
lineWidth: 16,
|
||||
radius: 160,
|
||||
|
@ -67,22 +129,11 @@ mixin FeedbackMixin {
|
|||
animation: true,
|
||||
center: Text(NumberFormat.percentPattern().format(percent)),
|
||||
animateFromLastPercent: true,
|
||||
);
|
||||
}
|
||||
return AnimatedSwitcher(
|
||||
duration: Durations.collectionOpOverlayAnimation,
|
||||
child: child,
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
Overlay.of(context).insert(_opReportOverlayEntry);
|
||||
}
|
||||
|
||||
Future<void> _hideOpReportOverlay() async {
|
||||
await Future.delayed(Durations.collectionOpOverlayAnimation * timeDilation);
|
||||
_opReportOverlayEntry.remove();
|
||||
_opReportOverlayEntry = null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/services/android_file_service.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
mixin PermissionAwareMixin {
|
||||
Future<bool> checkStoragePermission(BuildContext context, Set<ImageEntry> entries) {
|
||||
Future<bool> checkStoragePermission(BuildContext context, Set<AvesEntry> entries) {
|
||||
return checkStoragePermissionForAlbums(context, entries.where((e) => e.path != null).map((e) => e.directory).toSet());
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/actions/move_type.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/services/android_file_service.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/utils/file_utils.dart';
|
||||
|
@ -11,21 +12,30 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/widgets.dart';
|
||||
|
||||
mixin SizeAwareMixin {
|
||||
Future<bool> checkFreeSpaceForMove(BuildContext context, Set<ImageEntry> selection, String destinationAlbum, bool copy) async {
|
||||
Future<bool> checkFreeSpaceForMove(
|
||||
BuildContext context,
|
||||
Set<AvesEntry> selection,
|
||||
String destinationAlbum,
|
||||
MoveType moveType,
|
||||
) async {
|
||||
final destinationVolume = androidFileUtils.getStorageVolume(destinationAlbum);
|
||||
final free = await AndroidFileService.getFreeSpace(destinationVolume);
|
||||
int needed;
|
||||
int sumSize(sum, entry) => sum + entry.sizeBytes;
|
||||
if (copy) {
|
||||
needed = selection.fold(0, sumSize);
|
||||
} else {
|
||||
// when moving, we only need space for the entries that are not already on the destination volume
|
||||
final byVolume = groupBy<ImageEntry, StorageVolume>(selection, (entry) => androidFileUtils.getStorageVolume(entry.path));
|
||||
final otherVolumes = byVolume.keys.where((volume) => volume != destinationVolume);
|
||||
final fromOtherVolumes = otherVolumes.fold<int>(0, (sum, volume) => sum + byVolume[volume].fold(0, sumSize));
|
||||
// and we need at least as much space as the largest entry because individual entries are copied then deleted
|
||||
final largestSingle = selection.fold<int>(0, (largest, entry) => max(largest, entry.sizeBytes));
|
||||
needed = max(fromOtherVolumes, largestSingle);
|
||||
switch (moveType) {
|
||||
case MoveType.copy:
|
||||
case MoveType.export:
|
||||
needed = selection.fold(0, sumSize);
|
||||
break;
|
||||
case MoveType.move:
|
||||
// when moving, we only need space for the entries that are not already on the destination volume
|
||||
final byVolume = groupBy<AvesEntry, StorageVolume>(selection, (entry) => androidFileUtils.getStorageVolume(entry.path));
|
||||
final otherVolumes = byVolume.keys.where((volume) => volume != destinationVolume);
|
||||
final fromOtherVolumes = otherVolumes.fold<int>(0, (sum, volume) => sum + byVolume[volume].fold(0, sumSize));
|
||||
// and we need at least as much space as the largest entry because individual entries are copied then deleted
|
||||
final largestSingle = selection.fold<int>(0, (largest, entry) => max(largest, entry.sizeBytes));
|
||||
needed = max(fromOtherVolumes, largestSingle);
|
||||
break;
|
||||
}
|
||||
|
||||
final hasEnoughSpace = needed < free;
|
||||
|
|
388
lib/widgets/common/basic/draggable_scrollbar.dart
Normal file
388
lib/widgets/common/basic/draggable_scrollbar.dart
Normal file
|
@ -0,0 +1,388 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/*
|
||||
This is derived from `draggable_scrollbar` package v0.0.4:
|
||||
- removed default thumb builders
|
||||
- allow any `ScrollView` as child
|
||||
- allow any `Widget` as label content
|
||||
- moved out constraints responsibility
|
||||
- various extent & thumb positioning fixes
|
||||
*/
|
||||
|
||||
/// Build the Scroll Thumb and label using the current configuration
|
||||
typedef ScrollThumbBuilder = Widget Function(
|
||||
Color backgroundColor,
|
||||
Animation<double> thumbAnimation,
|
||||
Animation<double> labelAnimation,
|
||||
double height, {
|
||||
Widget labelText,
|
||||
});
|
||||
|
||||
/// Build a Text widget using the current scroll offset
|
||||
typedef LabelTextBuilder = Widget Function(double offsetY);
|
||||
|
||||
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
|
||||
/// for quick navigation of the BoxScrollView.
|
||||
class DraggableScrollbar extends StatefulWidget {
|
||||
/// The background color of the label and thumb
|
||||
final Color backgroundColor;
|
||||
|
||||
/// The height of the scroll thumb
|
||||
final double scrollThumbHeight;
|
||||
|
||||
/// A function that builds a thumb using the current configuration
|
||||
final ScrollThumbBuilder scrollThumbBuilder;
|
||||
|
||||
/// The amount of padding that should surround the thumb
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
/// Determines how quickly the scrollbar will animate in and out
|
||||
final Duration scrollbarAnimationDuration;
|
||||
|
||||
/// How long should the thumb be visible before fading out
|
||||
final Duration scrollbarTimeToFade;
|
||||
|
||||
/// Build a Text widget from the current offset in the BoxScrollView
|
||||
final LabelTextBuilder labelTextBuilder;
|
||||
|
||||
/// The ScrollController for the BoxScrollView
|
||||
final ScrollController controller;
|
||||
|
||||
/// The view that will be scrolled with the scroll thumb
|
||||
final ScrollView child;
|
||||
|
||||
DraggableScrollbar({
|
||||
Key key,
|
||||
@required this.backgroundColor,
|
||||
@required this.scrollThumbHeight,
|
||||
@required this.scrollThumbBuilder,
|
||||
@required this.controller,
|
||||
this.padding,
|
||||
this.scrollbarAnimationDuration = const Duration(milliseconds: 300),
|
||||
this.scrollbarTimeToFade = const Duration(milliseconds: 600),
|
||||
this.labelTextBuilder,
|
||||
@required this.child,
|
||||
}) : assert(controller != null),
|
||||
assert(scrollThumbBuilder != null),
|
||||
assert(child.scrollDirection == Axis.vertical),
|
||||
super(key: key);
|
||||
|
||||
@override
|
||||
_DraggableScrollbarState createState() => _DraggableScrollbarState();
|
||||
|
||||
static Widget buildScrollThumbAndLabel({
|
||||
@required Widget scrollThumb,
|
||||
@required Color backgroundColor,
|
||||
@required Animation<double> thumbAnimation,
|
||||
@required Animation<double> labelAnimation,
|
||||
@required Widget labelText,
|
||||
}) {
|
||||
final scrollThumbAndLabel = labelText == null
|
||||
? scrollThumb
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ScrollLabel(
|
||||
animation: labelAnimation,
|
||||
child: labelText,
|
||||
backgroundColor: backgroundColor,
|
||||
),
|
||||
scrollThumb,
|
||||
],
|
||||
);
|
||||
return SlideFadeTransition(
|
||||
animation: thumbAnimation,
|
||||
child: scrollThumbAndLabel,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ScrollLabel extends StatelessWidget {
|
||||
final Animation<double> animation;
|
||||
final Color backgroundColor;
|
||||
final Widget child;
|
||||
|
||||
const ScrollLabel({
|
||||
Key key,
|
||||
@required this.child,
|
||||
@required this.animation,
|
||||
@required this.backgroundColor,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: Container(
|
||||
margin: EdgeInsets.only(right: 12.0),
|
||||
child: Material(
|
||||
elevation: 4.0,
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.all(Radius.circular(16.0)),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DraggableScrollbarState extends State<DraggableScrollbar> with TickerProviderStateMixin {
|
||||
final ValueNotifier<double> _thumbOffsetNotifier = ValueNotifier(0), _viewOffsetNotifier = ValueNotifier(0);
|
||||
bool _isDragInProcess = false;
|
||||
|
||||
AnimationController _thumbAnimationController;
|
||||
Animation<double> _thumbAnimation;
|
||||
AnimationController _labelAnimationController;
|
||||
Animation<double> _labelAnimation;
|
||||
Timer _fadeoutTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_thumbAnimationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: widget.scrollbarAnimationDuration,
|
||||
);
|
||||
|
||||
_thumbAnimation = CurvedAnimation(
|
||||
parent: _thumbAnimationController,
|
||||
curve: Curves.fastOutSlowIn,
|
||||
);
|
||||
|
||||
_labelAnimationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: widget.scrollbarAnimationDuration,
|
||||
);
|
||||
|
||||
_labelAnimation = CurvedAnimation(
|
||||
parent: _labelAnimationController,
|
||||
curve: Curves.fastOutSlowIn,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_thumbAnimationController.dispose();
|
||||
_labelAnimationController.dispose();
|
||||
_fadeoutTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
ScrollController get controller => widget.controller;
|
||||
|
||||
double get thumbMaxScrollExtent => context.size.height - widget.scrollThumbHeight - (widget.padding?.vertical ?? 0.0);
|
||||
|
||||
double get thumbMinScrollExtent => 0.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
_onScrollNotification(notification);
|
||||
return false;
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
RepaintBoundary(
|
||||
child: widget.child,
|
||||
),
|
||||
RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onVerticalDragStart: _onVerticalDragStart,
|
||||
onVerticalDragUpdate: _onVerticalDragUpdate,
|
||||
onVerticalDragEnd: _onVerticalDragEnd,
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: _thumbOffsetNotifier,
|
||||
builder: (context, thumbOffset, child) => Container(
|
||||
alignment: AlignmentDirectional.topEnd,
|
||||
padding: EdgeInsets.only(top: thumbOffset) + widget.padding,
|
||||
child: widget.scrollThumbBuilder(
|
||||
widget.backgroundColor,
|
||||
_thumbAnimation,
|
||||
_labelAnimation,
|
||||
widget.scrollThumbHeight,
|
||||
labelText: (widget.labelTextBuilder != null && _isDragInProcess)
|
||||
? ValueListenableBuilder(
|
||||
valueListenable: _viewOffsetNotifier,
|
||||
builder: (context, viewOffset, child) => widget.labelTextBuilder(viewOffset + thumbOffset),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onScrollNotification(ScrollNotification notification) {
|
||||
final scrollMetrics = notification.metrics;
|
||||
|
||||
// do not update the thumb if we cannot actually scroll
|
||||
if (scrollMetrics.minScrollExtent >= scrollMetrics.maxScrollExtent) return;
|
||||
|
||||
_viewOffsetNotifier.value = scrollMetrics.pixels;
|
||||
|
||||
// we update the thumb position from the scrolled offset
|
||||
// when the user is not dragging the thumb
|
||||
if (!_isDragInProcess) {
|
||||
if (notification is ScrollUpdateNotification) {
|
||||
_thumbOffsetNotifier.value = (scrollMetrics.pixels / scrollMetrics.maxScrollExtent * thumbMaxScrollExtent).clamp(thumbMinScrollExtent, thumbMaxScrollExtent);
|
||||
}
|
||||
|
||||
if (notification is ScrollUpdateNotification || notification is OverscrollNotification) {
|
||||
_showThumb();
|
||||
_scheduleFadeout();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onVerticalDragStart(DragStartDetails details) {
|
||||
_labelAnimationController.forward();
|
||||
_fadeoutTimer?.cancel();
|
||||
setState(() => _isDragInProcess = true);
|
||||
}
|
||||
|
||||
void _onVerticalDragUpdate(DragUpdateDetails details) {
|
||||
_showThumb();
|
||||
if (_isDragInProcess) {
|
||||
// thumb offset
|
||||
_thumbOffsetNotifier.value = (_thumbOffsetNotifier.value + details.delta.dy).clamp(thumbMinScrollExtent, thumbMaxScrollExtent);
|
||||
|
||||
// scroll offset
|
||||
final min = controller.position.minScrollExtent;
|
||||
final max = controller.position.maxScrollExtent;
|
||||
controller.jumpTo((_thumbOffsetNotifier.value / thumbMaxScrollExtent * max).clamp(min, max));
|
||||
}
|
||||
}
|
||||
|
||||
void _onVerticalDragEnd(DragEndDetails details) {
|
||||
_scheduleFadeout();
|
||||
setState(() => _isDragInProcess = false);
|
||||
}
|
||||
|
||||
void _showThumb() {
|
||||
if (_thumbAnimationController.status != AnimationStatus.forward) {
|
||||
_thumbAnimationController.forward();
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleFadeout() {
|
||||
_fadeoutTimer?.cancel();
|
||||
_fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
|
||||
_thumbAnimationController.reverse();
|
||||
_labelAnimationController.reverse();
|
||||
_fadeoutTimer = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws 2 triangles like arrow up and arrow down
|
||||
class ArrowCustomPainter extends CustomPainter {
|
||||
Color color;
|
||||
|
||||
ArrowCustomPainter(this.color);
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()..color = color;
|
||||
const width = 12.0;
|
||||
const height = 8.0;
|
||||
final baseX = size.width / 2;
|
||||
final baseY = size.height / 2;
|
||||
|
||||
canvas.drawPath(
|
||||
_trianglePath(Offset(baseX, baseY - 2.0), width, height, true),
|
||||
paint,
|
||||
);
|
||||
canvas.drawPath(
|
||||
_trianglePath(Offset(baseX, baseY + 2.0), width, height, false),
|
||||
paint,
|
||||
);
|
||||
}
|
||||
|
||||
static Path _trianglePath(Offset o, double width, double height, bool isUp) {
|
||||
return Path()
|
||||
..moveTo(o.dx, o.dy)
|
||||
..lineTo(o.dx + width, o.dy)
|
||||
..lineTo(o.dx + (width / 2), isUp ? o.dy - height : o.dy + height)
|
||||
..close();
|
||||
}
|
||||
}
|
||||
|
||||
///This cut 2 lines in arrow shape
|
||||
class ArrowClipper extends CustomClipper<Path> {
|
||||
@override
|
||||
Path getClip(Size size) {
|
||||
final path = Path();
|
||||
path.lineTo(0.0, size.height);
|
||||
path.lineTo(size.width, size.height);
|
||||
path.lineTo(size.width, 0.0);
|
||||
path.lineTo(0.0, 0.0);
|
||||
path.close();
|
||||
|
||||
const arrowWidth = 8.0;
|
||||
final startPointX = (size.width - arrowWidth) / 2;
|
||||
var startPointY = size.height / 2 - arrowWidth / 2;
|
||||
path.moveTo(startPointX, startPointY);
|
||||
path.lineTo(startPointX + arrowWidth / 2, startPointY - arrowWidth / 2);
|
||||
path.lineTo(startPointX + arrowWidth, startPointY);
|
||||
path.lineTo(startPointX + arrowWidth, startPointY + 1.0);
|
||||
path.lineTo(startPointX + arrowWidth / 2, startPointY - arrowWidth / 2 + 1.0);
|
||||
path.lineTo(startPointX, startPointY + 1.0);
|
||||
path.close();
|
||||
|
||||
startPointY = size.height / 2 + arrowWidth / 2;
|
||||
path.moveTo(startPointX + arrowWidth, startPointY);
|
||||
path.lineTo(startPointX + arrowWidth / 2, startPointY + arrowWidth / 2);
|
||||
path.lineTo(startPointX, startPointY);
|
||||
path.lineTo(startPointX, startPointY - 1.0);
|
||||
path.lineTo(startPointX + arrowWidth / 2, startPointY + arrowWidth / 2 - 1.0);
|
||||
path.lineTo(startPointX + arrowWidth, startPointY - 1.0);
|
||||
path.close();
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldReclip(CustomClipper<Path> oldClipper) => false;
|
||||
}
|
||||
|
||||
class SlideFadeTransition extends StatelessWidget {
|
||||
final Animation<double> animation;
|
||||
final Widget child;
|
||||
|
||||
const SlideFadeTransition({
|
||||
Key key,
|
||||
@required this.animation,
|
||||
@required this.child,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) => animation.value == 0.0 ? Container() : child,
|
||||
child: SlideTransition(
|
||||
position: Tween(
|
||||
begin: Offset(0.3, 0.0),
|
||||
end: Offset(0.0, 0.0),
|
||||
).animate(animation),
|
||||
child: FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
62
lib/widgets/common/basic/insets.dart
Normal file
62
lib/widgets/common/basic/insets.dart
Normal file
|
@ -0,0 +1,62 @@
|
|||
import 'package:aves/widgets/common/extensions/media_query.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
// This widget should be added on top of Scaffolds with:
|
||||
// - `resizeToAvoidBottomInset` set to false,
|
||||
// - a vertically scrollable body.
|
||||
// It will prevent the body from scrolling when a user swipe from bottom to use Android Q style navigation gestures.
|
||||
class BottomGestureAreaProtector extends StatelessWidget {
|
||||
// as of Flutter v1.22.5, `systemGestureInsets` from `MediaQuery` mistakenly reports no bottom inset,
|
||||
// so we use an empirical measurement instead
|
||||
static const double systemGestureInsetsBottom = 32;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Selector<MediaQueryData, double>(
|
||||
selector: (c, mq) => mq.effectiveBottomPadding,
|
||||
builder: (c, mqPaddingBottom, child) {
|
||||
// devices with physical navigation buttons have no bottom insets
|
||||
// we assume these devices do not use gesture navigation
|
||||
if (mqPaddingBottom == 0) return SizedBox();
|
||||
return Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: systemGestureInsetsBottom,
|
||||
child: AbsorbPointer(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GestureAreaProtectorStack extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const GestureAreaProtectorStack({@required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
child,
|
||||
BottomGestureAreaProtector(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BottomPaddingSliver extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Selector<MediaQueryData, double>(
|
||||
selector: (context, mq) => mq.effectiveBottomPadding,
|
||||
builder: (context, mqPaddingBottom, child) {
|
||||
return SizedBox(height: mqPaddingBottom);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -44,7 +44,7 @@ class LinkChip extends StatelessWidget {
|
|||
SizedBox(width: 8),
|
||||
Builder(
|
||||
builder: (context) => Icon(
|
||||
AIcons.openInNew,
|
||||
AIcons.openOutside,
|
||||
size: DefaultTextStyle.of(context).style.fontSize,
|
||||
color: color,
|
||||
),
|
||||
|
|
|
@ -1,9 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
extension ExtraContext on BuildContext {
|
||||
String get currentRouteName => ModalRoute.of(this)?.settings?.name;
|
||||
}
|
||||
|
||||
class DirectMaterialPageRoute<T> extends PageRouteBuilder<T> {
|
||||
DirectMaterialPageRoute({
|
||||
RouteSettings settings,
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue