Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2021-01-31 15:21:25 +09:00
commit 5fbc9eebe5
174 changed files with 4115 additions and 2258 deletions

View file

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

View file

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

View file

@ -3,6 +3,22 @@ All notable changes to this project will be documented in this file.
## [Unreleased] ## [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 ## [v1.3.2] - 2021-01-17
### Added ### Added
Collection: identify multipage TIFF & multitrack HEIC/HEIF Collection: identify multipage TIFF & multitrack HEIC/HEIF

View file

@ -12,7 +12,7 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
## Features ## 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 animated images: GIF, WEBP
- support raw images: ARW, CR2, DNG, NEF, NRW, ORF, PEF, RAF, RW2, SRW - support raw images: ARW, CR2, DNG, NEF, NRW, ORF, PEF, RAF, RW2, SRW
- support vector images: SVG - 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 | | 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-P580 | Samsung Galaxy Tab A 10.1 | 8.1.0 (Oreo) | 27 |
| SM-G930S | Samsung Galaxy S7 | 8.0.0 (Oreo) | 26 | | SM-G930S | Samsung Galaxy S7 | 8.0.0 (Oreo) | 26 |
| E5823 | Sony Xperia Z5 Compact | 7.1.1 (Nougat) | 25 |
## Project Setup ## Project Setup

View file

@ -98,15 +98,15 @@ repositories {
dependencies { dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9' 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 'androidx.exifinterface:exifinterface:1.3.2'
implementation 'com.commonsware.cwac:document:0.4.1' implementation 'com.commonsware.cwac:document:0.4.1'
implementation 'com.drewnoakes:metadata-extractor:2.15.0' implementation 'com.drewnoakes:metadata-extractor:2.15.0'
implementation 'com.github.deckerst:Android-TiffBitmapFactory:f87db4305d' // forked, built by JitPack 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 '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') compileOnly rootProject.findProject(':streams_channel')
} }

View file

@ -40,7 +40,6 @@
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent> </intent>
</queries> </queries>

View file

@ -16,24 +16,18 @@ import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.PermissionManager import deckers.thibault.aves.utils.PermissionManager
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
companion object { private lateinit var contentStreamHandler: ContentChangeStreamHandler
private val LOG_TAG = LogUtils.createTag(MainActivity::class.java) private lateinit var intentStreamHandler: IntentStreamHandler
const val INTENT_CHANNEL = "deckers.thibault/aves/intent"
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer"
}
private val intentStreamHandler = IntentStreamHandler()
private lateinit var intentDataMap: MutableMap<String, Any?> private lateinit var intentDataMap: MutableMap<String, Any?>
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
Log.i(LOG_TAG, "onCreate intent=$intent") Log.i(LOG_TAG, "onCreate intent=$intent")
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
intentDataMap = extractIntentData(intent)
val messenger = flutterEngine!!.dartExecutor.binaryMessenger val messenger = flutterEngine!!.dartExecutor.binaryMessenger
MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this)) 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, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(this, args) }
StreamsChannel(messenger, StorageAccessStreamHandler.CHANNEL).setStreamHandlerFactory { args -> StorageAccessStreamHandler(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 -> MethodChannel(messenger, VIEWER_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) { when (call.method) {
"getIntentData" -> { "getIntentData" -> {
result.success(intentDataMap) result.success(intentDataMap)
intentDataMap.clear() intentDataMap.clear()
} }
"pick" -> { "pick" -> pick(call)
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()
}
} }
} }
EventChannel(messenger, INTENT_CHANNEL).setStreamHandler(intentStreamHandler)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
setupShortcuts() setupShortcuts()
} }
} }
@RequiresApi(Build.VERSION_CODES.N_MR1) override fun onDestroy() {
private fun setupShortcuts() { contentStreamHandler.dispose()
// do not use 'route' as extra key, as the Flutter framework acts on it super.onDestroy()
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 onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
@ -109,6 +78,25 @@ class MainActivity : FlutterActivity() {
intentStreamHandler.notifyNewIntent(extractIntentData(intent)) 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?> { private fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
when (intent?.action) { when (intent?.action) {
Intent.ACTION_MAIN -> { Intent.ACTION_MAIN -> {
@ -138,22 +126,48 @@ class MainActivity : FlutterActivity() {
return HashMap() return HashMap()
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { private fun pick(call: MethodCall) {
if (requestCode == PermissionManager.VOLUME_ACCESS_REQUEST_CODE) { val pickedUri = call.argument<String>("uri")
val treeUri = data?.data if (pickedUri != null) {
if (resultCode != RESULT_OK || treeUri == null) { val intent = Intent().apply {
PermissionManager.onPermissionResult(requestCode, null) data = Uri.parse(pickedUri)
return addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} }
setResult(RESULT_OK, intent)
// save access permissions across reboots } else {
val takeFlags = (data.flags setResult(RESULT_CANCELED)
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)
} }
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"
} }
} }

View file

@ -12,6 +12,8 @@ import androidx.core.content.FileProvider
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
@ -28,8 +30,8 @@ import kotlin.math.roundToInt
class AppAdapterHandler(private val context: Context) : MethodCallHandler { class AppAdapterHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"getAppIcon" -> GlobalScope.launch(Dispatchers.IO) { getAppIcon(call, Coresult(result)) } "getPackages" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPackages) }
"getAppNames" -> GlobalScope.launch(Dispatchers.IO) { getAppNames(Coresult(result)) } "getAppIcon" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getAppIcon) }
"edit" -> { "edit" -> {
val title = call.argument<String>("title") val title = call.argument<String>("title")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } 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) { private fun getPackages(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
val nameMap = HashMap<String, String>() val packages = HashMap<String, FieldMap>()
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)
// apps tend to use their name in English when creating folders fun addPackageDetails(intent: Intent) {
// so we get their names in English as well as the current locale // apps tend to use their name in English when creating folders
val englishConfig = Configuration().apply { setLocale(Locale.ENGLISH) } // so we get their names in English as well as the current locale
val englishConfig = Configuration().apply { setLocale(Locale.ENGLISH) }
val pm = context.packageManager val pm = context.packageManager
for (resolveInfo in pm.queryIntentActivities(intent, 0)) { for (resolveInfo in pm.queryIntentActivities(intent, 0)) {
val ai = resolveInfo.activityInfo.applicationInfo val appInfo = resolveInfo.activityInfo.applicationInfo
val isSystemPackage = ai.flags and ApplicationInfo.FLAG_SYSTEM != 0 val packageName = appInfo.packageName
if (!isSystemPackage) { if (!packages.containsKey(packageName)) {
val packageName = ai.packageName val currentLabel = pm.getApplicationLabel(appInfo).toString()
val englishLabel: String? = appInfo.labelRes.takeIf { it != 0 }?.let { labelRes ->
val currentLabel = pm.getApplicationLabel(ai).toString() var englishLabel: String? = null
nameMap[currentLabel] = packageName try {
val resources = pm.getResourcesForApplication(appInfo)
val labelRes = ai.labelRes // `updateConfiguration` is deprecated but it seems to be the only way
if (labelRes != 0) { // to query resources from another app with a specific locale.
try { // The following methods do not work:
val resources = pm.getResourcesForApplication(ai) // - `resources.getConfiguration().setLocale(...)`
// `updateConfiguration` is deprecated but it seems to be the only way // - getting a package manager from a custom context with `context.createConfigurationContext(config)`
// to query resources from another app with a specific locale. @Suppress("DEPRECATION")
// The following methods do not work: resources.updateConfiguration(englishConfig, resources.displayMetrics)
// - `resources.getConfiguration().setLocale(...)` englishLabel = resources.getString(labelRes)
// - getting a package manager from a custom context with `context.createConfigurationContext(config)` } catch (e: PackageManager.NameNotFoundException) {
@Suppress("DEPRECATION") Log.w(LOG_TAG, "failed to get app label in English for packageName=$packageName", e)
resources.updateConfiguration(englishConfig, resources.displayMetrics) }
val englishLabel = resources.getString(labelRes) englishLabel
nameMap[englishLabel] = packageName
} catch (e: PackageManager.NameNotFoundException) {
Log.w(LOG_TAG, "failed to get app label in English for packageName=$packageName", e)
} }
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) { private fun getAppIcon(call: MethodCall, result: MethodChannel.Result) {

View file

@ -1,9 +1,11 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.reflect.KSuspendFunction2
// ensure `result` methods are called on the main looper thread // ensure `result` methods are called on the main looper thread
class Coresult internal constructor(private val methodResult: MethodChannel.Result) : MethodChannel.Result { 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() { override fun notImplemented() {
mainScope.launch { methodResult.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())
}
}
}
} }

View file

@ -12,10 +12,11 @@ import android.util.Log
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import com.drew.imaging.ImageMetadataReader import com.drew.imaging.ImageMetadataReader
import com.drew.metadata.file.FileTypeDirectory 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.ExifInterfaceHelper
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
import deckers.thibault.aves.metadata.Metadata 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.LogUtils
import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
@ -37,12 +38,12 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
when (call.method) { when (call.method) {
"getContextDirs" -> result.success(getContextDirs()) "getContextDirs" -> result.success(getContextDirs())
"getEnv" -> result.success(System.getenv()) "getEnv" -> result.success(System.getenv())
"getBitmapFactoryInfo" -> GlobalScope.launch(Dispatchers.IO) { getBitmapFactoryInfo(call, Coresult(result)) } "getBitmapFactoryInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getBitmapFactoryInfo) }
"getContentResolverMetadata" -> GlobalScope.launch(Dispatchers.IO) { getContentResolverMetadata(call, Coresult(result)) } "getContentResolverMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverMetadata) }
"getExifInterfaceMetadata" -> GlobalScope.launch(Dispatchers.IO) { getExifInterfaceMetadata(call, Coresult(result)) } "getExifInterfaceMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getExifInterfaceMetadata) }
"getMediaMetadataRetrieverMetadata" -> GlobalScope.launch(Dispatchers.IO) { getMediaMetadataRetrieverMetadata(call, Coresult(result)) } "getMediaMetadataRetrieverMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMediaMetadataRetrieverMetadata) }
"getMetadataExtractorSummary" -> GlobalScope.launch(Dispatchers.IO) { getMetadataExtractorSummary(call, Coresult(result)) } "getMetadataExtractorSummary" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMetadataExtractorSummary) }
"getTiffStructure" -> GlobalScope.launch(Dispatchers.IO) { getTiffStructure(call, Coresult(result)) } "getTiffStructure" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getTiffStructure) }
else -> result.notImplemented() else -> result.notImplemented()
} }
} }

View file

@ -5,8 +5,13 @@ import android.graphics.Rect
import android.net.Uri import android.net.Uri
import android.util.Size import android.util.Size
import com.bumptech.glide.Glide 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.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.ImageProvider.ImageOpCallback
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
import deckers.thibault.aves.model.provider.MediaStoreImageProvider 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) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"getObsoleteEntries" -> GlobalScope.launch(Dispatchers.IO) { getObsoleteEntries(call, Coresult(result)) } "getObsoleteEntries" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getObsoleteEntries) }
"getImageEntry" -> GlobalScope.launch(Dispatchers.IO) { getImageEntry(call, Coresult(result)) } "getEntry" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getEntry) }
"getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { getThumbnail(call, Coresult(result)) } "getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getThumbnail) }
"getRegion" -> GlobalScope.launch(Dispatchers.IO) { getRegion(call, Coresult(result)) } "getRegion" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getRegion) }
"clearSizedThumbnailDiskCache" -> { "clearSizedThumbnailDiskCache" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::clearSizedThumbnailDiskCache) }
GlobalScope.launch(Dispatchers.IO) { Glide.get(activity).clearDiskCache() } "rename" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::rename) }
result.success(null) "rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
} "flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
"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)) }
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
@ -58,7 +60,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
val isFlipped = call.argument<Boolean>("isFlipped") val isFlipped = call.argument<Boolean>("isFlipped")
val widthDip = call.argument<Double>("widthDip") val widthDip = call.argument<Double>("widthDip")
val heightDip = call.argument<Double>("heightDip") val heightDip = call.argument<Double>("heightDip")
val page = call.argument<Int>("page") val pageId = call.argument<Int>("pageId")
val defaultSizeDip = call.argument<Double>("defaultSizeDip") val defaultSizeDip = call.argument<Double>("defaultSizeDip")
if (uri == null || mimeType == null || dateModifiedSecs == null || rotationDegrees == null || isFlipped == null || widthDip == null || heightDip == null || defaultSizeDip == null) { if (uri == null || mimeType == null || dateModifiedSecs == null || rotationDegrees == null || isFlipped == null || widthDip == null || heightDip == null || defaultSizeDip == null) {
@ -76,7 +78,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
isFlipped, isFlipped,
width = (widthDip * density).roundToInt(), width = (widthDip * density).roundToInt(),
height = (heightDip * density).roundToInt(), height = (heightDip * density).roundToInt(),
page = page, pageId = pageId,
defaultSize = (defaultSizeDip * density).roundToInt(), defaultSize = (defaultSizeDip * density).roundToInt(),
result, result,
).fetch() ).fetch()
@ -85,7 +87,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
private fun getRegion(call: MethodCall, result: MethodChannel.Result) { private fun getRegion(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val mimeType = call.argument<String>("mimeType") 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 sampleSize = call.argument<Int>("sampleSize")
val x = call.argument<Int>("regionX") val x = call.argument<Int>("regionX")
val y = call.argument<Int>("regionY") 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) val regionRect = Rect(x, y, x + width, y + height)
when (mimeType) { when (mimeType) {
MimeTypes.TIFF -> TiffRegionFetcher(activity).fetch( MimeTypes.TIFF -> TiffRegionFetcher(activity).fetch(
uri, uri = uri,
sampleSize, page = pageId ?: 0,
regionRect, sampleSize = sampleSize,
page = page ?: 0, regionRect = regionRect,
result, result = result,
) )
else -> regionFetcher.fetch( else -> regionFetcher.fetch(
uri, uri = uri,
mimeType, mimeType = mimeType,
sampleSize, pageId = pageId,
regionRect, sampleSize = sampleSize,
Size(imageWidth, imageHeight), regionRect = regionRect,
result, 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 mimeType = call.argument<String>("mimeType") // MIME type is optional
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) { if (uri == null) {
result.error("getImageEntry-args", "failed because of missing arguments", null) result.error("getEntry-args", "failed because of missing arguments", null)
return return
} }
val provider = getProvider(uri) val provider = getProvider(uri)
if (provider == null) { 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 return
} }
provider.fetchSingle(activity, uri, mimeType, object : ImageOpCallback { provider.fetchSingle(activity, uri, mimeType, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields) override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("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) { private suspend fun rename(call: MethodCall, result: MethodChannel.Result) {
val entryMap = call.argument<FieldMap>("entry") val entryMap = call.argument<FieldMap>("entry")
val newName = call.argument<String>("newName") val newName = call.argument<String>("newName")

View file

@ -1,8 +1,15 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context import android.content.Context
import android.database.Cursor
import android.media.MediaExtractor
import android.media.MediaFormat
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.Log import android.util.Log
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import com.adobe.internal.xmp.XMPException 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.mp4.media.Mp4UuidBoxDirectory
import com.drew.metadata.webp.WebpDirectory import com.drew.metadata.webp.WebpDirectory
import com.drew.metadata.xmp.XmpDirectory 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.*
import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis 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.getSafeDateMillis
import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText
import deckers.thibault.aves.metadata.XMP.isPanorama 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.FileImageProvider
import deckers.thibault.aves.model.provider.ImageProvider import deckers.thibault.aves.model.provider.ImageProvider
import deckers.thibault.aves.utils.BitmapUtils import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes 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.isImage
import deckers.thibault.aves.utils.MimeTypes.isMultimedia import deckers.thibault.aves.utils.MimeTypes.isMultimedia
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
@ -66,14 +75,15 @@ import kotlin.math.roundToLong
class MetadataHandler(private val context: Context) : MethodCallHandler { class MetadataHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"getAllMetadata" -> GlobalScope.launch(Dispatchers.IO) { getAllMetadata(call, Coresult(result)) } "getAllMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getAllMetadata) }
"getCatalogMetadata" -> GlobalScope.launch(Dispatchers.IO) { getCatalogMetadata(call, Coresult(result)) } "getCatalogMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getCatalogMetadata) }
"getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { getOverlayMetadata(call, Coresult(result)) } "getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getOverlayMetadata) }
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { getMultiPageInfo(call, Coresult(result)) } "getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) }
"getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { getPanoramaInfo(call, Coresult(result)) } "getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPanoramaInfo) }
"getEmbeddedPictures" -> GlobalScope.launch(Dispatchers.IO) { getEmbeddedPictures(call, Coresult(result)) } "getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) }
"getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { getExifThumbnails(call, Coresult(result)) } "getEmbeddedPictures" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getEmbeddedPictures) }
"extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { extractXmpDataProp(call, Coresult(result)) } "getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getExifThumbnails) }
"extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractXmpDataProp) }
else -> result.notImplemented() 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) { retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS) {
if (it > 1) flags = flags or MASK_IS_MULTIPAGE if (it > 1) flags = flags or MASK_IS_MULTIPAGE
} }
@ -521,21 +531,57 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
return return
} }
val pages = HashMap<Int, Any>() val pages = ArrayList<Map<String, Any>>()
if (mimeType == MimeTypes.TIFF) { if (mimeType == MimeTypes.TIFF) {
fun toMap(options: TiffBitmapFactory.Options): Map<String, Any> { fun toMap(page: Int, options: TiffBitmapFactory.Options): HashMap<String, Any> {
return hashMapOf( return hashMapOf(
"width" to options.outWidth, KEY_PAGE to page,
"height" to options.outHeight, KEY_MIME_TYPE to mimeType,
KEY_WIDTH to options.outWidth,
KEY_HEIGHT to options.outHeight,
) )
} }
getTiffPageInfo(uri, 0)?.let { first -> getTiffPageInfo(uri, 0)?.let { first ->
pages[0] = toMap(first) pages.add(toMap(0, first))
val pageCount = first.outDirectoryCount val pageCount = first.outDirectoryCount
for (i in 1 until pageCount) { 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) result.success(pages)
} }
@ -555,14 +601,16 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
val metadata = ImageMetadataReader.readMetadata(input) val metadata = ImageMetadataReader.readMetadata(input)
val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java) val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java)
try { 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( val fields: FieldMap = hashMapOf(
"croppedAreaLeft" to getProp(XMP.GPANO_CROPPED_AREA_LEFT_PROP_NAME), "croppedAreaLeft" to getIntProp(XMP.GPANO_CROPPED_AREA_LEFT_PROP_NAME),
"croppedAreaTop" to getProp(XMP.GPANO_CROPPED_AREA_TOP_PROP_NAME), "croppedAreaTop" to getIntProp(XMP.GPANO_CROPPED_AREA_TOP_PROP_NAME),
"croppedAreaWidth" to getProp(XMP.GPANO_CROPPED_AREA_WIDTH_PROP_NAME), "croppedAreaWidth" to getIntProp(XMP.GPANO_CROPPED_AREA_WIDTH_PROP_NAME),
"croppedAreaHeight" to getProp(XMP.GPANO_CROPPED_AREA_HEIGHT_PROP_NAME), "croppedAreaHeight" to getIntProp(XMP.GPANO_CROPPED_AREA_HEIGHT_PROP_NAME),
"fullPanoWidth" to getProp(XMP.GPANO_FULL_PANO_WIDTH_PROP_NAME), "fullPanoWidth" to getIntProp(XMP.GPANO_FULL_PANO_WIDTH_PROP_NAME),
"fullPanoHeight" to getProp(XMP.GPANO_FULL_PANO_HEIGHT_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) result.success(fields)
return return
@ -580,6 +628,55 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
result.error("getPanoramaInfo-empty", "failed to read XMP from uri=$uri", null) 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) { private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) { if (uri == null) {
@ -619,7 +716,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
exif.thumbnailBitmap?.let { bitmap -> exif.thumbnailBitmap?.let { bitmap ->
TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let { 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) private val LOG_TAG = LogUtils.createTag(MetadataHandler::class.java)
const val CHANNEL = "deckers.thibault/aves/metadata" const val CHANNEL = "deckers.thibault/aves/metadata"
// catalog metadata // catalog metadata & page info
private const val KEY_MIME_TYPE = "mimeType" private const val KEY_MIME_TYPE = "mimeType"
private const val KEY_DATE_MILLIS = "dateMillis" private const val KEY_DATE_MILLIS = "dateMillis"
private const val KEY_FLAGS = "flags" 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_LONGITUDE = "longitude"
private const val KEY_XMP_SUBJECTS = "xmpSubjects" private const val KEY_XMP_SUBJECTS = "xmpSubjects"
private const val KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription" 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_ANIMATED = 1 shl 0
private const val MASK_IS_FLIPPED = 1 shl 1 private const val MASK_IS_FLIPPED = 1 shl 1

View file

@ -5,7 +5,7 @@ import android.media.MediaScannerConnection
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.storage.StorageManager 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.PermissionManager
import deckers.thibault.aves.utils.StorageUtils.getVolumePaths import deckers.thibault.aves.utils.StorageUtils.getVolumePaths
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
@ -20,27 +20,18 @@ import java.util.*
class StorageHandler(private val context: Context) : MethodCallHandler { class StorageHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"getStorageVolumes" -> { "getStorageVolumes" -> safe(call, result, ::getStorageVolumes)
val volumes: List<Map<String, Any>> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { "getFreeSpace" -> safe(call, result, ::getFreeSpace)
storageVolumes "getGrantedDirectories" -> safe(call, result, ::getGrantedDirectories)
} else { "getInaccessibleDirectories" -> safe(call, result, ::getInaccessibleDirectories)
// TODO TLAD find alternative for Android <N "revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess)
emptyList() "scanFile" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::scanFile) }
}
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)) }
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
private val storageVolumes: List<Map<String, Any>> private fun getStorageVolumes(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
@RequiresApi(api = Build.VERSION_CODES.N) val volumes: List<Map<String, Any>> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
get() {
val volumes = ArrayList<Map<String, Any>>() val volumes = ArrayList<Map<String, Any>>()
val sm = context.getSystemService(StorageManager::class.java) val sm = context.getSystemService(StorageManager::class.java)
if (sm != null) { 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) { private fun getFreeSpace(call: MethodCall, result: MethodChannel.Result) {
val path = call.argument<String>("path") 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) { private fun getInaccessibleDirectories(call: MethodCall, result: MethodChannel.Result) {
val dirPaths = call.argument<List<String>>("dirPaths") val dirPaths = call.argument<List<String>>("dirPaths")
if (dirPaths == null) { if (dirPaths == null) {

View file

@ -1,15 +1,22 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls.fetchers
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder import android.graphics.BitmapRegionDecoder
import android.graphics.Rect import android.graphics.Rect
import android.net.Uri import android.net.Uri
import android.util.Size 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.BitmapUtils.getBytes
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import java.io.File
import kotlin.math.roundToInt import kotlin.math.roundToInt
class RegionFetcher internal constructor( class RegionFetcher internal constructor(
@ -17,21 +24,42 @@ class RegionFetcher internal constructor(
) { ) {
private var lastDecoderRef: LastDecoderRef? = null 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( fun fetch(
uri: Uri, uri: Uri,
mimeType: String, mimeType: String,
pageId: Int?,
sampleSize: Int, sampleSize: Int,
regionRect: Rect, regionRect: Rect,
imageSize: Size, imageSize: Size,
result: MethodChannel.Result, 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 { val options = BitmapFactory.Options().apply {
inSampleSize = sampleSize inSampleSize = sampleSize
} }
var currentDecoderRef = lastDecoderRef var currentDecoderRef = lastDecoderRef
if (currentDecoderRef != null && currentDecoderRef.uri != uri) { if (currentDecoderRef != null && currentDecoderRef.uri != uri) {
currentDecoderRef.decoder.recycle()
currentDecoderRef = null 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) 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( private data class LastDecoderRef(

View file

@ -1,4 +1,4 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls.fetchers
import android.content.ContentUris import android.content.ContentUris
import android.content.Context 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.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.signature.ObjectKey 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.decoder.VideoThumbnail
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.MimeTypes 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.isVideo
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
@ -32,14 +34,16 @@ class ThumbnailFetcher internal constructor(
private val isFlipped: Boolean, private val isFlipped: Boolean,
width: Int?, width: Int?,
height: Int?, height: Int?,
page: Int?, private val pageId: Int?,
private val defaultSize: Int, private val defaultSize: Int,
private val result: MethodChannel.Result, private val result: MethodChannel.Result,
) { ) {
private val uri: Uri = Uri.parse(uri) private val uri: Uri = Uri.parse(uri)
private val width: Int = if (width?.takeIf { it > 0 } != null) width else defaultSize 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 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() { fun fetch() {
var bitmap: Bitmap? = null var bitmap: Bitmap? = null
@ -47,7 +51,7 @@ class ThumbnailFetcher internal constructor(
var exception: Exception? = null var exception: Exception? = null
try { 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. // Fetch low quality thumbnails when size is not specified.
// As of Android R, the Media Store content resolver may return a thumbnail // As of Android R, the Media Store content resolver may return a thumbnail
// that is automatically rotated according to EXIF orientation, but not flipped, // 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 // add signature to ignore cache for images which got modified but kept the same URI
var options = RequestOptions() var options = RequestOptions()
.format(DecodeFormat.PREFER_RGB_565) .format(DecodeFormat.PREFER_RGB_565)
.signature(ObjectKey("$dateModifiedSecs-$rotationDegrees-$isFlipped-$width-$page")) .signature(ObjectKey("$dateModifiedSecs-$rotationDegrees-$isFlipped-$width-$pageId"))
.override(width, height) .override(width, height)
val target = if (isVideo(mimeType)) { val target = if (isVideo(mimeType)) {
@ -121,7 +125,11 @@ class ThumbnailFetcher internal constructor(
.load(VideoThumbnail(context, uri)) .load(VideoThumbnail(context, uri))
.submit(width, height) .submit(width, height)
} else { } 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) Glide.with(context)
.asBitmap() .asBitmap()
.apply(options) .apply(options)

View file

@ -1,4 +1,4 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls.fetchers
import android.content.Context import android.content.Context
import android.graphics.Rect import android.graphics.Rect
@ -13,9 +13,9 @@ class TiffRegionFetcher internal constructor(
) { ) {
fun fetch( fun fetch(
uri: Uri, uri: Uri,
page: Int,
sampleSize: Int, sampleSize: Int,
regionRect: Rect, regionRect: Rect,
page: Int = 0,
result: MethodChannel.Result, result: MethodChannel.Result,
) { ) {
try { try {

View file

@ -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"
}
}

View file

@ -9,11 +9,14 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions 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.decoder.VideoThumbnail
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes 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.isSupportedByFlutter
import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
@ -23,7 +26,6 @@ import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import java.io.IOException import java.io.IOException
import java.io.InputStream 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 uri = (arguments["uri"] as String?)?.let { Uri.parse(it) }
val rotationDegrees = arguments["rotationDegrees"] as Int val rotationDegrees = arguments["rotationDegrees"] as Int
val isFlipped = arguments["isFlipped"] as Boolean val isFlipped = arguments["isFlipped"] as Boolean
val page = arguments["page"] as Int val pageId = arguments["pageId"] as Int?
if (mimeType == null || uri == null) { if (mimeType == null || uri == null) {
error("streamImage-args", "failed because of missing arguments", 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)) { if (isVideo(mimeType)) {
streamVideoByGlide(uri) streamVideoByGlide(uri)
} else if (mimeType == MimeTypes.TIFF) {
streamTiffImage(uri, page)
} else if (!isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) { } else if (!isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) {
// decode exotic format on platform side, then encode it in portable format for Flutter // decode exotic format on platform side, then encode it in portable format for Flutter
streamImageByGlide(uri, mimeType, rotationDegrees, isFlipped) streamImageByGlide(uri, pageId, mimeType, rotationDegrees, isFlipped)
} else { } else {
// to be decoded by Flutter // to be decoded by Flutter
streamImageAsIs(uri) 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) val target = Glide.with(activity)
.asBitmap() .asBitmap()
.apply(glideOptions) .apply(glideOptions)
.load(uri) .load(model)
.submit() .submit()
try { try {
var bitmap = target.get() 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? { private fun toErrorDetails(e: Exception): String? {
val errorDetails = e.message val errorDetails = e.message
return if (errorDetails?.isNotEmpty() == true) { return if (errorDetails?.isNotEmpty() == true) {

View file

@ -5,8 +5,8 @@ import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import deckers.thibault.aves.model.AvesImageEntry import deckers.thibault.aves.model.AvesEntry
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.ImageProvider.ImageOpCallback
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
@ -42,6 +42,7 @@ class ImageOpStreamHandler(private val context: Context, private val arguments:
when (op) { when (op) {
"delete" -> GlobalScope.launch(Dispatchers.IO) { delete() } "delete" -> GlobalScope.launch(Dispatchers.IO) { delete() }
"export" -> GlobalScope.launch(Dispatchers.IO) { export() }
"move" -> GlobalScope.launch(Dispatchers.IO) { move() } "move" -> GlobalScope.launch(Dispatchers.IO) { move() }
else -> endOfStream() 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() { private suspend fun delete() {
if (entryMapList.isEmpty()) { if (entryMapList.isEmpty()) {
endOfStream() endOfStream()
@ -144,6 +115,66 @@ class ImageOpStreamHandler(private val context: Context, private val arguments:
endOfStream() 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 { companion object {
private val LOG_TAG = LogUtils.createTag(ImageOpStreamHandler::class.java) private val LOG_TAG = LogUtils.createTag(ImageOpStreamHandler::class.java)
const val CHANNEL = "deckers.thibault/aves/imageopstream" const val CHANNEL = "deckers.thibault/aves/imageopstream"

View file

@ -18,4 +18,8 @@ class IntentStreamHandler : EventChannel.StreamHandler {
fun notifyNewIntent(intentData: MutableMap<String, Any?>?) { fun notifyNewIntent(intentData: MutableMap<String, Any?>?) {
eventSink?.success(intentData) eventSink?.success(intentData)
} }
companion object {
const val CHANNEL = "deckers.thibault/aves/intent"
}
} }

View file

@ -4,7 +4,7 @@ import android.content.Context
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import deckers.thibault.aves.model.provider.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.provider.MediaStoreImageProvider import deckers.thibault.aves.model.provider.MediaStoreImageProvider
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -1,7 +1,9 @@
package deckers.thibault.aves.decoder package deckers.thibault.aves.decoder
import android.content.Context import android.content.Context
import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import android.os.Build
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.Priority import com.bumptech.glide.Priority
import com.bumptech.glide.Registry import com.bumptech.glide.Registry
@ -34,7 +36,7 @@ internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, InputStream> {
return ModelLoader.LoadData(ObjectKey(model.uri), VideoThumbnailFetcher(model)) 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> { internal class Factory : ModelLoaderFactory<VideoThumbnail, InputStream> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<VideoThumbnail, InputStream> = VideoThumbnailLoader() 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) val retriever = openMetadataRetriever(model.context, model.uri)
if (retriever != null) { if (retriever != null) {
try { try {
val picture = retriever.embeddedPicture ?: retriever.frameAtTime?.getBytes(canHaveAlpha = false, recycle = false) var bytes = retriever.embeddedPicture
if (picture != null) { if (bytes == null) {
callback.onDataReady(ByteArrayInputStream(picture)) // 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 { } else {
callback.onLoadFailed(Exception("failed to get embedded picture or any frame")) callback.onLoadFailed(Exception("failed to get embedded picture or any frame"))
} }

View file

@ -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
}
}

View file

@ -1,6 +1,7 @@
package deckers.thibault.aves.metadata package deckers.thibault.aves.metadata
import android.util.Log import android.util.Log
import com.adobe.internal.xmp.XMPError
import com.adobe.internal.xmp.XMPException import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.XMPMeta import com.adobe.internal.xmp.XMPMeta
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
@ -9,6 +10,8 @@ import java.util.*
object XMP { object XMP {
private val LOG_TAG = LogUtils.createTag(XMP::class.java) 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 DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/"
const val PHOTOSHOP_SCHEMA_NS = "http://ns.adobe.com/photoshop/1.0/" const val PHOTOSHOP_SCHEMA_NS = "http://ns.adobe.com/photoshop/1.0/"
const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/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_CROPPED_AREA_TOP_PROP_NAME = "GPano:CroppedAreaTopPixels"
const val GPANO_FULL_PANO_HEIGHT_PROP_NAME = "GPano:FullPanoHeightPixels" const val GPANO_FULL_PANO_HEIGHT_PROP_NAME = "GPano:FullPanoHeightPixels"
const val GPANO_FULL_PANO_WIDTH_PROP_NAME = "GPano:FullPanoWidthPixels" 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" 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( private val gpanoRequiredProps = listOf(
GPANO_CROPPED_AREA_HEIGHT_PROP_NAME, GPANO_CROPPED_AREA_HEIGHT_PROP_NAME,
GPANO_CROPPED_AREA_WIDTH_PROP_NAME, GPANO_CROPPED_AREA_WIDTH_PROP_NAME,
GPANO_CROPPED_AREA_LEFT_PROP_NAME, GPANO_CROPPED_AREA_LEFT_PROP_NAME,
GPANO_CROPPED_AREA_TOP_PROP_NAME, GPANO_CROPPED_AREA_TOP_PROP_NAME,
GPANO_FULL_PANO_HEIGHT_PROP_NAME,
GPANO_FULL_PANO_WIDTH_PROP_NAME, GPANO_FULL_PANO_WIDTH_PROP_NAME,
GPANO_PROJECTION_TYPE_PROP_NAME,
) )
// extensions // extensions
fun XMPMeta.isPanorama(): Boolean { fun XMPMeta.isPanorama(): Boolean {
// Google // 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 // 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 return false
} }
@ -102,7 +124,7 @@ object XMP {
} }
} }
} catch (e: XMPException) { } 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)
} }
} }
} }

View file

@ -1,13 +1,15 @@
package deckers.thibault.aves.model package deckers.thibault.aves.model
import android.net.Uri 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 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 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 mimeType = map["mimeType"] as String
val width = map["width"] as Int val width = map["width"] as Int
val height = map["height"] as Int val height = map["height"] as Int
val rotationDegrees = map["rotationDegrees"] as Int val rotationDegrees = map["rotationDegrees"] as Int
val isFlipped = map["isFlipped"] as Boolean
} }

View file

@ -0,0 +1,3 @@
package deckers.thibault.aves.model
typealias FieldMap = MutableMap<String, Any?>

View file

@ -25,13 +25,13 @@ import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeLong 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.MimeTypes
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
import org.beyka.tiffbitmapfactory.TiffBitmapFactory import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import java.io.IOException import java.io.IOException
class SourceImageEntry { class SourceEntry {
val uri: Uri // content or file URI val uri: Uri // content or file URI
var path: String? = null // best effort to get local path var path: String? = null // best effort to get local path
private val sourceMimeType: String private val sourceMimeType: String
@ -119,7 +119,7 @@ class SourceImageEntry {
// metadata retrieval // metadata retrieval
// expects entry with: uri, mimeType // expects entry with: uri, mimeType
// finds: width, height, orientation/rotation, date, title, duration // finds: width, height, orientation/rotation, date, title, duration
fun fillPreCatalogMetadata(context: Context): SourceImageEntry { fun fillPreCatalogMetadata(context: Context): SourceEntry {
if (isSvg) return this if (isSvg) return this
if (isVideo) { if (isVideo) {
fillVideoByMediaMetadataRetriever(context) fillVideoByMediaMetadataRetriever(context)

View file

@ -3,7 +3,7 @@ package deckers.thibault.aves.model.provider
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.provider.MediaStore import android.provider.MediaStore
import deckers.thibault.aves.model.SourceImageEntry import deckers.thibault.aves.model.SourceEntry
internal class ContentImageProvider : ImageProvider() { internal class ContentImageProvider : ImageProvider() {
override suspend fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) { override suspend fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) {
@ -28,7 +28,7 @@ internal class ContentImageProvider : ImageProvider() {
return return
} }
val entry = SourceImageEntry(map).fillPreCatalogMetadata(context) val entry = SourceEntry(map).fillPreCatalogMetadata(context)
if (entry.isSized || entry.isSvg) { if (entry.isSized || entry.isSvg) {
callback.onSuccess(entry.toMap()) callback.onSuccess(entry.toMap())
} else { } else {

View file

@ -2,7 +2,7 @@ package deckers.thibault.aves.model.provider
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import deckers.thibault.aves.model.SourceImageEntry import deckers.thibault.aves.model.SourceEntry
import java.io.File import java.io.File
internal class FileImageProvider : ImageProvider() { internal class FileImageProvider : ImageProvider() {
@ -12,7 +12,7 @@ internal class FileImageProvider : ImageProvider() {
return return
} }
val entry = SourceImageEntry(uri, mimeType) val entry = SourceEntry(uri, mimeType)
val path = uri.path val path = uri.path
if (path != null) { if (path != null) {

View file

@ -2,18 +2,28 @@ package deckers.thibault.aves.model.provider
import android.content.ContentUris import android.content.ContentUris
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.media.MediaScannerConnection import android.media.MediaScannerConnection
import android.net.Uri import android.net.Uri
import android.os.Build
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log import android.util.Log
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
import com.commonsware.cwac.document.DocumentFileCompat 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.ExifOrientationOp
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.StorageUtils.copyFileToTemp import deckers.thibault.aves.utils.StorageUtils.copyFileToTemp
import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent
import deckers.thibault.aves.utils.StorageUtils.getDocumentFile import deckers.thibault.aves.utils.StorageUtils.getDocumentFile
import java.io.File import java.io.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
@ -32,10 +42,151 @@ abstract class ImageProvider {
throw UnsupportedOperationException() 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()) 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) { suspend fun rename(context: Context, oldPath: String, oldMediaUri: Uri, mimeType: String, newFilename: String, callback: ImageOpCallback) {
val oldFile = File(oldPath) val oldFile = File(oldPath)
val newFile = File(oldFile.parent, newFilename) val newFile = File(oldFile.parent, newFilename)
@ -147,9 +298,9 @@ abstract class ImageProvider {
// `newURI` is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872") // `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") // but we need an image/video media URI (e.g. "content://media/external/images/media/62872")
contentId = ContentUris.parseId(newUri) contentId = ContentUris.parseId(newUri)
if (isImage(mimeType)) { if (MimeTypes.isImage(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId) 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) 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) private val LOG_TAG = LogUtils.createTag(ImageProvider::class.java)
} }
} }
typealias FieldMap = MutableMap<String, Any?>

View file

@ -8,8 +8,9 @@ import android.os.Build
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log import android.util.Log
import com.commonsware.cwac.document.DocumentFileCompat import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.model.AvesImageEntry import deckers.thibault.aves.model.AvesEntry
import deckers.thibault.aves.model.SourceImageEntry import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.SourceEntry
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.isImage
@ -158,7 +159,7 @@ class MediaStoreImageProvider : ImageProvider() {
// missing some attributes such as width, height, orientation. // missing some attributes such as width, height, orientation.
// Also, the reported size of raw images is inconsistent across devices // Also, the reported size of raw images is inconsistent across devices
// and Android versions (sometimes the raw size, sometimes the decoded size). // 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() entryMap = entry.toMap()
} }
@ -185,7 +186,7 @@ class MediaStoreImageProvider : ImageProvider() {
override suspend fun delete(context: Context, uri: Uri, path: String?) { override suspend fun delete(context: Context, uri: Uri, path: String?) {
path ?: throw Exception("failed to delete file because path is null") 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 // 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 // but it doesn't delete the file, even if the app has the permission
val df = getDocumentFile(context, path, uri) val df = getDocumentFile(context, path, uri)
@ -203,7 +204,7 @@ class MediaStoreImageProvider : ImageProvider() {
context: Context, context: Context,
copy: Boolean, copy: Boolean,
destinationDir: String, destinationDir: String,
entries: List<AvesImageEntry>, entries: List<AvesEntry>,
callback: ImageOpCallback, callback: ImageOpCallback,
) { ) {
val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir) val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir)

View file

@ -26,7 +26,7 @@ object BitmapUtils {
} catch (e: IllegalStateException) { } catch (e: IllegalStateException) {
Log.e(LOG_TAG, "failed to get bytes from bitmap", e) 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? { fun applyExifOrientation(context: Context, bitmap: Bitmap?, rotationDegrees: Int?, isFlipped: Boolean?): Bitmap? {

View file

@ -9,10 +9,10 @@ object MimeTypes {
private const val BMP = "image/bmp" private const val BMP = "image/bmp"
const val GIF = "image/gif" const val GIF = "image/gif"
const val HEIC = "image/heic" 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 ICO = "image/x-icon"
private const val JPEG = "image/jpeg" const val JPEG = "image/jpeg"
private const val PNG = "image/png" const val PNG = "image/png"
const val TIFF = "image/tiff" const val TIFF = "image/tiff"
private const val WBMP = "image/vnd.wap.wbmp" private const val WBMP = "image/vnd.wap.wbmp"
const val WEBP = "image/webp" const val WEBP = "image/webp"
@ -41,10 +41,9 @@ object MimeTypes {
fun isVideo(mimeType: String?) = mimeType != null && mimeType.startsWith(VIDEO) fun isVideo(mimeType: String?) = mimeType != null && mimeType.startsWith(VIDEO)
fun isMultimedia(mimeType: String?) = when (mimeType) { fun isHeifLike(mimeType: String?) = mimeType != null && (mimeType == HEIC || mimeType == HEIF)
HEIC, HEIF -> true
else -> isVideo(mimeType) fun isMultimedia(mimeType: String?) = isVideo(mimeType) || isHeifLike(mimeType)
}
fun isRaw(mimeType: String): Boolean { fun isRaw(mimeType: String): Boolean {
return when (mimeType) { return when (mimeType) {

View file

@ -9,7 +9,7 @@ buildscript {
// TODO TLAD upgrade AGP to 4+ when this lands on stable: https://github.com/flutter/flutter/pull/70808 // 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 'com.android.tools.build:gradle:3.6.4'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.google.gms:google-services:4.3.4' classpath 'com.google.gms:google-services:4.3.5'
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.4.1' classpath 'com.google.firebase:firebase-crashlytics-gradle:2.4.1'
} }
} }

View file

@ -2,7 +2,7 @@ import 'dart:ui' as ui show Codec;
import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/android_app_service.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart';
class AppIconImage extends ImageProvider<AppIconImageKey> { class AppIconImage extends ImageProvider<AppIconImageKey> {
const AppIconImage({ const AppIconImage({

View file

@ -2,10 +2,9 @@ import 'dart:async';
import 'dart:math'; import 'dart:math';
import 'dart:ui' as ui show Codec; import 'dart:ui' as ui show Codec;
import 'package:aves/model/image_entry.dart';
import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/image_file_service.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart';
class RegionProvider extends ImageProvider<RegionProviderKey> { class RegionProvider extends ImageProvider<RegionProviderKey> {
final RegionProviderKey key; final RegionProviderKey key;
@ -23,7 +22,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
codec: _loadAsync(key, decode), codec: _loadAsync(key, decode),
scale: key.scale, scale: key.scale,
informationCollector: () sync* { 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 { Future<ui.Codec> _loadAsync(RegionProviderKey key, DecoderCallback decode) async {
final uri = key.uri; final uri = key.uri;
final mimeType = key.mimeType; final mimeType = key.mimeType;
final pageId = key.pageId;
try { try {
final bytes = await ImageFileService.getRegion( final bytes = await ImageFileService.getRegion(
uri, uri,
@ -38,9 +38,9 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
key.rotationDegrees, key.rotationDegrees,
key.isFlipped, key.isFlipped,
key.sampleSize, key.sampleSize,
key.regionRect, key.region,
key.imageSize, key.imageSize,
page: key.page, pageId: pageId,
taskKey: key, taskKey: key,
); );
if (bytes == null) { if (bytes == null) {
@ -49,7 +49,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
return await decode(bytes); return await decode(bytes);
} catch (error) { } catch (error) {
debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$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 { 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 String uri, mimeType;
final int rotationDegrees, sampleSize, page; final int pageId, rotationDegrees, sampleSize;
final bool isFlipped; final bool isFlipped;
final Rectangle<int> regionRect; final Rectangle<int> region;
final Size imageSize; final Size imageSize;
final double scale; final double scale;
const RegionProviderKey({ const RegionProviderKey({
@required this.uri, @required this.uri,
@required this.mimeType, @required this.mimeType,
@required this.pageId,
@required this.rotationDegrees, @required this.rotationDegrees,
@required this.isFlipped, @required this.isFlipped,
this.page = 0,
@required this.sampleSize, @required this.sampleSize,
@required this.regionRect, @required this.region,
@required this.imageSize, @required this.imageSize,
this.scale = 1.0, this.scale = 1.0,
}) : assert(uri != null), }) : assert(uri != null),
@ -85,49 +87,29 @@ class RegionProviderKey {
assert(rotationDegrees != null), assert(rotationDegrees != null),
assert(isFlipped != null), assert(isFlipped != null),
assert(sampleSize != null), assert(sampleSize != null),
assert(regionRect != null), assert(region != null),
assert(imageSize != null), assert(imageSize != null),
assert(scale != 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 @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false; 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 @override
int get hashCode => hashValues( int get hashCode => hashValues(
uri, uri,
mimeType, mimeType,
pageId,
rotationDegrees, rotationDegrees,
isFlipped, isFlipped,
page,
sampleSize, sampleSize,
regionRect, region,
imageSize, imageSize,
scale, scale,
); );
@override @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}';
} }

View file

@ -1,9 +1,8 @@
import 'dart:ui' as ui show Codec; import 'dart:ui' as ui show Codec;
import 'package:aves/model/image_entry.dart';
import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/image_file_service.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart';
class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> { class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
final ThumbnailProviderKey key; final ThumbnailProviderKey key;
@ -24,7 +23,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
codec: _loadAsync(key, decode), codec: _loadAsync(key, decode),
scale: key.scale, scale: key.scale,
informationCollector: () sync* { 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 { Future<ui.Codec> _loadAsync(ThumbnailProviderKey key, DecoderCallback decode) async {
final uri = key.uri; final uri = key.uri;
final mimeType = key.mimeType; final mimeType = key.mimeType;
final pageId = key.pageId;
try { try {
final bytes = await ImageFileService.getThumbnail( final bytes = await ImageFileService.getThumbnail(
uri, uri: uri,
mimeType, mimeType: mimeType,
key.dateModifiedSecs, pageId: pageId,
key.rotationDegrees, rotationDegrees: key.rotationDegrees,
key.isFlipped, isFlipped: key.isFlipped,
key.extent, dateModifiedSecs: key.dateModifiedSecs,
key.extent, extent: key.extent,
page: key.page,
taskKey: key, taskKey: key,
); );
if (bytes == null) { if (bytes == null) {
@ -50,7 +49,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
return await decode(bytes); return await decode(bytes);
} catch (error) { } catch (error) {
debugPrint('$runtimeType _loadAsync failed with uri=$uri, error=$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 { 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 String uri, mimeType;
final int dateModifiedSecs, rotationDegrees, page; final int pageId, rotationDegrees;
final bool isFlipped; final bool isFlipped;
final int dateModifiedSecs;
final double extent, scale; final double extent, scale;
const ThumbnailProviderKey({ const ThumbnailProviderKey({
@required this.uri, @required this.uri,
@required this.mimeType, @required this.mimeType,
@required this.dateModifiedSecs, @required this.pageId,
@required this.rotationDegrees, @required this.rotationDegrees,
@required this.isFlipped, @required this.isFlipped,
this.page = 0, @required this.dateModifiedSecs,
this.extent = 0, this.extent = 0,
this.scale = 1, this.scale = 1,
}) : assert(uri != null), }) : assert(uri != null),
assert(mimeType != null), assert(mimeType != null),
assert(dateModifiedSecs != null),
assert(rotationDegrees != null), assert(rotationDegrees != null),
assert(isFlipped != null), assert(isFlipped != null),
assert(dateModifiedSecs != null),
assert(extent != null), assert(extent != null),
assert(scale != 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 @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false; 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 @override
int get hashCode => hashValues( int get hashCode => hashValues(
uri, uri,
mimeType, mimeType,
dateModifiedSecs, pageId,
rotationDegrees, rotationDegrees,
isFlipped, isFlipped,
page, dateModifiedSecs,
extent, extent,
scale, scale,
); );
@override @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}';
} }

View file

@ -3,19 +3,19 @@ import 'dart:ui' as ui show Codec;
import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/image_file_service.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart';
import 'package:pedantic/pedantic.dart'; import 'package:pedantic/pedantic.dart';
class UriImage extends ImageProvider<UriImage> { class UriImage extends ImageProvider<UriImage> {
final String uri, mimeType; final String uri, mimeType;
final int page, rotationDegrees, expectedContentLength; final int pageId, rotationDegrees, expectedContentLength;
final bool isFlipped; final bool isFlipped;
final double scale; final double scale;
const UriImage({ const UriImage({
@required this.uri, @required this.uri,
@required this.mimeType, @required this.mimeType,
this.page = 0, @required this.pageId,
@required this.rotationDegrees, @required this.rotationDegrees,
@required this.isFlipped, @required this.isFlipped,
this.expectedContentLength, this.expectedContentLength,
@ -37,7 +37,7 @@ class UriImage extends ImageProvider<UriImage> {
scale: key.scale, scale: key.scale,
chunkEvents: chunkEvents.stream, chunkEvents: chunkEvents.stream,
informationCollector: () sync* { 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, mimeType,
rotationDegrees, rotationDegrees,
isFlipped, isFlipped,
page: page, pageId: pageId,
expectedContentLength: expectedContentLength, expectedContentLength: expectedContentLength,
onBytesReceived: (cumulative, total) { onBytesReceived: (cumulative, total) {
chunkEvents.add(ImageChunkEvent( chunkEvents.add(ImageChunkEvent(
@ -66,7 +66,7 @@ class UriImage extends ImageProvider<UriImage> {
return await decode(bytes); return await decode(bytes);
} catch (error) { } catch (error) {
debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$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 { } finally {
unawaited(chunkEvents.close()); unawaited(chunkEvents.close());
} }
@ -75,7 +75,7 @@ class UriImage extends ImageProvider<UriImage> {
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false; 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 @override
@ -84,10 +84,10 @@ class UriImage extends ImageProvider<UriImage> {
mimeType, mimeType,
rotationDegrees, rotationDegrees,
isFlipped, isFlipped,
page, pageId,
scale, scale,
); );
@override @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}';
} }

View file

@ -1,6 +1,6 @@
import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/image_file_service.dart';
import 'package:flutter/foundation.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:flutter_svg/flutter_svg.dart';
import 'package:pedantic/pedantic.dart'; import 'package:pedantic/pedantic.dart';
@ -30,7 +30,7 @@ class UriPicture extends PictureProvider<UriPicture> {
Future<PictureInfo> _loadAsync(UriPicture key, {PictureErrorListener onError}) async { Future<PictureInfo> _loadAsync(UriPicture key, {PictureErrorListener onError}) async {
assert(key == this); assert(key == this);
final data = await ImageFileService.getImage(uri, mimeType, 0, false); final data = await ImageFileService.getSvg(uri, mimeType);
if (data == null || data.isEmpty) { if (data == null || data.isEmpty) {
return null; return null;
} }

View file

@ -2,10 +2,13 @@ import 'dart:isolate';
import 'dart:ui'; import 'dart:ui';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/media_store_source.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.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/route_tracker.dart';
import 'package:aves/widgets/common/behaviour/routes.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/home_page.dart';
import 'package:aves/widgets/welcome_page.dart'; import 'package:aves/widgets/welcome_page.dart';
import 'package:firebase_analytics/firebase_analytics.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/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:overlay_support/overlay_support.dart'; import 'package:overlay_support/overlay_support.dart';
import 'package:provider/provider.dart';
void main() { void main() {
// HttpClient.enableTimelineLogging = true; // enable network traffic logging // HttpClient.enableTimelineLogging = true; // enable network traffic logging
@ -43,12 +47,16 @@ class AvesApp extends StatefulWidget {
class _AvesAppState extends State<AvesApp> { class _AvesAppState extends State<AvesApp> {
Future<void> _appSetup; 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 // observers are not registered when using the same list object with different items
// the list itself needs to be reassigned // the list itself needs to be reassigned
List<NavigatorObserver> _navigatorObservers = []; List<NavigatorObserver> _navigatorObservers = [];
final _newIntentChannel = EventChannel('deckers.thibault/aves/intent'); final EventChannel _contentChangeChannel = EventChannel('deckers.thibault/aves/contentchange');
final _navigatorKey = GlobalKey<NavigatorState>(); final EventChannel _newIntentChannel = EventChannel('deckers.thibault/aves/intent');
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator');
static const accentColor = Colors.indigoAccent; static const accentColor = Colors.indigoAccent;
@ -94,9 +102,57 @@ class _AvesAppState extends State<AvesApp> {
void initState() { void initState() {
super.initState(); super.initState();
_appSetup = _setup(); _appSetup = _setup();
_contentChangeChannel.receiveBroadcastStream().listen((event) => _onContentChange(event as String));
_newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map)); _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 { Future<void> _setup() async {
await Firebase.initializeApp().then((app) { await Firebase.initializeApp().then((app) {
final crashlytics = FirebaseCrashlytics.instance; final crashlytics = FirebaseCrashlytics.instance;
@ -133,46 +189,11 @@ class _AvesAppState extends State<AvesApp> {
)); ));
} }
@override void _onContentChange(String uri) {
Widget build(BuildContext context) { changedUris.add(uri);
// place the settings provider above `MaterialApp` _contentChangeDebouncer(() {
// so it can be used during navigation transitions _mediaStoreSource.refreshUris(List.of(changedUris));
return SettingsProvider( changedUris.clear();
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()),
],
),
);
} }
} }

View file

@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart';
enum EntryAction { enum EntryAction {
delete, delete,
edit, edit,
export,
flip, flip,
info, info,
open, open,
@ -31,6 +32,7 @@ class EntryActions {
EntryAction.share, EntryAction.share,
EntryAction.delete, EntryAction.delete,
EntryAction.rename, EntryAction.rename,
EntryAction.export,
EntryAction.print, EntryAction.print,
EntryAction.viewSource, EntryAction.viewSource,
]; ];
@ -52,6 +54,8 @@ extension ExtraEntryAction on EntryAction {
return null; return null;
case EntryAction.delete: case EntryAction.delete:
return 'Delete'; return 'Delete';
case EntryAction.export:
return 'Export';
case EntryAction.info: case EntryAction.info:
return 'Info'; return 'Info';
case EntryAction.rename: case EntryAction.rename:
@ -91,6 +95,8 @@ extension ExtraEntryAction on EntryAction {
return null; return null;
case EntryAction.delete: case EntryAction.delete:
return AIcons.delete; return AIcons.delete;
case EntryAction.export:
return AIcons.export;
case EntryAction.info: case EntryAction.info:
return AIcons.info; return AIcons.info;
case EntryAction.rename: case EntryAction.rename:

View file

@ -0,0 +1 @@
enum MoveType { copy, move, export }

View 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');
}
}

View file

@ -2,7 +2,7 @@ import 'dart:async';
import 'package:aves/model/entry_cache.dart'; import 'package:aves/model/entry_cache.dart';
import 'package:aves/model/favourite_repo.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/metadata_db.dart';
import 'package:aves/model/multipage.dart'; import 'package:aves/model/multipage.dart';
import 'package:aves/services/image_file_service.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'; import '../ref/mime_types.dart';
class ImageEntry { class AvesEntry {
String uri; String uri;
String _path, _directory, _filename, _extension; String _path, _directory, _filename, _extension;
int contentId; int pageId, contentId;
final String sourceMimeType; final String sourceMimeType;
int width; int width;
int height; int height;
int sourceRotationDegrees; int sourceRotationDegrees;
final int sizeBytes; final int sizeBytes;
String sourceTitle; String sourceTitle;
// `dateModifiedSecs` can be missing in viewer mode
int _dateModifiedSecs; int _dateModifiedSecs;
final int sourceDateTakenMillis; final int sourceDateTakenMillis;
final int durationMillis; final int durationMillis;
@ -43,10 +45,11 @@ class ImageEntry {
// TODO TLAD make it dynamic if it depends on OS/lib versions // TODO TLAD make it dynamic if it depends on OS/lib versions
static const List<String> undecodable = [MimeTypes.crw, MimeTypes.psd]; static const List<String> undecodable = [MimeTypes.crw, MimeTypes.psd];
ImageEntry({ AvesEntry({
this.uri, this.uri,
String path, String path,
this.contentId, this.contentId,
this.pageId,
this.sourceMimeType, this.sourceMimeType,
@required this.width, @required this.width,
@required this.height, @required this.height,
@ -66,14 +69,14 @@ class ImageEntry {
bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType); bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType);
ImageEntry copyWith({ AvesEntry copyWith({
@required String uri, @required String uri,
@required String path, @required String path,
@required int contentId, @required int contentId,
@required int dateModifiedSecs, @required int dateModifiedSecs,
}) { }) {
final copyContentId = contentId ?? this.contentId; final copyContentId = contentId ?? this.contentId;
final copied = ImageEntry( final copied = AvesEntry(
uri: uri ?? uri, uri: uri ?? uri,
path: path ?? this.path, path: path ?? this.path,
contentId: copyContentId, contentId: copyContentId,
@ -93,9 +96,39 @@ class ImageEntry {
return copied; 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 // from DB or platform source entry
factory ImageEntry.fromMap(Map map) { factory AvesEntry.fromMap(Map map) {
return ImageEntry( return AvesEntry(
uri: map['uri'] as String, uri: map['uri'] as String,
path: map['path'] as String, path: map['path'] as String,
contentId: map['contentId'] as int, contentId: map['contentId'] as int,
@ -136,7 +169,7 @@ class ImageEntry {
} }
@override @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) { set path(String path) {
_path = path; _path = path;
@ -196,7 +229,11 @@ class ImageEntry {
].contains(mimeType) && ].contains(mimeType) &&
!isAnimated; !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); bool get isRaw => MimeTypes.rawImages.contains(mimeType);
@ -216,8 +253,6 @@ class ImageEntry {
bool get canEdit => path != null; bool get canEdit => path != null;
bool get canPrint => !isVideo;
bool get canRotateAndFlip => canEdit && canEditExif; bool get canRotateAndFlip => canEdit && canEditExif;
// support for writing EXIF // support for writing EXIF
@ -233,29 +268,21 @@ class ImageEntry {
} }
} }
// The additional comparison of width to height is a workaround for badly registered entries. // Media Store size/rotation is inaccurate, e.g. a portrait FHD video is rotated according to its metadata,
// e.g. a portrait FHD video should be registered as width=1920, height=1080, orientation=90, // so it should be registered as width=1920, height=1080, orientation=90,
// but is incorrectly registered in the Media Store as width=1080, height=1920, orientation=0 // but is incorrectly registered as width=1080, height=1920, orientation=0.
// Double-checking the width/height during loading or cataloguing is the proper solution, // Double-checking the width/height during loading or cataloguing is the proper solution, but it would take space and time.
// but it would take space and time, so a basic workaround will do. // Comparing width and height can help with the portrait FHD video example,
bool get isPortrait => rotationDegrees % 180 == 90 && (catalogMetadata?.rotationDegrees == null || width > height); // 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 ratioSeparator = '\u2236';
static const resolutionSeparator = ' \u00D7 '; static const resolutionSeparator = ' \u00D7 ';
String getResolutionText({MultiPageInfo multiPageInfo, int page}) { String get resolutionText {
int w; final ws = width ?? '?';
int h; final hs = height ?? '?';
if (multiPageInfo != null && page != null) { return isRotated ? '$hs$resolutionSeparator$ws' : '$ws$resolutionSeparator$hs';
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 aspectRatioText { String get aspectRatioText {
@ -263,7 +290,7 @@ class ImageEntry {
final gcd = width.gcd(height); final gcd = width.gcd(height);
final w = width ~/ gcd; final w = width ~/ gcd;
final h = height ~/ gcd; final h = height ~/ gcd;
return isPortrait ? '$h$ratioSeparator$w' : '$w$ratioSeparator$h'; return isRotated ? '$h$ratioSeparator$w' : '$w$ratioSeparator$h';
} else { } else {
return '?$ratioSeparator?'; return '?$ratioSeparator?';
} }
@ -271,20 +298,13 @@ class ImageEntry {
double get displayAspectRatio { double get displayAspectRatio {
if (width == 0 || height == 0) return 1; 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}) { Size get displaySize {
int w; final w = width.toDouble();
int h; final h = height.toDouble();
if (multiPageInfo != null && page != null) { return isRotated ? Size(h, w) : Size(w, h);
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());
} }
int get megaPixels => width != null && height != null ? (width * height / 1000000).round() : null; int get megaPixels => width != null && height != null ? (width * height / 1000000).round() : null;
@ -598,7 +618,7 @@ class ImageEntry {
// compare by: // compare by:
// 1) title ascending // 1) title ascending
// 2) extension 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); final c = compareAsciiUpperCase(a.bestTitle, b.bestTitle);
return c != 0 ? c : compareAsciiUpperCase(a.extension, b.extension); return c != 0 ? c : compareAsciiUpperCase(a.extension, b.extension);
} }
@ -606,7 +626,7 @@ class ImageEntry {
// compare by: // compare by:
// 1) size descending // 1) size descending
// 2) name ascending // 2) name ascending
static int compareBySize(ImageEntry a, ImageEntry b) { static int compareBySize(AvesEntry a, AvesEntry b) {
final c = b.sizeBytes.compareTo(a.sizeBytes); final c = b.sizeBytes.compareTo(a.sizeBytes);
return c != 0 ? c : compareByName(a, b); return c != 0 ? c : compareByName(a, b);
} }
@ -615,9 +635,12 @@ class ImageEntry {
// compare by: // compare by:
// 1) date descending // 1) date descending
// 2) name ascending // 2) name descending
static int compareByDate(ImageEntry a, ImageEntry b) { static int compareByDate(AvesEntry a, AvesEntry b) {
final c = (b.bestDate ?? _epoch).compareTo(a.bestDate ?? _epoch); var c = (b.bestDate ?? _epoch).compareTo(a.bestDate ?? _epoch);
return c != 0 ? c : compareByName(a, b); if (c != 0) return c;
c = (b.dateModifiedSecs ?? 0).compareTo(a.dateModifiedSecs ?? 0);
if (c != 0) return c;
return -compareByName(a, b);
} }
} }

View file

@ -12,14 +12,14 @@ class EntryCache {
int oldRotationDegrees, int oldRotationDegrees,
bool oldIsFlipped, bool oldIsFlipped,
) async { ) async {
// TODO TLAD revisit this for multipage items, if someday image editing features are added for them // TODO TLAD provide pageId parameter for multipage items, if someday image editing features are added for them
const page = 0; int pageId;
// evict fullscreen image // evict fullscreen image
await UriImage( await UriImage(
uri: uri, uri: uri,
mimeType: mimeType, mimeType: mimeType,
page: page, pageId: pageId,
rotationDegrees: oldRotationDegrees, rotationDegrees: oldRotationDegrees,
isFlipped: oldIsFlipped, isFlipped: oldIsFlipped,
).evict(); ).evict();
@ -28,10 +28,10 @@ class EntryCache {
await ThumbnailProvider(ThumbnailProviderKey( await ThumbnailProvider(ThumbnailProviderKey(
uri: uri, uri: uri,
mimeType: mimeType, mimeType: mimeType,
pageId: pageId,
dateModifiedSecs: dateModifiedSecs, dateModifiedSecs: dateModifiedSecs,
rotationDegrees: oldRotationDegrees, rotationDegrees: oldRotationDegrees,
isFlipped: oldIsFlipped, isFlipped: oldIsFlipped,
page: page,
)).evict(); )).evict();
// evict higher quality thumbnails (with powers of 2 from 32 to 1024 as specified extents) // evict higher quality thumbnails (with powers of 2 from 32 to 1024 as specified extents)
@ -41,10 +41,10 @@ class EntryCache {
(extent) => ThumbnailProvider(ThumbnailProviderKey( (extent) => ThumbnailProvider(ThumbnailProviderKey(
uri: uri, uri: uri,
mimeType: mimeType, mimeType: mimeType,
pageId: pageId,
dateModifiedSecs: dateModifiedSecs, dateModifiedSecs: dateModifiedSecs,
rotationDegrees: oldRotationDegrees, rotationDegrees: oldRotationDegrees,
isFlipped: oldIsFlipped, isFlipped: oldIsFlipped,
page: page,
extent: extent, extent: extent,
)).evict()); )).evict());
} }

View 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();
}
}

View file

@ -1,5 +1,5 @@
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/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/metadata_db.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';
@ -18,25 +18,25 @@ class FavouriteRepo {
int get count => _rows.length; 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); final newRows = entries.map(_entryToRow);
await metadataDb.addFavourites(newRows); await metadataDb.addFavourites(newRows);
_rows.addAll(newRows); _rows.addAll(newRows);
changeNotifier.notifyListeners(); changeNotifier.notifyListeners();
} }
Future<void> remove(Iterable<ImageEntry> entries) async { Future<void> remove(Iterable<AvesEntry> entries) async {
final removedRows = entries.map(_entryToRow); final removedRows = entries.map(_entryToRow);
await metadataDb.removeFavourites(removedRows); await metadataDb.removeFavourites(removedRows);
removedRows.forEach(_rows.remove); removedRows.forEach(_rows.remove);
changeNotifier.notifyListeners(); 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); final oldRow = _rows.firstWhere((row) => row.contentId == oldContentId, orElse: () => null);
if (oldRow != null) { if (oldRow != null) {
_rows.remove(oldRow); _rows.remove(oldRow);

View file

@ -1,6 +1,6 @@
import 'package:aves/image_providers/app_icon_image_provider.dart'; import 'package:aves/image_providers/app_icon_image_provider.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/common/identity/aves_icons.dart'; import 'package:aves/widgets/common/identity/aves_icons.dart';
@ -33,7 +33,7 @@ class AlbumFilter extends CollectionFilter {
}; };
@override @override
bool filter(ImageEntry entry) => entry.directory == album; bool filter(AvesEntry entry) => entry.directory == album;
@override @override
String get label => uniqueName ?? album.split(separator).last; String get label => uniqueName ?? album.split(separator).last;

View file

@ -1,5 +1,5 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -13,7 +13,7 @@ class FavouriteFilter extends CollectionFilter {
}; };
@override @override
bool filter(ImageEntry entry) => entry.isFavourite; bool filter(AvesEntry entry) => entry.isFavourite;
@override @override
String get label => 'Favourite'; String get label => 'Favourite';

View file

@ -1,12 +1,12 @@
import 'dart:convert'; import 'dart:convert';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/filters/query.dart'; import 'package:aves/model/filters/query.dart';
import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/utils/color_utils.dart'; import 'package:aves/utils/color_utils.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -49,7 +49,7 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
String toJson() => jsonEncode(toMap()); String toJson() => jsonEncode(toMap());
bool filter(ImageEntry entry); bool filter(AvesEntry entry);
bool get isUnique => true; 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 // TODO TLAD replace this by adding getters to CollectionFilter, with cached entry/count coming from Source
class FilterGridItem<T extends CollectionFilter> { class FilterGridItem<T extends CollectionFilter> {
final T filter; final T filter;
final ImageEntry entry; final AvesEntry entry;
const FilterGridItem(this.filter, this.entry); const FilterGridItem(this.filter, this.entry);

View file

@ -1,5 +1,5 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -35,7 +35,7 @@ class LocationFilter extends CollectionFilter {
String get countryNameAndCode => '$_location$locationSeparator$_countryCode'; String get countryNameAndCode => '$_location$locationSeparator$_countryCode';
@override @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 @override
String get label => _location.isEmpty ? emptyLabel : _location; String get label => _location.isEmpty ? emptyLabel : _location;

View file

@ -1,5 +1,5 @@
import 'package:aves/model/filters/filters.dart'; 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/theme/icons.dart';
import 'package:aves/utils/mime_utils.dart'; import 'package:aves/utils/mime_utils.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -15,7 +15,7 @@ class MimeFilter extends CollectionFilter {
static const geotiff = 'aves/geotiff'; // subset of `image/tiff` static const geotiff = 'aves/geotiff'; // subset of `image/tiff`
final String mime; final String mime;
bool Function(ImageEntry) _filter; bool Function(AvesEntry) _filter;
String _label; String _label;
IconData _icon; IconData _icon;
@ -67,7 +67,7 @@ class MimeFilter extends CollectionFilter {
}; };
@override @override
bool filter(ImageEntry entry) => _filter(entry); bool filter(AvesEntry entry) => _filter(entry);
@override @override
String get label => _label; String get label => _label;

View file

@ -1,5 +1,5 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -12,7 +12,7 @@ class QueryFilter extends CollectionFilter {
final String query; final String query;
final bool colorful; final bool colorful;
bool Function(ImageEntry) _filter; bool Function(AvesEntry) _filter;
QueryFilter(this.query, {this.colorful = true}) { QueryFilter(this.query, {this.colorful = true}) {
var upQuery = query.toUpperCase(); var upQuery = query.toUpperCase();
@ -44,7 +44,7 @@ class QueryFilter extends CollectionFilter {
}; };
@override @override
bool filter(ImageEntry entry) => _filter(entry); bool filter(AvesEntry entry) => _filter(entry);
@override @override
bool get isUnique => false; bool get isUnique => false;

View file

@ -1,5 +1,5 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -24,7 +24,7 @@ class TagFilter extends CollectionFilter {
}; };
@override @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 @override
bool get isUnique => false; bool get isUnique => false;

View file

@ -68,17 +68,19 @@ class CatalogMetadata {
} }
CatalogMetadata copyWith({ CatalogMetadata copyWith({
@required int contentId, int contentId,
String mimeType,
bool isMultipage,
}) { }) {
return CatalogMetadata( return CatalogMetadata(
contentId: contentId ?? this.contentId, contentId: contentId ?? this.contentId,
mimeType: mimeType, mimeType: mimeType ?? this.mimeType,
dateMillis: dateMillis, dateMillis: dateMillis,
isAnimated: isAnimated, isAnimated: isAnimated,
isFlipped: isFlipped, isFlipped: isFlipped,
isGeotiff: isGeotiff, isGeotiff: isGeotiff,
is360: is360, is360: is360,
isMultipage: isMultipage, isMultipage: isMultipage ?? this.isMultipage,
rotationDegrees: rotationDegrees, rotationDegrees: rotationDegrees,
xmpSubjects: xmpSubjects, xmpSubjects: xmpSubjects,
xmpTitleDescription: xmpTitleDescription, xmpTitleDescription: xmpTitleDescription,
@ -169,7 +171,7 @@ class AddressDetails {
}); });
AddressDetails copyWith({ AddressDetails copyWith({
@required int contentId, int contentId,
}) { }) {
return AddressDetails( return AddressDetails(
contentId: contentId ?? this.contentId, contentId: contentId ?? this.contentId,

View file

@ -1,7 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/metadata.dart';
import 'package:aves/model/metadata_db_upgrade.dart'; import 'package:aves/model/metadata_db_upgrade.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
@ -116,16 +116,16 @@ class MetadataDb {
debugPrint('$runtimeType clearEntries deleted $count entries'); debugPrint('$runtimeType clearEntries deleted $count entries');
} }
Future<List<ImageEntry>> loadEntries() async { Future<List<AvesEntry>> loadEntries() async {
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
final db = await _database; final db = await _database;
final maps = await db.query(entryTable); final maps = await db.query(entryTable);
final entries = maps.map((map) => 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'); debugPrint('$runtimeType loadEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries');
return entries; return entries;
} }
Future<void> saveEntries(Iterable<ImageEntry> entries) async { Future<void> saveEntries(Iterable<AvesEntry> entries) async {
if (entries == null || entries.isEmpty) return; if (entries == null || entries.isEmpty) return;
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
final db = await _database; final db = await _database;
@ -135,7 +135,7 @@ class MetadataDb {
debugPrint('$runtimeType saveEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries'); 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 db = await _database;
final batch = db.batch(); final batch = db.batch();
batch.delete(entryTable, where: 'contentId = ?', whereArgs: [oldId]); batch.delete(entryTable, where: 'contentId = ?', whereArgs: [oldId]);
@ -143,7 +143,7 @@ class MetadataDb {
await batch.commit(noResult: true); await batch.commit(noResult: true);
} }
void _batchInsertEntry(Batch batch, ImageEntry entry) { void _batchInsertEntry(Batch batch, AvesEntry entry) {
if (entry == null) return; if (entry == null) return;
batch.insert( batch.insert(
entryTable, entryTable,

View file

@ -1,42 +1,83 @@
import 'package:flutter/foundation.dart'; 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 { class MultiPageInfo {
final Map<int, SinglePageInfo> pages; final List<SinglePageInfo> pages;
int get pageCount => pages.length; int get pageCount => pages.length;
MultiPageInfo({ MultiPageInfo({
this.pages, this.pages,
}); }) {
if (pages.isNotEmpty) {
factory MultiPageInfo.fromMap(Map map) { pages.sort();
final pages = <int, SinglePageInfo>{}; // make sure there is a page marked as default
map.keys.forEach((key) { if (defaultPage == null) {
final index = key as int; final firstPage = pages.removeAt(0);
pages.putIfAbsent(index, () => SinglePageInfo.fromMap(map[key])); pages.insert(0, firstPage.copyWith(isDefault: true));
}); }
return MultiPageInfo(pages: pages); }
} }
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 @override
String toString() => '$runtimeType#${shortHash(this)}{pages=$pages}'; 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);
}

View file

@ -4,24 +4,38 @@ import 'package:flutter/widgets.dart';
class PanoramaInfo { class PanoramaInfo {
final Rect croppedAreaRect; final Rect croppedAreaRect;
final Size fullPanoSize; final Size fullPanoSize;
final String projectionType;
PanoramaInfo({ PanoramaInfo({
this.croppedAreaRect, this.croppedAreaRect,
this.fullPanoSize, this.fullPanoSize,
this.projectionType,
}); });
factory PanoramaInfo.fromMap(Map map) { factory PanoramaInfo.fromMap(Map map) {
final cLeft = map['croppedAreaLeft'] as int; var cLeft = map['croppedAreaLeft'] as int;
final cTop = map['croppedAreaTop'] as int; var cTop = map['croppedAreaTop'] as int;
final cWidth = map['croppedAreaWidth'] as int; final cWidth = map['croppedAreaWidth'] as int;
final cHeight = map['croppedAreaHeight'] 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; Rect croppedAreaRect;
if (cLeft != null && cTop != null && cWidth != null && cHeight != null) { if (cLeft != null && cTop != null && cWidth != null && cHeight != null) {
croppedAreaRect = Rect.fromLTWH(cLeft.toDouble(), cTop.toDouble(), cWidth.toDouble(), cHeight.toDouble()); 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; Size fullPanoSize;
if (fWidth != null && fHeight != null) { if (fWidth != null && fHeight != null) {
fullPanoSize = Size(fWidth.toDouble(), fHeight.toDouble()); fullPanoSize = Size(fWidth.toDouble(), fHeight.toDouble());
@ -30,11 +44,12 @@ class PanoramaInfo {
return PanoramaInfo( return PanoramaInfo(
croppedAreaRect: croppedAreaRect, croppedAreaRect: croppedAreaRect,
fullPanoSize: fullPanoSize, fullPanoSize: fullPanoSize,
projectionType: projectionType,
); );
} }
bool get hasCroppedArea => croppedAreaRect != null && fullPanoSize != null; bool get hasCroppedArea => croppedAreaRect != null && fullPanoSize != null;
@override @override
String toString() => '$runtimeType#${shortHash(this)}{croppedAreaRect=$croppedAreaRect, fullPanoSize=$fullPanoSize}'; String toString() => '$runtimeType#${shortHash(this)}{croppedAreaRect=$croppedAreaRect, fullPanoSize=$fullPanoSize, projectionType=$projectionType}';
} }

View 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;
}
}
}

View file

@ -2,8 +2,8 @@ import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/coordinate_format.dart'; import 'package:aves/model/settings/coordinate_format.dart';
import 'package:aves/model/settings/entry_background.dart'; import 'package:aves/model/settings/entry_background.dart';
import 'package:aves/model/settings/home_page.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/model/settings/screen_on.dart';
import 'package:aves/widgets/viewer/info/location_section.dart';
import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart';

View file

@ -1,5 +1,5 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.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/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/utils/android_file_utils.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 entries = sortedEntriesForFilterList;
final regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[]; final regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[];
for (var album in sortedAlbums) { for (final album in sortedAlbums) {
switch (androidFileUtils.getAlbumType(album)) { switch (androidFileUtils.getAlbumType(album)) {
case AlbumType.regular: case AlbumType.regular:
regularAlbums.add(album); regularAlbums.add(album);

View file

@ -1,9 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/section_keys.dart'; import 'package:aves/model/source/section_keys.dart';
import 'package:aves/model/source/tag.dart'; import 'package:aves/model/source/tag.dart';
@ -19,24 +19,28 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
EntryGroupFactor groupFactor; EntryGroupFactor groupFactor;
EntrySortFactor sortFactor; EntrySortFactor sortFactor;
final AChangeNotifier filterChangeNotifier = AChangeNotifier(); final AChangeNotifier filterChangeNotifier = AChangeNotifier();
bool listenToSource;
List<ImageEntry> _filteredEntries; List<AvesEntry> _filteredEntries;
List<StreamSubscription> _subscriptions = []; List<StreamSubscription> _subscriptions = [];
Map<SectionKey, List<ImageEntry>> sections = Map.unmodifiable({}); Map<SectionKey, List<AvesEntry>> sections = Map.unmodifiable({});
CollectionLens({ CollectionLens({
@required this.source, @required this.source,
Iterable<CollectionFilter> filters, Iterable<CollectionFilter> filters,
@required EntryGroupFactor groupFactor, @required EntryGroupFactor groupFactor,
@required EntrySortFactor sortFactor, @required EntrySortFactor sortFactor,
this.listenToSource = true,
}) : filters = {if (filters != null) ...filters.where((f) => f != null)}, }) : filters = {if (filters != null) ...filters.where((f) => f != null)},
groupFactor = groupFactor ?? EntryGroupFactor.month, groupFactor = groupFactor ?? EntryGroupFactor.month,
sortFactor = sortFactor ?? EntrySortFactor.date { sortFactor = sortFactor ?? EntrySortFactor.date {
_subscriptions.add(source.eventBus.on<EntryAddedEvent>().listen((e) => _refresh())); if (listenToSource) {
_subscriptions.add(source.eventBus.on<EntryRemovedEvent>().listen((e) => onEntryRemoved(e.entries))); _subscriptions.add(source.eventBus.on<EntryAddedEvent>().listen((e) => _refresh()));
_subscriptions.add(source.eventBus.on<EntryMovedEvent>().listen((e) => _refresh())); _subscriptions.add(source.eventBus.on<EntryRemovedEvent>().listen((e) => onEntryRemoved(e.entries)));
_subscriptions.add(source.eventBus.on<CatalogMetadataChangedEvent>().listen((e) => _refresh())); _subscriptions.add(source.eventBus.on<EntryMovedEvent>().listen((e) => _refresh()));
_subscriptions.add(source.eventBus.on<CatalogMetadataChangedEvent>().listen((e) => _refresh()));
}
_refresh(); _refresh();
} }
@ -49,23 +53,14 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
super.dispose(); super.dispose();
} }
CollectionLens derive(CollectionFilter filter) {
return CollectionLens(
source: source,
filters: filters,
groupFactor: groupFactor,
sortFactor: sortFactor,
)..addFilter(filter);
}
bool get isEmpty => _filteredEntries.isEmpty; bool get isEmpty => _filteredEntries.isEmpty;
int get entryCount => _filteredEntries.length; int get entryCount => _filteredEntries.length;
// sorted as displayed to the user, i.e. sorted then grouped, not an absolute order on all entries // 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)); _sortedEntries ??= List.of(sections.entries.expand((e) => e.value));
return _sortedEntries; return _sortedEntries;
} }
@ -82,7 +77,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
return true; return true;
} }
Object heroTag(ImageEntry entry) => '$hashCode${entry.uri}'; Object heroTag(AvesEntry entry) => entry.uri;
void addFilter(CollectionFilter filter) { void addFilter(CollectionFilter filter) {
if (filter == null || filters.contains(filter)) return; if (filter == null || filters.contains(filter)) return;
@ -123,13 +118,13 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
void _applySort() { void _applySort() {
switch (sortFactor) { switch (sortFactor) {
case EntrySortFactor.date: case EntrySortFactor.date:
_filteredEntries.sort(ImageEntry.compareByDate); _filteredEntries.sort(AvesEntry.compareByDate);
break; break;
case EntrySortFactor.size: case EntrySortFactor.size:
_filteredEntries.sort(ImageEntry.compareBySize); _filteredEntries.sort(AvesEntry.compareBySize);
break; break;
case EntrySortFactor.name: case EntrySortFactor.name:
_filteredEntries.sort(ImageEntry.compareByName); _filteredEntries.sort(AvesEntry.compareByName);
break; break;
} }
} }
@ -139,13 +134,13 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
case EntrySortFactor.date: case EntrySortFactor.date:
switch (groupFactor) { switch (groupFactor) {
case EntryGroupFactor.album: case EntryGroupFactor.album:
sections = groupBy<ImageEntry, EntryAlbumSectionKey>(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory)); sections = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory));
break; break;
case EntryGroupFactor.month: case EntryGroupFactor.month:
sections = groupBy<ImageEntry, EntryDateSectionKey>(_filteredEntries, (entry) => EntryDateSectionKey(entry.monthTaken)); sections = groupBy<AvesEntry, EntryDateSectionKey>(_filteredEntries, (entry) => EntryDateSectionKey(entry.monthTaken));
break; break;
case EntryGroupFactor.day: case EntryGroupFactor.day:
sections = groupBy<ImageEntry, EntryDateSectionKey>(_filteredEntries, (entry) => EntryDateSectionKey(entry.dayTaken)); sections = groupBy<AvesEntry, EntryDateSectionKey>(_filteredEntries, (entry) => EntryDateSectionKey(entry.dayTaken));
break; break;
case EntryGroupFactor.none: case EntryGroupFactor.none:
sections = Map.fromEntries([ sections = Map.fromEntries([
@ -160,8 +155,8 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
]); ]);
break; break;
case EntrySortFactor.name: case EntrySortFactor.name:
final byAlbum = groupBy<ImageEntry, EntryAlbumSectionKey>(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory)); final byAlbum = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory));
sections = SplayTreeMap<EntryAlbumSectionKey, List<ImageEntry>>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.folderPath, b.folderPath)); sections = SplayTreeMap<EntryAlbumSectionKey, List<AvesEntry>>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.folderPath, b.folderPath));
break; break;
} }
sections = Map.unmodifiable(sections); sections = Map.unmodifiable(sections);
@ -177,7 +172,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
_applyGroup(); _applyGroup();
} }
void onEntryRemoved(Iterable<ImageEntry> entries) { void onEntryRemoved(Iterable<AvesEntry> entries) {
// we should remove obsolete entries and sections // we should remove obsolete entries and sections
// but do not apply sort/group // but do not apply sort/group
// as section order change would surprise the user while browsing // as section order change would surprise the user while browsing
@ -207,18 +202,18 @@ mixin CollectionActivityMixin {
mixin CollectionSelectionMixin on CollectionActivityMixin { mixin CollectionSelectionMixin on CollectionActivityMixin {
final AChangeNotifier selectionChangeNotifier = AChangeNotifier(); 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); _selection.addAll(entries);
selectionChangeNotifier.notifyListeners(); selectionChangeNotifier.notifyListeners();
} }
void removeFromSelection(Iterable<ImageEntry> entries) { void removeFromSelection(Iterable<AvesEntry> entries) {
_selection.removeAll(entries); _selection.removeAll(entries);
selectionChangeNotifier.notifyListeners(); selectionChangeNotifier.notifyListeners();
} }
@ -228,7 +223,7 @@ mixin CollectionSelectionMixin on CollectionActivityMixin {
selectionChangeNotifier.notifyListeners(); selectionChangeNotifier.notifyListeners();
} }
void toggleSelection(ImageEntry entry) { void toggleSelection(AvesEntry entry) {
if (_selection.isEmpty) select(); if (_selection.isEmpty) select();
if (!_selection.remove(entry)) _selection.add(entry); if (!_selection.remove(entry)) _selection.add(entry);
selectionChangeNotifier.notifyListeners(); selectionChangeNotifier.notifyListeners();

View file

@ -1,30 +1,30 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/model/entry.dart';
import 'package:aves/model/favourite_repo.dart'; import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/metadata.dart';
import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/metadata_db.dart'; import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/source/album.dart'; import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/tag.dart'; import 'package:aves/model/source/tag.dart';
import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/image_op_events.dart';
import 'package:event_bus/event_bus.dart'; import 'package:event_bus/event_bus.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'enums.dart'; import 'enums.dart';
mixin SourceBase { 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(); final EventBus _eventBus = EventBus();
EventBus get eventBus => _eventBus; EventBus get eventBus => _eventBus;
List<ImageEntry> get sortedEntriesForFilterList; List<AvesEntry> get sortedEntriesForFilterList;
final Map<CollectionFilter, int> _filterEntryCountMap = {}; final Map<CollectionFilter, int> _filterEntryCountMap = {};
@ -39,7 +39,7 @@ mixin SourceBase {
abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin { abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin {
@override @override
List<ImageEntry> get sortedEntriesForFilterList => CollectionLens( List<AvesEntry> get sortedEntriesForFilterList => CollectionLens(
source: this, source: this,
groupFactor: EntryGroupFactor.none, groupFactor: EntryGroupFactor.none,
sortFactor: EntrySortFactor.date, 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'); 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) { if (_rawEntries.isNotEmpty) {
final newContentIds = entries.map((entry) => entry.contentId).toList(); final newContentIds = entries.map((entry) => entry.contentId).toList();
_rawEntries.removeWhere((entry) => newContentIds.contains(entry.contentId)); _rawEntries.removeWhere((entry) => newContentIds.contains(entry.contentId));
@ -70,7 +71,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
eventBus.fire(EntryAddedEvent()); eventBus.fire(EntryAddedEvent());
} }
void removeEntries(List<ImageEntry> entries) { void removeEntries(List<AvesEntry> entries) {
entries.forEach((entry) => entry.removeFromFavourites()); entries.forEach((entry) => entry.removeFromFavourites());
_rawEntries.removeWhere(entries.contains); _rawEntries.removeWhere(entries.contains);
cleanEmptyAlbums(entries.map((entry) => entry.directory).toSet()); 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, // `dateModifiedSecs` changes when moving entries to another directory,
// but it does not change when renaming the containing 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 oldContentId = entry.contentId;
final newContentId = newFields['contentId'] as int; final newContentId = newFields['contentId'] as int;
final newDateModifiedSecs = newFields['dateModifiedSecs'] as int; final newDateModifiedSecs = newFields['dateModifiedSecs'] as int;
@ -109,7 +110,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
} }
void updateAfterMove({ void updateAfterMove({
@required Set<ImageEntry> selection, @required Set<AvesEntry> selection,
@required bool copy, @required bool copy,
@required String destinationAlbum, @required String destinationAlbum,
@required Iterable<MoveOpEvent> movedOps, @required Iterable<MoveOpEvent> movedOps,
@ -117,7 +118,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
if (movedOps.isEmpty) return; if (movedOps.isEmpty) return;
final fromAlbums = <String>{}; final fromAlbums = <String>{};
final movedEntries = <ImageEntry>[]; final movedEntries = <AvesEntry>[];
if (copy) { if (copy) {
movedOps.forEach((movedOp) { movedOps.forEach((movedOp) {
final sourceUri = movedOp.uri; 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); return _filterEntryCountMap.putIfAbsent(filter, () => _rawEntries.where((entry) => filter.filter(entry)).length);
} }
bool get initialized => false;
Future<void> init();
Future<void> refresh(); Future<void> refresh();
Future<void> refreshMetadata(Set<ImageEntry> entries); Future<void> refreshMetadata(Set<AvesEntry> entries);
} }
enum SourceState { loading, cataloguing, locating, ready } enum SourceState { loading, cataloguing, locating, ready }
class EntryAddedEvent { class EntryAddedEvent {
final ImageEntry entry; final AvesEntry entry;
const EntryAddedEvent([this.entry]); const EntryAddedEvent([this.entry]);
} }
class EntryRemovedEvent { class EntryRemovedEvent {
final Iterable<ImageEntry> entries; final Iterable<AvesEntry> entries;
const EntryRemovedEvent(this.entries); const EntryRemovedEvent(this.entries);
} }
class EntryMovedEvent { class EntryMovedEvent {
final Iterable<ImageEntry> entries; final Iterable<AvesEntry> entries;
const EntryMovedEvent(this.entries); const EntryMovedEvent(this.entries);
} }

View file

@ -1,8 +1,9 @@
import 'dart:math'; 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/filters/location.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/metadata.dart';
import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/metadata_db.dart'; import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -27,8 +28,10 @@ mixin LocationMixin on SourceBase {
} }
Future<void> locateEntries() async { Future<void> locateEntries() async {
if (!(await connectivity.canGeolocate)) return;
// final stopwatch = Stopwatch()..start(); // 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] ?? []; final todo = byLocated[false] ?? [];
if (todo.isEmpty) return; 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) // - 652 calls (22%) when approximating to 2 decimal places (~1km - town or village)
// cf https://en.wikipedia.org/wiki/Decimal_degrees#Precision // cf https://en.wikipedia.org/wiki/Decimal_degrees#Precision
final latLngFactor = pow(10, 2); final latLngFactor = pow(10, 2);
Tuple2 approximateLatLng(ImageEntry entry) { Tuple2 approximateLatLng(AvesEntry entry) {
final lat = entry.catalogMetadata?.latitude; final lat = entry.catalogMetadata?.latitude;
final lng = entry.catalogMetadata?.longitude; final lng = entry.catalogMetadata?.longitude;
if (lat == null || lng == null) return null; if (lat == null || lng == null) return null;
@ -57,7 +60,7 @@ mixin LocationMixin on SourceBase {
setProgress(done: progressDone, total: progressTotal); setProgress(done: progressDone, total: progressTotal);
final newAddresses = <AddressDetails>[]; final newAddresses = <AddressDetails>[];
await Future.forEach<ImageEntry>(todo, (entry) async { await Future.forEach<AvesEntry>(todo, (entry) async {
final latLng = approximateLatLng(entry); final latLng = approximateLatLng(entry);
if (knownLocations.containsKey(latLng)) { if (knownLocations.containsKey(latLng)) {
entry.addressDetails = knownLocations[latLng]?.copyWith(contentId: entry.contentId); entry.addressDetails = knownLocations[latLng]?.copyWith(contentId: entry.contentId);

View file

@ -1,7 +1,8 @@
import 'dart:async';
import 'dart:math'; import 'dart:math';
import 'package:aves/model/entry.dart';
import 'package:aves/model/favourite_repo.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/metadata_db.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
@ -13,6 +14,12 @@ import 'package:flutter_native_timezone/flutter_native_timezone.dart';
import 'package:pedantic/pedantic.dart'; import 'package:pedantic/pedantic.dart';
class MediaStoreSource extends CollectionSource { class MediaStoreSource extends CollectionSource {
bool _initialized = false;
@override
bool get initialized => _initialized;
@override
Future<void> init() async { Future<void> init() async {
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
stateNotifier.value = SourceState.loading; stateNotifier.value = SourceState.loading;
@ -28,11 +35,13 @@ class MediaStoreSource extends CollectionSource {
settings.catalogTimeZone = currentTimeZone; settings.catalogTimeZone = currentTimeZone;
} }
await loadDates(); // 100ms for 5400 entries await loadDates(); // 100ms for 5400 entries
_initialized = true;
debugPrint('$runtimeType init done, elapsed=${stopwatch.elapsed}'); debugPrint('$runtimeType init done, elapsed=${stopwatch.elapsed}');
} }
@override @override
Future<void> refresh() async { Future<void> refresh() async {
assert(_initialized);
debugPrint('$runtimeType refresh start'); debugPrint('$runtimeType refresh start');
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
stateNotifier.value = SourceState.loading; stateNotifier.value = SourceState.loading;
@ -40,8 +49,8 @@ class MediaStoreSource extends CollectionSource {
final oldEntries = await metadataDb.loadEntries(); // 400ms for 5500 entries final oldEntries = await metadataDb.loadEntries(); // 400ms for 5500 entries
final knownEntryMap = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId, entry.dateModifiedSecs))); final knownEntryMap = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId, entry.dateModifiedSecs)));
final obsoleteEntries = (await ImageFileService.getObsoleteEntries(knownEntryMap.keys.toList())).toSet(); final obsoleteContentIds = (await ImageFileService.getObsoleteEntries(knownEntryMap.keys.toList())).toSet();
oldEntries.removeWhere((entry) => obsoleteEntries.contains(entry.contentId)); oldEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId));
// show known entries // show known entries
addAll(oldEntries); addAll(oldEntries);
@ -50,19 +59,20 @@ class MediaStoreSource extends CollectionSource {
debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}'); debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}');
// clean up obsolete entries // clean up obsolete entries
metadataDb.removeIds(obsoleteEntries, updateFavourites: true); metadataDb.removeIds(obsoleteContentIds, updateFavourites: true);
// fetch new entries // fetch new entries
// refresh after the first 10 entries, then after 100 more, then every 1000 entries
var refreshCount = 10; var refreshCount = 10;
const refreshCountMax = 1000; const refreshCountMax = 1000;
final allNewEntries = <ImageEntry>[], pendingNewEntries = <ImageEntry>[]; final allNewEntries = <AvesEntry>[], pendingNewEntries = <AvesEntry>[];
void addPendingEntries() { void addPendingEntries() {
allNewEntries.addAll(pendingNewEntries); allNewEntries.addAll(pendingNewEntries);
addAll(pendingNewEntries); addAll(pendingNewEntries);
pendingNewEntries.clear(); pendingNewEntries.clear();
} }
ImageFileService.getImageEntries(knownEntryMap).listen( ImageFileService.getEntries(knownEntryMap).listen(
(entry) { (entry) {
pendingNewEntries.add(entry); pendingNewEntries.add(entry);
if (pendingNewEntries.length >= refreshCount) { 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 @override
Future<void> refreshMetadata(Set<ImageEntry> entries) { Future<void> refreshMetadata(Set<AvesEntry> entries) {
final contentIds = entries.map((entry) => entry.contentId).toSet(); final contentIds = entries.map((entry) => entry.contentId).toSet();
metadataDb.removeIds(contentIds, updateFavourites: false); metadataDb.removeIds(contentIds, updateFavourites: false);
return refresh(); return refresh();

View file

@ -1,5 +1,5 @@
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/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/metadata_db.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -31,7 +31,7 @@ mixin TagMixin on SourceBase {
setProgress(done: progressDone, total: progressTotal); setProgress(done: progressDone, total: progressTotal);
final newMetadata = <CatalogMetadata>[]; final newMetadata = <CatalogMetadata>[];
await Future.forEach<ImageEntry>(todo, (entry) async { await Future.forEach<AvesEntry>(todo, (entry) async {
await entry.catalog(background: true); await entry.catalog(background: true);
if (entry.isCatalogued) { if (entry.isCatalogued) {
newMetadata.add(entry.catalogMetadata); newMetadata.add(entry.catalogMetadata);

View file

@ -1,6 +1,7 @@
import 'dart:typed_data'; 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:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -8,12 +9,18 @@ import 'package:flutter/services.dart';
class AndroidAppService { class AndroidAppService {
static const platform = MethodChannel('deckers.thibault/aves/app'); static const platform = MethodChannel('deckers.thibault/aves/app');
static Future<Map> getAppNames() async { static Future<Set<Package>> getPackages() async {
try { try {
final result = await platform.invokeMethod('getAppNames'); final result = await platform.invokeMethod('getPackages');
return result as Map; 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) { } 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 {}; return {};
} }
@ -81,10 +88,10 @@ class AndroidAppService {
return false; 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 // 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 // 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 { try {
return await platform.invokeMethod('share', <String, dynamic>{ return await platform.invokeMethod('share', <String, dynamic>{
'title': 'Share via:', 'title': 'Share via:',

View file

@ -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:aves/ref/mime_types.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -26,7 +26,7 @@ class AndroidDebugService {
return {}; return {};
} }
static Future<Map> getBitmapFactoryInfo(ImageEntry entry) async { static Future<Map> getBitmapFactoryInfo(AvesEntry entry) async {
try { try {
// return map with all data available when decoding image bounds with `BitmapFactory` // return map with all data available when decoding image bounds with `BitmapFactory`
final result = await platform.invokeMethod('getBitmapFactoryInfo', <String, dynamic>{ final result = await platform.invokeMethod('getBitmapFactoryInfo', <String, dynamic>{
@ -39,7 +39,7 @@ class AndroidDebugService {
return {}; return {};
} }
static Future<Map> getContentResolverMetadata(ImageEntry entry) async { static Future<Map> getContentResolverMetadata(AvesEntry entry) async {
try { try {
// return map with all data available from the content resolver // return map with all data available from the content resolver
final result = await platform.invokeMethod('getContentResolverMetadata', <String, dynamic>{ final result = await platform.invokeMethod('getContentResolverMetadata', <String, dynamic>{
@ -53,7 +53,7 @@ class AndroidDebugService {
return {}; return {};
} }
static Future<Map> getExifInterfaceMetadata(ImageEntry entry) async { static Future<Map> getExifInterfaceMetadata(AvesEntry entry) async {
try { try {
// return map with all data available from the `ExifInterface` library // return map with all data available from the `ExifInterface` library
final result = await platform.invokeMethod('getExifInterfaceMetadata', <String, dynamic>{ final result = await platform.invokeMethod('getExifInterfaceMetadata', <String, dynamic>{
@ -68,7 +68,7 @@ class AndroidDebugService {
return {}; return {};
} }
static Future<Map> getMediaMetadataRetrieverMetadata(ImageEntry entry) async { static Future<Map> getMediaMetadataRetrieverMetadata(AvesEntry entry) async {
try { try {
// return map with all data available from `MediaMetadataRetriever` // return map with all data available from `MediaMetadataRetriever`
final result = await platform.invokeMethod('getMediaMetadataRetrieverMetadata', <String, dynamic>{ final result = await platform.invokeMethod('getMediaMetadataRetrieverMetadata', <String, dynamic>{
@ -81,7 +81,7 @@ class AndroidDebugService {
return {}; return {};
} }
static Future<Map> getMetadataExtractorSummary(ImageEntry entry) async { static Future<Map> getMetadataExtractorSummary(AvesEntry entry) async {
try { try {
// return map with the mime type and tag count for each directory found by `metadata-extractor` // return map with the mime type and tag count for each directory found by `metadata-extractor`
final result = await platform.invokeMethod('getMetadataExtractorSummary', <String, dynamic>{ final result = await platform.invokeMethod('getMetadataExtractorSummary', <String, dynamic>{
@ -96,7 +96,7 @@ class AndroidDebugService {
return {}; return {};
} }
static Future<Map> getTiffStructure(ImageEntry entry) async { static Future<Map> getTiffStructure(AvesEntry entry) async {
if (entry.mimeType != MimeTypes.tiff) return {}; if (entry.mimeType != MimeTypes.tiff) return {};
try { try {

View file

@ -9,14 +9,14 @@ class AndroidFileService {
static const platform = MethodChannel('deckers.thibault/aves/storage'); static const platform = MethodChannel('deckers.thibault/aves/storage');
static final StreamsChannel storageAccessChannel = StreamsChannel('deckers.thibault/aves/storageaccessstream'); static final StreamsChannel storageAccessChannel = StreamsChannel('deckers.thibault/aves/storageaccessstream');
static Future<List<Map>> getStorageVolumes() async { static Future<Set<StorageVolume>> getStorageVolumes() async {
try { try {
final result = await platform.invokeMethod('getStorageVolumes'); 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) { } on PlatformException catch (e) {
debugPrint('getStorageVolumes failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); debugPrint('getStorageVolumes failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
} }
return []; return {};
} }
static Future<int> getFreeSpace(StorageVolume volume) async { static Future<int> getFreeSpace(StorageVolume volume) async {

View file

@ -1,7 +1,7 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.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:aves/services/image_file_service.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -26,18 +26,18 @@ class AppShortcutService {
return false; 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; Uint8List iconBytes;
if (iconEntry != null) { if (entry != null) {
final size = iconEntry.isVideo ? 0.0 : 256.0; final size = entry.isVideo ? 0.0 : 256.0;
iconBytes = await ImageFileService.getThumbnail( iconBytes = await ImageFileService.getThumbnail(
iconEntry.uri, uri: entry.uri,
iconEntry.mimeType, mimeType: entry.mimeType,
iconEntry.dateModifiedSecs, pageId: entry.pageId,
iconEntry.rotationDegrees, rotationDegrees: entry.rotationDegrees,
iconEntry.isFlipped, isFlipped: entry.isFlipped,
size, dateModifiedSecs: entry.dateModifiedSecs,
size, extent: size,
); );
} }
try { try {

View file

@ -3,12 +3,12 @@ import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'dart:typed_data'; 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/ref/mime_types.dart';
import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/service_policy.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/services.dart';
import 'package:flutter/widgets.dart';
import 'package:streams_channel/streams_channel.dart'; import 'package:streams_channel/streams_channel.dart';
class ImageFileService { class ImageFileService {
@ -18,10 +18,11 @@ class ImageFileService {
static final StreamsChannel opChannel = StreamsChannel('deckers.thibault/aves/imageopstream'); static final StreamsChannel opChannel = StreamsChannel('deckers.thibault/aves/imageopstream');
static const double thumbnailDefaultSize = 64.0; static const double thumbnailDefaultSize = 64.0;
static Map<String, dynamic> _toPlatformEntryMap(ImageEntry entry) { static Map<String, dynamic> _toPlatformEntryMap(AvesEntry entry) {
return { return {
'uri': entry.uri, 'uri': entry.uri,
'path': entry.path, 'path': entry.path,
'pageId': entry.pageId,
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,
'width': entry.width, 'width': entry.width,
'height': entry.height, 'height': entry.height,
@ -32,13 +33,13 @@ class ImageFileService {
} }
// knownEntries: map of contentId -> dateModifiedSecs // knownEntries: map of contentId -> dateModifiedSecs
static Stream<ImageEntry> getImageEntries(Map<int, int> knownEntries) { static Stream<AvesEntry> getEntries(Map<int, int> knownEntries) {
try { try {
return mediaStoreChannel.receiveBroadcastStream(<String, dynamic>{ return mediaStoreChannel.receiveBroadcastStream(<String, dynamic>{
'knownEntries': knownEntries, 'knownEntries': knownEntries,
}).map((event) => ImageEntry.fromMap(event)); }).map((event) => AvesEntry.fromMap(event));
} on PlatformException catch (e) { } 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); return Stream.error(e);
} }
} }
@ -55,26 +56,40 @@ class ImageFileService {
return []; return [];
} }
static Future<ImageEntry> getImageEntry(String uri, String mimeType) async { static Future<AvesEntry> getEntry(String uri, String mimeType) async {
debugPrint('getImageEntry for uri=$uri, mimeType=$mimeType');
try { try {
final result = await platform.invokeMethod('getImageEntry', <String, dynamic>{ final result = await platform.invokeMethod('getEntry', <String, dynamic>{
'uri': uri, 'uri': uri,
'mimeType': mimeType, 'mimeType': mimeType,
}) as Map; }) as Map;
return ImageEntry.fromMap(result); return AvesEntry.fromMap(result);
} on PlatformException catch (e) { } 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; 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( static Future<Uint8List> getImage(
String uri, String uri,
String mimeType, String mimeType,
int rotationDegrees, int rotationDegrees,
bool isFlipped, { bool isFlipped, {
int page = 0, int pageId,
int expectedContentLength, int expectedContentLength,
BytesReceivedCallback onBytesReceived, BytesReceivedCallback onBytesReceived,
}) { }) {
@ -87,7 +102,7 @@ class ImageFileService {
'mimeType': mimeType, 'mimeType': mimeType,
'rotationDegrees': rotationDegrees ?? 0, 'rotationDegrees': rotationDegrees ?? 0,
'isFlipped': isFlipped ?? false, 'isFlipped': isFlipped ?? false,
'page': page ?? 0, 'pageId': pageId,
}).listen( }).listen(
(data) { (data) {
final chunk = data as Uint8List; final chunk = data as Uint8List;
@ -125,7 +140,7 @@ class ImageFileService {
int sampleSize, int sampleSize,
Rectangle<int> regionRect, Rectangle<int> regionRect,
Size imageSize, { Size imageSize, {
int page = 0, int pageId,
Object taskKey, Object taskKey,
int priority, int priority,
}) { }) {
@ -135,7 +150,7 @@ class ImageFileService {
final result = await platform.invokeMethod('getRegion', <String, dynamic>{ final result = await platform.invokeMethod('getRegion', <String, dynamic>{
'uri': uri, 'uri': uri,
'mimeType': mimeType, 'mimeType': mimeType,
'page': page, 'pageId': pageId,
'sampleSize': sampleSize, 'sampleSize': sampleSize,
'regionX': regionRect.left, 'regionX': regionRect.left,
'regionY': regionRect.top, 'regionY': regionRect.top,
@ -155,15 +170,14 @@ class ImageFileService {
); );
} }
static Future<Uint8List> getThumbnail( static Future<Uint8List> getThumbnail({
String uri, @required String uri,
String mimeType, @required String mimeType,
int dateModifiedSecs, @required int rotationDegrees,
int rotationDegrees, @required int pageId,
bool isFlipped, @required bool isFlipped,
double width, @required int dateModifiedSecs,
double height, { @required double extent,
int page,
Object taskKey, Object taskKey,
int priority, int priority,
}) { }) {
@ -179,9 +193,9 @@ class ImageFileService {
'dateModifiedSecs': dateModifiedSecs, 'dateModifiedSecs': dateModifiedSecs,
'rotationDegrees': rotationDegrees, 'rotationDegrees': rotationDegrees,
'isFlipped': isFlipped, 'isFlipped': isFlipped,
'widthDip': width, 'widthDip': extent,
'heightDip': height, 'heightDip': extent,
'page': page, 'pageId': pageId,
'defaultSizeDip': thumbnailDefaultSize, 'defaultSizeDip': thumbnailDefaultSize,
}); });
return result as Uint8List; return result as Uint8List;
@ -191,7 +205,7 @@ class ImageFileService {
return null; return null;
}, },
// debugLabel: 'getThumbnail width=$width, height=$height entry=${entry.filenameWithoutExtension}', // 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, key: taskKey,
); );
} }
@ -210,7 +224,7 @@ class ImageFileService {
static Future<T> resumeLoading<T>(Object taskKey) => servicePolicy.resume<T>(taskKey); 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 { try {
return opChannel.receiveBroadcastStream(<String, dynamic>{ return opChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'delete', '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 { try {
return opChannel.receiveBroadcastStream(<String, dynamic>{ return opChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'move', '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 { try {
// return map with: 'contentId' 'path' 'title' 'uri' (all optional) // return map with: 'contentId' 'path' 'title' 'uri' (all optional)
final result = await platform.invokeMethod('rename', <String, dynamic>{ final result = await platform.invokeMethod('rename', <String, dynamic>{
@ -250,7 +286,7 @@ class ImageFileService {
return {}; return {};
} }
static Future<Map> rotate(ImageEntry entry, {@required bool clockwise}) async { static Future<Map> rotate(AvesEntry entry, {@required bool clockwise}) async {
try { try {
// return map with: 'rotationDegrees' 'isFlipped' // return map with: 'rotationDegrees' 'isFlipped'
final result = await platform.invokeMethod('rotate', <String, dynamic>{ final result = await platform.invokeMethod('rotate', <String, dynamic>{
@ -264,7 +300,7 @@ class ImageFileService {
return {}; return {};
} }
static Future<Map> flip(ImageEntry entry) async { static Future<Map> flip(AvesEntry entry) async {
try { try {
// return map with: 'rotationDegrees' 'isFlipped' // return map with: 'rotationDegrees' 'isFlipped'
final result = await platform.invokeMethod('flip', <String, dynamic>{ 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` // cf flutter/foundation `consolidateHttpClientResponseBytes`
typedef BytesReceivedCallback = void Function(int cumulative, int total); typedef BytesReceivedCallback = void Function(int cumulative, int total);

View 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}';
}

View file

@ -1,7 +1,7 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/metadata.dart';
import 'package:aves/model/multipage.dart'; import 'package:aves/model/multipage.dart';
import 'package:aves/model/panorama.dart'; import 'package:aves/model/panorama.dart';
import 'package:aves/services/service_policy.dart'; import 'package:aves/services/service_policy.dart';
@ -12,7 +12,7 @@ class MetadataService {
static const platform = MethodChannel('deckers.thibault/aves/metadata'); 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) // 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; if (entry.isSvg) return null;
try { try {
@ -28,7 +28,7 @@ class MetadataService {
return {}; 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; if (entry.isSvg) return null;
Future<CatalogMetadata> call() async { Future<CatalogMetadata> call() async {
@ -65,7 +65,7 @@ class MetadataService {
: call(); : call();
} }
static Future<OverlayMetadata> getOverlayMetadata(ImageEntry entry) async { static Future<OverlayMetadata> getOverlayMetadata(AvesEntry entry) async {
if (entry.isSvg) return null; if (entry.isSvg) return null;
try { try {
@ -82,20 +82,21 @@ class MetadataService {
return null; return null;
} }
static Future<MultiPageInfo> getMultiPageInfo(ImageEntry entry) async { static Future<MultiPageInfo> getMultiPageInfo(AvesEntry entry) async {
try { try {
final result = await platform.invokeMethod('getMultiPageInfo', <String, dynamic>{ final result = await platform.invokeMethod('getMultiPageInfo', <String, dynamic>{
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,
'uri': entry.uri, 'uri': entry.uri,
}) as Map; });
return MultiPageInfo.fromMap(result); final pageMaps = (result as List).cast<Map>();
return MultiPageInfo.fromPageMaps(pageMaps);
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getMultiPageInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}'); debugPrint('getMultiPageInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}');
} }
return null; return null;
} }
static Future<PanoramaInfo> getPanoramaInfo(ImageEntry entry) async { static Future<PanoramaInfo> getPanoramaInfo(AvesEntry entry) async {
try { try {
// return map with values for: // return map with values for:
// 'croppedAreaLeft' (int), 'croppedAreaTop' (int), 'croppedAreaWidth' (int), 'croppedAreaHeight' (int), // 'croppedAreaLeft' (int), 'croppedAreaTop' (int), 'croppedAreaWidth' (int), 'croppedAreaHeight' (int),
@ -112,6 +113,19 @@ class MetadataService {
return null; 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 { static Future<List<Uint8List>> getEmbeddedPictures(String uri) async {
try { try {
final result = await platform.invokeMethod('getEmbeddedPictures', <String, dynamic>{ final result = await platform.invokeMethod('getEmbeddedPictures', <String, dynamic>{
@ -124,7 +138,7 @@ class MetadataService {
return []; return [];
} }
static Future<List<Uint8List>> getExifThumbnails(ImageEntry entry) async { static Future<List<Uint8List>> getExifThumbnails(AvesEntry entry) async {
try { try {
final result = await platform.invokeMethod('getExifThumbnails', <String, dynamic>{ final result = await platform.invokeMethod('getExifThumbnails', <String, dynamic>{
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,
@ -138,7 +152,7 @@ class MetadataService {
return []; return [];
} }
static Future<Map> extractXmpDataProp(ImageEntry entry, String propPath, String propMimeType) async { static Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async {
try { try {
final result = await platform.invokeMethod('extractXmpDataProp', <String, dynamic>{ final result = await platform.invokeMethod('extractXmpDataProp', <String, dynamic>{
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,

View file

@ -1,6 +1,6 @@
import 'dart:convert'; 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/services/image_file_service.dart';
import 'package:aves/utils/string_utils.dart'; import 'package:aves/utils/string_utils.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -15,9 +15,9 @@ class SvgMetadataService {
static const _textElements = ['title', 'desc']; static const _textElements = ['title', 'desc'];
static const _metadataElement = 'metadata'; static const _metadataElement = 'metadata';
static Future<Size> getSize(ImageEntry entry) async { static Future<Size> getSize(AvesEntry entry) async {
try { 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 document = XmlDocument.parse(utf8.decode(data));
final root = document.rootElement; final root = document.rootElement;
@ -48,7 +48,7 @@ class SvgMetadataService {
return null; 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) { String formatKey(String key) {
switch (key) { switch (key) {
case 'desc': case 'desc':
@ -59,7 +59,7 @@ class SvgMetadataService {
} }
try { 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 document = XmlDocument.parse(utf8.decode(data));
final root = document.rootElement; final root = document.rootElement;

View file

@ -48,4 +48,11 @@ class Durations {
static const doubleBackTimerDelay = Duration(milliseconds: 1000); static const doubleBackTimerDelay = Duration(milliseconds: 1000);
static const softKeyboardDisplayDelay = Duration(milliseconds: 300); static const softKeyboardDisplayDelay = Duration(milliseconds: 300);
static const searchDebounceDelay = Duration(milliseconds: 250); static const searchDebounceDelay = Duration(milliseconds: 250);
// 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);
} }

View file

@ -31,6 +31,7 @@ class AIcons {
static const IconData createAlbum = Icons.add_circle_outline; static const IconData createAlbum = Icons.add_circle_outline;
static const IconData debug = Icons.whatshot_outlined; static const IconData debug = Icons.whatshot_outlined;
static const IconData delete = Icons.delete_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 flip = Icons.flip_outlined;
static const IconData favourite = Icons.favorite_border; static const IconData favourite = Icons.favorite_border;
static const IconData favouriteActive = Icons.favorite; static const IconData favouriteActive = Icons.favorite;
@ -38,7 +39,7 @@ class AIcons {
static const IconData group = Icons.group_work_outlined; static const IconData group = Icons.group_work_outlined;
static const IconData info = Icons.info_outlined; static const IconData info = Icons.info_outlined;
static const IconData layers = Icons.layers_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 pin = Icons.push_pin_outlined;
static const IconData print = Icons.print_outlined; static const IconData print = Icons.print_outlined;
static const IconData refresh = Icons.refresh_outlined; static const IconData refresh = Icons.refresh_outlined;

View file

@ -1,6 +1,7 @@
import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/android_file_service.dart'; import 'package:aves/services/android_file_service.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
final AndroidFileUtils androidFileUtils = AndroidFileUtils._private(); final AndroidFileUtils androidFileUtils = AndroidFileUtils._private();
@ -8,14 +9,17 @@ final AndroidFileUtils androidFileUtils = AndroidFileUtils._private();
class AndroidFileUtils { class AndroidFileUtils {
String primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath; String primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath;
Set<StorageVolume> storageVolumes = {}; Set<StorageVolume> storageVolumes = {};
Map appNameMap = {}; Set<Package> _packages = {};
List<String> _potentialAppDirs = [];
AChangeNotifier appNameChangeNotifier = AChangeNotifier(); AChangeNotifier appNameChangeNotifier = AChangeNotifier();
Iterable<Package> get _launcherPackages => _packages.where((package) => package.categoryLauncher);
AndroidFileUtils._private(); AndroidFileUtils._private();
Future<void> init() async { 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' // path_provider getExternalStorageDirectory() gives '/storage/emulated/0/Android/data/deckers.thibault.aves/files'
primaryStorage = storageVolumes.firstWhere((volume) => volume.isPrimary).path; primaryStorage = storageVolumes.firstWhere((volume) => volume.isPrimary).path;
dcimPath = join(primaryStorage, 'DCIM'); dcimPath = join(primaryStorage, 'DCIM');
@ -25,8 +29,8 @@ class AndroidFileUtils {
} }
Future<void> initAppNames() async { Future<void> initAppNames() async {
appNameMap = await AndroidAppService.getAppNames() _packages = await AndroidAppService.getPackages();
..addAll({'KakaoTalkDownload': 'com.kakao.talk'}); _potentialAppDirs = _launcherPackages.expand((package) => package.potentialDirs).toList();
appNameChangeNotifier.notifyListeners(); appNameChangeNotifier.notifyListeners();
} }
@ -42,27 +46,67 @@ class AndroidFileUtils {
bool isOnRemovableStorage(String path) => getStorageVolume(path)?.isRemovable ?? false; bool isOnRemovableStorage(String path) => getStorageVolume(path)?.isRemovable ?? false;
AlbumType getAlbumType(String albumDirectory) { AlbumType getAlbumType(String albumPath) {
if (albumDirectory != null) { if (albumPath != null) {
if (isCameraPath(albumDirectory)) return AlbumType.camera; if (isCameraPath(albumPath)) return AlbumType.camera;
if (isDownloadPath(albumDirectory)) return AlbumType.download; if (isDownloadPath(albumPath)) return AlbumType.download;
if (isScreenRecordingsPath(albumDirectory)) return AlbumType.screenRecordings; if (isScreenRecordingsPath(albumPath)) return AlbumType.screenRecordings;
if (isScreenshotsPath(albumDirectory)) return AlbumType.screenshots; if (isScreenshotsPath(albumPath)) return AlbumType.screenshots;
final parts = albumDirectory.split(separator); final dir = albumPath.split(separator).last;
if (albumDirectory.startsWith(primaryStorage) && appNameMap.keys.contains(parts.last)) return AlbumType.app; if (albumPath.startsWith(primaryStorage) && _potentialAppDirs.contains(dir)) return AlbumType.app;
} }
return AlbumType.regular; return AlbumType.regular;
} }
String getAlbumAppPackageName(String albumDirectory) { String getAlbumAppPackageName(String albumPath) {
final parts = albumDirectory.split(separator); if (albumPath == null) return null;
return appNameMap[parts.last]; 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 } 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 { class StorageVolume {
final String description, path, state; final String description, path, state;
final bool isEmulated, isPrimary, isRemovable; final bool isEmulated, isPrimary, isRemovable;

View file

@ -29,14 +29,14 @@ class Constants {
Dependency( Dependency(
name: 'AndroidX Core-KTX', name: 'AndroidX Core-KTX',
license: 'Apache 2.0', license: 'Apache 2.0',
licenseUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/LICENSE.txt', licenseUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt',
sourceUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/core/core-ktx', sourceUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/core/core-ktx',
), ),
Dependency( Dependency(
name: 'AndroidX Exifinterface', name: 'AndroidX Exifinterface',
license: 'Apache 2.0', license: 'Apache 2.0',
licenseUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/LICENSE.txt', licenseUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt',
sourceUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/exifinterface/exifinterface', sourceUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/exifinterface/exifinterface',
), ),
Dependency( Dependency(
name: 'Android-TiffBitmapFactory', name: 'Android-TiffBitmapFactory',
@ -83,18 +83,18 @@ class Constants {
licenseUrl: 'https://github.com/dart-lang/collection/blob/master/LICENSE', licenseUrl: 'https://github.com/dart-lang/collection/blob/master/LICENSE',
sourceUrl: 'https://github.com/dart-lang/collection', 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( Dependency(
name: 'Decorated Icon', name: 'Decorated Icon',
license: 'MIT', license: 'MIT',
licenseUrl: 'https://github.com/benPesso/flutter_decorated_icon/blob/master/LICENSE', licenseUrl: 'https://github.com/benPesso/flutter_decorated_icon/blob/master/LICENSE',
sourceUrl: 'https://github.com/benPesso/flutter_decorated_icon', 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( Dependency(
name: 'Event Bus', name: 'Event Bus',
license: 'MIT', license: 'MIT',

View file

@ -1,7 +1,7 @@
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/collection/thumbnail_collection.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/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/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/drawer/app_drawer.dart'; import 'package:aves/widgets/drawer/app_drawer.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';

View file

@ -2,11 +2,13 @@ import 'dart:async';
import 'package:aves/model/actions/collection_actions.dart'; import 'package:aves/model/actions/collection_actions.dart';
import 'package:aves/model/actions/entry_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_lens.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/image_file_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/feedback.dart';
import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
import 'package:aves/widgets/common/action_mixins/size_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; CollectionSource get source => collection.source;
Set<ImageEntry> get selection => collection.selection; Set<AvesEntry> get selection => collection.selection;
EntrySetActionDelegate({ EntrySetActionDelegate({
@required this.collection, @required this.collection,
@ -46,10 +48,10 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
void onCollectionActionSelected(BuildContext context, CollectionAction action) { void onCollectionActionSelected(BuildContext context, CollectionAction action) {
switch (action) { switch (action) {
case CollectionAction.copy: case CollectionAction.copy:
_moveSelection(context, copy: true); _moveSelection(context, moveType: MoveType.copy);
break; break;
case CollectionAction.move: case CollectionAction.move:
_moveSelection(context, copy: false); _moveSelection(context, moveType: MoveType.move);
break; break;
case CollectionAction.refreshMetadata: case CollectionAction.refreshMetadata:
source.refreshMetadata(selection); 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( final destinationAlbum = await Navigator.push(
context, context,
MaterialPageRoute<String>( MaterialPageRoute<String>(
settings: RouteSettings(name: AlbumPickPage.routeName), 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; if (destinationAlbum == null || destinationAlbum.isEmpty) return;
@ -74,16 +76,17 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
if (!await checkStoragePermission(context, selection)) return; 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>( showOpReport<MoveOpEvent>(
context: context, context: context,
selection: selection,
opStream: ImageFileService.move(selection, copy: copy, destinationAlbum: destinationAlbum), opStream: ImageFileService.move(selection, copy: copy, destinationAlbum: destinationAlbum),
itemCount: selectionCount,
onDone: (processed) async { onDone: (processed) async {
final movedOps = processed.where((e) => e.success); final movedOps = processed.where((e) => e.success);
final movedCount = movedOps.length; final movedCount = movedOps.length;
final selectionCount = selection.length;
if (movedCount < selectionCount) { if (movedCount < selectionCount) {
final count = selectionCount - movedCount; final count = selectionCount - movedCount;
showFeedback(context, 'Failed to ${copy ? 'copy' : 'move'} ${Intl.plural(count, one: '$count item', other: '$count items')}'); 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; if (!await checkStoragePermission(context, selection)) return;
final selectionCount = selection.length;
showOpReport<ImageOpEvent>( showOpReport<ImageOpEvent>(
context: context, context: context,
selection: selection,
opStream: ImageFileService.delete(selection), opStream: ImageFileService.delete(selection),
itemCount: selectionCount,
onDone: (processed) { onDone: (processed) {
final deletedUris = processed.where((e) => e.success).map((e) => e.uri).toList(); final deletedUris = processed.where((e) => e.success).map((e) => e.uri).toList();
final deletedCount = deletedUris.length; final deletedCount = deletedUris.length;
final selectionCount = selection.length;
if (deletedCount < selectionCount) { if (deletedCount < selectionCount) {
final count = selectionCount - deletedCount; final count = selectionCount - deletedCount;
showFeedback(context, 'Failed to delete ${Intl.plural(count, one: '$count item', other: '$count items')}'); showFeedback(context, 'Failed to delete ${Intl.plural(count, one: '$count item', other: '$count items')}');

View file

@ -25,7 +25,7 @@ class FilterBar extends StatefulWidget implements PreferredSizeWidget {
} }
class _FilterBarState extends State<FilterBar> { class _FilterBarState extends State<FilterBar> {
final GlobalKey<AnimatedListState> _animatedListKey = GlobalKey(); final GlobalKey<AnimatedListState> _animatedListKey = GlobalKey(debugLabel: 'filter-bar-animated-list');
CollectionFilter _userRemovedFilter; CollectionFilter _userRemovedFilter;
@override @override

View file

@ -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/collection_lens.dart';
import 'package:aves/model/source/section_keys.dart'; import 'package:aves/model/source/section_keys.dart';
import 'package:aves/widgets/collection/grid/headers/any.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/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider<ImageEntry> { class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider<AvesEntry> {
final CollectionLens collection; final CollectionLens collection;
const SectionedEntryListLayoutProvider({ const SectionedEntryListLayoutProvider({
@ -14,7 +14,7 @@ class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider<Image
@required double scrollableWidth, @required double scrollableWidth,
@required int columnCount, @required int columnCount,
@required double tileExtent, @required double tileExtent,
@required Widget Function(ImageEntry entry) tileBuilder, @required Widget Function(AvesEntry entry) tileBuilder,
@required Widget child, @required Widget child,
}) : super( }) : super(
scrollableWidth: scrollableWidth, scrollableWidth: scrollableWidth,
@ -28,7 +28,7 @@ class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider<Image
bool get showHeaders => collection.showHeaders; bool get showHeaders => collection.showHeaders;
@override @override
Map<SectionKey, List<ImageEntry>> get sections => collection.sections; Map<SectionKey, List<AvesEntry>> get sections => collection.sections;
@override @override
double getHeaderExtent(BuildContext context, SectionKey sectionKey) { double getHeaderExtent(BuildContext context, SectionKey sectionKey) {

View file

@ -1,9 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'dart:math'; 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/model/source/collection_lens.dart';
import 'package:aves/utils/math_utils.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:aves/widgets/common/grid/section_layout.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
@ -38,7 +39,7 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
CollectionLens get collection => widget.collection; CollectionLens get collection => widget.collection;
List<ImageEntry> get entries => collection.sortedEntries; List<AvesEntry> get entries => collection.sortedEntries;
ScrollController get scrollController => widget.scrollController; ScrollController get scrollController => widget.scrollController;
@ -62,7 +63,7 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
_lastToIndex = _fromIndex; _lastToIndex = _fromIndex;
_scrollableInsets = EdgeInsets.only( _scrollableInsets = EdgeInsets.only(
top: appBarHeight, top: appBarHeight,
bottom: context.read<MediaQueryData>().viewInsets.bottom, bottom: context.read<MediaQueryData>().effectiveBottomPadding,
); );
_scrollSpeedFactor = 0; _scrollSpeedFactor = 0;
_pressing = true; _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, // 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, // 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. // so we use custom layout computation instead to find the entry.
final offset = Offset(0, scrollController.offset - appBarHeight) + localPosition; final offset = Offset(0, scrollController.offset - appBarHeight) + localPosition;
final sectionedListLayout = context.read<SectionedListLayout<ImageEntry>>(); final sectionedListLayout = context.read<SectionedListLayout<AvesEntry>>();
return sectionedListLayout.getItemAt(offset); return sectionedListLayout.getItemAt(offset);
} }

View file

@ -1,5 +1,5 @@
import 'package:aves/main.dart'; 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/model/source/collection_lens.dart';
import 'package:aves/services/viewer_service.dart'; import 'package:aves/services/viewer_service.dart';
import 'package:aves/widgets/collection/thumbnail/decorated.dart'; import 'package:aves/widgets/collection/thumbnail/decorated.dart';
@ -10,7 +10,7 @@ import 'package:flutter/material.dart';
class InteractiveThumbnail extends StatelessWidget { class InteractiveThumbnail extends StatelessWidget {
final CollectionLens collection; final CollectionLens collection;
final ImageEntry entry; final AvesEntry entry;
final double tileExtent; final double tileExtent;
final ValueNotifier<bool> isScrollingNotifier; final ValueNotifier<bool> isScrollingNotifier;
@ -53,9 +53,15 @@ class InteractiveThumbnail extends StatelessWidget {
Navigator.push( Navigator.push(
context, context,
TransparentMaterialPageRoute( TransparentMaterialPageRoute(
settings: RouteSettings(name: MultiEntryViewerPage.routeName), settings: RouteSettings(name: EntryViewerPage.routeName),
pageBuilder: (c, a, sa) => MultiEntryViewerPage( pageBuilder: (c, a, sa) => EntryViewerPage(
collection: collection, collection: CollectionLens(
source: collection.source,
filters: collection.filters,
groupFactor: collection.groupFactor,
sortFactor: collection.sortFactor,
listenToSource: false,
),
initialEntry: entry, initialEntry: entry,
), ),
), ),

View file

@ -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/collection_lens.dart';
import 'package:aves/widgets/collection/thumbnail/overlay.dart'; import 'package:aves/widgets/collection/thumbnail/overlay.dart';
import 'package:aves/widgets/collection/thumbnail/raster.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'; import 'package:flutter/material.dart';
class DecoratedThumbnail extends StatelessWidget { class DecoratedThumbnail extends StatelessWidget {
final ImageEntry entry; final AvesEntry entry;
final double extent; final double extent;
final CollectionLens collection; final CollectionLens collection;
final ValueNotifier<bool> isScrollingNotifier; final ValueNotifier<bool> isScrollingNotifier;

View file

@ -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:aves/utils/mime_utils.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class ErrorThumbnail extends StatelessWidget { class ErrorThumbnail extends StatelessWidget {
final ImageEntry entry; final AvesEntry entry;
final double extent; final double extent;
final String tooltip; final String tooltip;

View file

@ -1,7 +1,7 @@
import 'dart:math'; import 'dart:math';
import 'package:aves/model/highlight.dart'; 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/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
@ -14,7 +14,7 @@ import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
class ThumbnailEntryOverlay extends StatelessWidget { class ThumbnailEntryOverlay extends StatelessWidget {
final ImageEntry entry; final AvesEntry entry;
final double extent; final double extent;
const ThumbnailEntryOverlay({ const ThumbnailEntryOverlay({
@ -61,7 +61,7 @@ class ThumbnailEntryOverlay extends StatelessWidget {
} }
class ThumbnailSelectionOverlay extends StatelessWidget { class ThumbnailSelectionOverlay extends StatelessWidget {
final ImageEntry entry; final AvesEntry entry;
final double extent; final double extent;
const ThumbnailSelectionOverlay({ const ThumbnailSelectionOverlay({
@ -121,7 +121,7 @@ class ThumbnailSelectionOverlay extends StatelessWidget {
} }
class ThumbnailHighlightOverlay extends StatefulWidget { class ThumbnailHighlightOverlay extends StatefulWidget {
final ImageEntry entry; final AvesEntry entry;
final double extent; final double extent;
const ThumbnailHighlightOverlay({ const ThumbnailHighlightOverlay({
@ -137,7 +137,7 @@ class ThumbnailHighlightOverlay extends StatefulWidget {
class _ThumbnailHighlightOverlayState extends State<ThumbnailHighlightOverlay> { class _ThumbnailHighlightOverlayState extends State<ThumbnailHighlightOverlay> {
final ValueNotifier<bool> _highlightedNotifier = ValueNotifier(false); final ValueNotifier<bool> _highlightedNotifier = ValueNotifier(false);
ImageEntry get entry => widget.entry; AvesEntry get entry => widget.entry;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View file

@ -1,17 +1,14 @@
import 'dart:math';
import 'package:aves/image_providers/thumbnail_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:aves/model/image_entry.dart'; import 'package:aves/model/entry_images.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/collection/thumbnail/error.dart'; import 'package:aves/widgets/collection/thumbnail/error.dart';
import 'package:aves/widgets/common/fx/transition_image.dart'; import 'package:aves/widgets/common/fx/transition_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class RasterImageThumbnail extends StatefulWidget { class RasterImageThumbnail extends StatefulWidget {
final ImageEntry entry; final AvesEntry entry;
final double extent; final double extent;
final int page;
final ValueNotifier<bool> isScrollingNotifier; final ValueNotifier<bool> isScrollingNotifier;
final Object heroTag; final Object heroTag;
@ -19,7 +16,6 @@ class RasterImageThumbnail extends StatefulWidget {
Key key, Key key,
@required this.entry, @required this.entry,
@required this.extent, @required this.extent,
this.page = 0,
this.isScrollingNotifier, this.isScrollingNotifier,
this.heroTag, this.heroTag,
}) : super(key: key); }) : super(key: key);
@ -31,19 +27,12 @@ class RasterImageThumbnail extends StatefulWidget {
class _RasterImageThumbnailState extends State<RasterImageThumbnail> { class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
ThumbnailProvider _fastThumbnailProvider, _sizedThumbnailProvider; ThumbnailProvider _fastThumbnailProvider, _sizedThumbnailProvider;
ImageEntry get entry => widget.entry; AvesEntry get entry => widget.entry;
int get page => widget.page;
double get extent => widget.extent; double get extent => widget.extent;
Object get heroTag => widget.heroTag; 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 @override
void initState() { void initState() {
super.initState(); super.initState();
@ -78,14 +67,8 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
void _initProvider() { void _initProvider() {
if (!entry.canDecode) return; if (!entry.canDecode) return;
_fastThumbnailProvider = ThumbnailProvider( _fastThumbnailProvider = entry.getThumbnail();
ThumbnailProviderKey.fromEntry(entry, page: page), _sizedThumbnailProvider = entry.getThumbnail(extent: extent);
);
if (!entry.isVideo) {
_sizedThumbnailProvider = ThumbnailProvider(
ThumbnailProviderKey.fromEntry(entry, page: page, extent: requestExtent),
);
}
} }
void _pauseProvider() { void _pauseProvider() {
@ -148,22 +131,8 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
: Hero( : Hero(
tag: heroTag, tag: heroTag,
flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) { 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( return TransitionImage(
image: heroImageProvider, image: entry.getBestThumbnail(extent),
animation: animation, animation: animation,
); );
}, },

View file

@ -1,5 +1,5 @@
import 'package:aves/image_providers/uri_picture_provider.dart'; 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/entry_background.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/fx/checkered_decoration.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'; import 'package:provider/provider.dart';
class VectorImageThumbnail extends StatelessWidget { class VectorImageThumbnail extends StatelessWidget {
final ImageEntry entry; final AvesEntry entry;
final double extent; final double extent;
final Object heroTag; final Object heroTag;
@ -29,10 +29,10 @@ class VectorImageThumbnail extends StatelessWidget {
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final availableSize = constraints.biggest; 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 offset = fitSize / 2 - availableSize / 2;
final child = DecoratedBox( final child = CustomPaint(
decoration: CheckeredDecoration(checkSize: extent / 8, offset: offset), painter: CheckeredPainter(checkSize: extent / 8, offset: offset),
child: SvgPicture( child: SvgPicture(
UriPicture( UriPicture(
uri: entry.uri, uri: entry.uri,

View file

@ -4,7 +4,7 @@ import 'package:aves/main.dart';
import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/highlight.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_lens.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/ref/mime_types.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/selector.dart';
import 'package:aves/widgets/collection/grid/thumbnail.dart'; import 'package:aves/widgets/collection/grid/thumbnail.dart';
import 'package:aves/widgets/collection/thumbnail/decorated.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/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/section_layout.dart';
import 'package:aves/widgets/common/grid/sliver.dart'; import 'package:aves/widgets/common/grid/sliver.dart';
import 'package:aves/widgets/common/identity/scroll_thumb.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/providers/highlight_info_provider.dart';
import 'package:aves/widgets/common/scaling.dart'; import 'package:aves/widgets/common/scaling.dart';
import 'package:aves/widgets/common/tile_extent_manager.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/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.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> _appBarHeightNotifier = ValueNotifier(0);
final ValueNotifier<double> _tileExtentNotifier = ValueNotifier(0); final ValueNotifier<double> _tileExtentNotifier = ValueNotifier(0);
final ValueNotifier<bool> _isScrollingNotifier = ValueNotifier(false); final ValueNotifier<bool> _isScrollingNotifier = ValueNotifier(false);
final GlobalKey _scrollableKey = GlobalKey(); final GlobalKey _scrollableKey = GlobalKey(debugLabel: 'thumbnail-collection-scrollable');
static const columnCountDefault = 4; static const columnCountDefault = 4;
static const extentMin = 46.0; static const extentMin = 46.0;
@ -44,6 +46,7 @@ class ThumbnailCollection extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return HighlightInfoProvider( return HighlightInfoProvider(
child: SafeArea( child: SafeArea(
bottom: false,
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final viewportSize = constraints.biggest; final viewportSize = constraints.biggest;
@ -79,7 +82,7 @@ class ThumbnailCollection extends StatelessWidget {
), ),
); );
final scaler = GridScaleGestureDetector<ImageEntry>( final scaler = GridScaleGestureDetector<AvesEntry>(
tileExtentManager: tileExtentManager, tileExtentManager: tileExtentManager,
scrollableKey: _scrollableKey, scrollableKey: _scrollableKey,
appBarHeightNotifier: _appBarHeightNotifier, appBarHeightNotifier: _appBarHeightNotifier,
@ -103,7 +106,7 @@ class ThumbnailCollection extends StatelessWidget {
highlightable: false, highlightable: false,
), ),
getScaledItemTileRect: (context, entry) { getScaledItemTileRect: (context, entry) {
final sectionedListLayout = context.read<SectionedListLayout<ImageEntry>>(); final sectionedListLayout = context.read<SectionedListLayout<AvesEntry>>();
return sectionedListLayout.getTileRect(entry) ?? Rect.zero; return sectionedListLayout.getTileRect(entry) ?? Rect.zero;
}, },
onScaled: (entry) => Provider.of<HighlightInfo>(context, listen: false).add(entry), onScaled: (entry) => Provider.of<HighlightInfo>(context, listen: false).add(entry),
@ -192,10 +195,12 @@ class _CollectionScrollViewState extends State<CollectionScrollView> {
} }
void _registerWidget(CollectionScrollView widget) { void _registerWidget(CollectionScrollView widget) {
widget.collection.filterChangeNotifier.addListener(_onFilterChange);
widget.scrollController.addListener(_onScrollChange); widget.scrollController.addListener(_onScrollChange);
} }
void _unregisterWidget(CollectionScrollView widget) { void _unregisterWidget(CollectionScrollView widget) {
widget.collection.filterChangeNotifier.removeListener(_onFilterChange);
widget.scrollController.removeListener(_onScrollChange); widget.scrollController.removeListener(_onScrollChange);
} }
@ -220,15 +225,8 @@ class _CollectionScrollViewState extends State<CollectionScrollView> {
child: _buildEmptyCollectionPlaceholder(collection), child: _buildEmptyCollectionPlaceholder(collection),
hasScrollBody: false, hasScrollBody: false,
) )
: SectionedListSliver<ImageEntry>(), : SectionedListSliver<AvesEntry>(),
SliverToBoxAdapter( BottomPaddingSliver(),
child: Selector<MediaQueryData, double>(
selector: (context, mq) => mq.viewInsets.bottom,
builder: (context, mqViewInsetsBottom, child) {
return SizedBox(height: mqViewInsetsBottom);
},
),
),
], ],
); );
} }
@ -237,10 +235,10 @@ class _CollectionScrollViewState extends State<CollectionScrollView> {
return ValueListenableBuilder<double>( return ValueListenableBuilder<double>(
valueListenable: widget.appBarHeightNotifier, valueListenable: widget.appBarHeightNotifier,
builder: (context, appBarHeight, child) => Selector<MediaQueryData, double>( builder: (context, appBarHeight, child) => Selector<MediaQueryData, double>(
selector: (context, mq) => mq.viewInsets.bottom, selector: (context, mq) => mq.effectiveBottomPadding,
builder: (context, mqViewInsetsBottom, child) => DraggableScrollbar( builder: (context, mqPaddingBottom, child) => DraggableScrollbar(
heightScrollThumb: avesScrollThumbHeight,
backgroundColor: Colors.white, backgroundColor: Colors.white,
scrollThumbHeight: avesScrollThumbHeight,
scrollThumbBuilder: avesScrollThumbBuilder( scrollThumbBuilder: avesScrollThumbBuilder(
height: avesScrollThumbHeight, height: avesScrollThumbHeight,
backgroundColor: Colors.white, backgroundColor: Colors.white,
@ -249,7 +247,7 @@ class _CollectionScrollViewState extends State<CollectionScrollView> {
padding: EdgeInsets.only( padding: EdgeInsets.only(
// padding to keep scroll thumb between app bar above and nav bar below // padding to keep scroll thumb between app bar above and nav bar below
top: appBarHeight, top: appBarHeight,
bottom: mqViewInsetsBottom, bottom: mqPaddingBottom,
), ),
child: scrollView, child: scrollView,
), ),
@ -285,6 +283,8 @@ class _CollectionScrollViewState extends State<CollectionScrollView> {
); );
} }
void _onFilterChange() => widget.scrollController.jumpTo(0);
void _onScrollChange() { void _onScrollChange() {
widget.isScrollingNotifier.value = true; widget.isScrollingNotifier.value = true;
_stopScrollMonitoringTimer(); _stopScrollMonitoringTimer();

View file

@ -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:aves/theme/durations.dart';
import 'package:flushbar/flushbar.dart'; import 'package:flushbar/flushbar.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -27,19 +25,66 @@ mixin FeedbackMixin {
// report overlay for multiple operations // report overlay for multiple operations
OverlayEntry _opReportOverlayEntry; void showOpReport<T>({
void showOpReport<T extends ImageOpEvent>({
@required BuildContext context, @required BuildContext context,
@required Set<ImageEntry> selection,
@required Stream<T> opStream, @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` // do not handle completion inside `StreamBuilder`
// as it could be called multiple times // 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( opStream.listen(
processed.add, processed.add,
onError: (error) { onError: (error) {
@ -48,17 +93,34 @@ mixin FeedbackMixin {
}, },
onDone: onComplete, onDone: onComplete,
); );
}
_opReportOverlayEntry = OverlayEntry( @override
builder: (context) { void dispose() {
return AbsorbPointer( _animationController.dispose();
child: StreamBuilder<T>( super.dispose();
stream: opStream, }
builder: (context, snapshot) {
Widget child = SizedBox.shrink(); @override
if (!snapshot.hasError) { Widget build(BuildContext context) {
final percent = processed.length.toDouble() / selection.length; return AbsorbPointer(
child = CircularPercentIndicator( 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, percent: percent,
lineWidth: 16, lineWidth: 16,
radius: 160, radius: 160,
@ -67,22 +129,11 @@ mixin FeedbackMixin {
animation: true, animation: true,
center: Text(NumberFormat.percentPattern().format(percent)), center: Text(NumberFormat.percentPattern().format(percent)),
animateFromLastPercent: true, 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;
} }
} }

View file

@ -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/services/android_file_service.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
mixin PermissionAwareMixin { 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()); return checkStoragePermissionForAlbums(context, entries.where((e) => e.path != null).map((e) => e.directory).toSet());
} }

View file

@ -1,7 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:math'; 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/services/android_file_service.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/file_utils.dart'; import 'package:aves/utils/file_utils.dart';
@ -11,21 +12,30 @@ import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
mixin SizeAwareMixin { 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 destinationVolume = androidFileUtils.getStorageVolume(destinationAlbum);
final free = await AndroidFileService.getFreeSpace(destinationVolume); final free = await AndroidFileService.getFreeSpace(destinationVolume);
int needed; int needed;
int sumSize(sum, entry) => sum + entry.sizeBytes; int sumSize(sum, entry) => sum + entry.sizeBytes;
if (copy) { switch (moveType) {
needed = selection.fold(0, sumSize); case MoveType.copy:
} else { case MoveType.export:
// when moving, we only need space for the entries that are not already on the destination volume needed = selection.fold(0, sumSize);
final byVolume = groupBy<ImageEntry, StorageVolume>(selection, (entry) => androidFileUtils.getStorageVolume(entry.path)); break;
final otherVolumes = byVolume.keys.where((volume) => volume != destinationVolume); case MoveType.move:
final fromOtherVolumes = otherVolumes.fold<int>(0, (sum, volume) => sum + byVolume[volume].fold(0, sumSize)); // when moving, we only need space for the entries that are not already on the destination volume
// and we need at least as much space as the largest entry because individual entries are copied then deleted final byVolume = groupBy<AvesEntry, StorageVolume>(selection, (entry) => androidFileUtils.getStorageVolume(entry.path));
final largestSingle = selection.fold<int>(0, (largest, entry) => max(largest, entry.sizeBytes)); final otherVolumes = byVolume.keys.where((volume) => volume != destinationVolume);
needed = max(fromOtherVolumes, largestSingle); 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; final hasEnoughSpace = needed < free;

View 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,
),
),
);
}
}

View 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);
},
),
);
}
}

View file

@ -44,7 +44,7 @@ class LinkChip extends StatelessWidget {
SizedBox(width: 8), SizedBox(width: 8),
Builder( Builder(
builder: (context) => Icon( builder: (context) => Icon(
AIcons.openInNew, AIcons.openOutside,
size: DefaultTextStyle.of(context).style.fontSize, size: DefaultTextStyle.of(context).style.fontSize,
color: color, color: color,
), ),

View file

@ -1,9 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
extension ExtraContext on BuildContext {
String get currentRouteName => ModalRoute.of(this)?.settings?.name;
}
class DirectMaterialPageRoute<T> extends PageRouteBuilder<T> { class DirectMaterialPageRoute<T> extends PageRouteBuilder<T> {
DirectMaterialPageRoute({ DirectMaterialPageRoute({
RouteSettings settings, RouteSettings settings,

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