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
with:
channel: stable
flutter-version: '1.22.5'
flutter-version: '1.22.6'
- name: Clone the repository.
uses: actions/checkout@v2

View file

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

View file

@ -3,6 +3,22 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
## [v1.3.3] - 2021-01-31
### Added
- Viewer: support for multi-track HEIF
- Viewer: export image (including multipage TIFF/HEIF and images embedded in XMP)
- Info: show owner app (Android Q and up)
- listen to Media Store changes
### Changed
- upgraded Flutter to stable v1.22.6
- check connectivity before using features that need it
### Fixed
- checkerboard background performance
- deleting files that no longer exist but are still registered in the Media Store
- insets handling on Android 11
## [v1.3.2] - 2021-01-17
### Added
Collection: identify multipage TIFF & multitrack HEIC/HEIF

View file

@ -12,7 +12,7 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
## Features
- support raster images: JPEG, GIF, PNG, HEIC (from Android Pie), WEBP, TIFF, BMP, WBMP, ICO
- support raster images: JPEG, GIF, PNG, HEIC/HEIF (including multi-track, from Android Pie), WEBP, TIFF (including multi-page), BMP, WBMP, ICO
- support animated images: GIF, WEBP
- support raw images: ARW, CR2, DNG, NEF, NRW, ORF, PEF, RAF, RW2, SRW
- support vector images: SVG
@ -36,10 +36,10 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
| Model | Name | Android Version | API |
| ----------- | -------------------------- | --------------- | ---:|
| SM-G970N | Samsung Galaxy S10e | 10 (Android10) | 29 |
| SM-G981N | Samsung Galaxy S20 5G | 11 | 30 |
| SM-G970N | Samsung Galaxy S10e | 10 (Q) | 29 |
| SM-P580 | Samsung Galaxy Tab A 10.1 | 8.1.0 (Oreo) | 27 |
| SM-G930S | Samsung Galaxy S7 | 8.0.0 (Oreo) | 26 |
| E5823 | Sony Xperia Z5 Compact | 7.1.1 (Nougat) | 25 |
## Project Setup

View file

@ -98,15 +98,15 @@ repositories {
dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
implementation 'androidx.core:core-ktx:1.5.0-alpha05' // v1.5.0-alpha02+ for ShortcutManagerCompat.setDynamicShortcuts
implementation 'androidx.core:core-ktx:1.5.0-beta01' // v1.5.0-alpha02+ for ShortcutManagerCompat.setDynamicShortcuts
implementation 'androidx.exifinterface:exifinterface:1.3.2'
implementation 'com.commonsware.cwac:document:0.4.1'
implementation 'com.drewnoakes:metadata-extractor:2.15.0'
implementation 'com.github.deckerst:Android-TiffBitmapFactory:f87db4305d' // forked, built by JitPack
implementation 'com.github.bumptech.glide:glide:4.11.0'
implementation 'com.github.bumptech.glide:glide:4.12.0'
kapt 'androidx.annotation:annotation:1.1.0'
kapt 'com.github.bumptech.glide:compiler:4.11.0'
kapt 'com.github.bumptech.glide:compiler:4.12.0'
compileOnly rootProject.findProject(':streams_channel')
}

View file

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

View file

@ -16,24 +16,18 @@ import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.PermissionManager
import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
companion object {
private val LOG_TAG = LogUtils.createTag(MainActivity::class.java)
const val INTENT_CHANNEL = "deckers.thibault/aves/intent"
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer"
}
private val intentStreamHandler = IntentStreamHandler()
private lateinit var contentStreamHandler: ContentChangeStreamHandler
private lateinit var intentStreamHandler: IntentStreamHandler
private lateinit var intentDataMap: MutableMap<String, Any?>
override fun onCreate(savedInstanceState: Bundle?) {
Log.i(LOG_TAG, "onCreate intent=$intent")
super.onCreate(savedInstanceState)
intentDataMap = extractIntentData(intent)
val messenger = flutterEngine!!.dartExecutor.binaryMessenger
MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
@ -48,59 +42,34 @@ class MainActivity : FlutterActivity() {
StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(this, args) }
StreamsChannel(messenger, StorageAccessStreamHandler.CHANNEL).setStreamHandlerFactory { args -> StorageAccessStreamHandler(this, args) }
// Media Store change monitoring
contentStreamHandler = ContentChangeStreamHandler(this).apply {
EventChannel(messenger, ContentChangeStreamHandler.CHANNEL).setStreamHandler(this)
}
// intent handling
intentStreamHandler = IntentStreamHandler().apply {
EventChannel(messenger, IntentStreamHandler.CHANNEL).setStreamHandler(this)
}
intentDataMap = extractIntentData(intent)
MethodChannel(messenger, VIEWER_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"getIntentData" -> {
result.success(intentDataMap)
intentDataMap.clear()
}
"pick" -> {
val pickedUri = call.argument<String>("uri")
if (pickedUri != null) {
val intent = Intent().apply {
data = Uri.parse(pickedUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
setResult(RESULT_OK, intent)
} else {
setResult(RESULT_CANCELED)
}
finish()
}
"pick" -> pick(call)
}
}
EventChannel(messenger, INTENT_CHANNEL).setStreamHandler(intentStreamHandler)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
setupShortcuts()
}
}
@RequiresApi(Build.VERSION_CODES.N_MR1)
private fun setupShortcuts() {
// do not use 'route' as extra key, as the Flutter framework acts on it
val search = ShortcutInfoCompat.Builder(this, "search")
.setShortLabel(getString(R.string.search_shortcut_short_label))
.setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_search))
.setIntent(
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
.putExtra("page", "/search")
)
.build()
val videos = ShortcutInfoCompat.Builder(this, "videos")
.setShortLabel(getString(R.string.videos_shortcut_short_label))
.setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_movie))
.setIntent(
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
.putExtra("page", "/collection")
.putExtra("filters", arrayOf("{\"type\":\"mime\",\"mime\":\"video/*\"}"))
)
.build()
ShortcutManagerCompat.setDynamicShortcuts(this, listOf(videos, search))
override fun onDestroy() {
contentStreamHandler.dispose()
super.onDestroy()
}
override fun onNewIntent(intent: Intent) {
@ -109,6 +78,25 @@ class MainActivity : FlutterActivity() {
intentStreamHandler.notifyNewIntent(extractIntentData(intent))
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == PermissionManager.VOLUME_ACCESS_REQUEST_CODE) {
val treeUri = data?.data
if (resultCode != RESULT_OK || treeUri == null) {
PermissionManager.onPermissionResult(requestCode, null)
return
}
// save access permissions across reboots
val takeFlags = (data.flags
and (Intent.FLAG_GRANT_READ_URI_PERMISSION
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION))
contentResolver.takePersistableUriPermission(treeUri, takeFlags)
// resume pending action
PermissionManager.onPermissionResult(requestCode, treeUri)
}
}
private fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
when (intent?.action) {
Intent.ACTION_MAIN -> {
@ -138,22 +126,48 @@ class MainActivity : FlutterActivity() {
return HashMap()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == PermissionManager.VOLUME_ACCESS_REQUEST_CODE) {
val treeUri = data?.data
if (resultCode != RESULT_OK || treeUri == null) {
PermissionManager.onPermissionResult(requestCode, null)
return
private fun pick(call: MethodCall) {
val pickedUri = call.argument<String>("uri")
if (pickedUri != null) {
val intent = Intent().apply {
data = Uri.parse(pickedUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
setResult(RESULT_OK, intent)
} else {
setResult(RESULT_CANCELED)
}
finish()
}
// 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)
@RequiresApi(Build.VERSION_CODES.N_MR1)
private fun setupShortcuts() {
// do not use 'route' as extra key, as the Flutter framework acts on it
// resume pending action
PermissionManager.onPermissionResult(requestCode, treeUri)
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.load.DecodeFormat
import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils
import io.flutter.plugin.common.MethodCall
@ -28,8 +30,8 @@ import kotlin.math.roundToInt
class AppAdapterHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getAppIcon" -> GlobalScope.launch(Dispatchers.IO) { getAppIcon(call, Coresult(result)) }
"getAppNames" -> GlobalScope.launch(Dispatchers.IO) { getAppNames(Coresult(result)) }
"getPackages" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPackages) }
"getAppIcon" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getAppIcon) }
"edit" -> {
val title = call.argument<String>("title")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
@ -61,30 +63,24 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
}
}
private fun getAppNames(result: MethodChannel.Result) {
val nameMap = HashMap<String, String>()
val intent = Intent(Intent.ACTION_MAIN, null)
.addCategory(Intent.CATEGORY_LAUNCHER)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
private fun getPackages(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
val packages = HashMap<String, FieldMap>()
fun addPackageDetails(intent: Intent) {
// apps tend to use their name in English when creating folders
// so we get their names in English as well as the current locale
val englishConfig = Configuration().apply { setLocale(Locale.ENGLISH) }
val pm = context.packageManager
for (resolveInfo in pm.queryIntentActivities(intent, 0)) {
val ai = resolveInfo.activityInfo.applicationInfo
val isSystemPackage = ai.flags and ApplicationInfo.FLAG_SYSTEM != 0
if (!isSystemPackage) {
val packageName = ai.packageName
val currentLabel = pm.getApplicationLabel(ai).toString()
nameMap[currentLabel] = packageName
val labelRes = ai.labelRes
if (labelRes != 0) {
val appInfo = resolveInfo.activityInfo.applicationInfo
val packageName = appInfo.packageName
if (!packages.containsKey(packageName)) {
val currentLabel = pm.getApplicationLabel(appInfo).toString()
val englishLabel: String? = appInfo.labelRes.takeIf { it != 0 }?.let { labelRes ->
var englishLabel: String? = null
try {
val resources = pm.getResourcesForApplication(ai)
val resources = pm.getResourcesForApplication(appInfo)
// `updateConfiguration` is deprecated but it seems to be the only way
// to query resources from another app with a specific locale.
// The following methods do not work:
@ -92,15 +88,26 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
// - getting a package manager from a custom context with `context.createConfigurationContext(config)`
@Suppress("DEPRECATION")
resources.updateConfiguration(englishConfig, resources.displayMetrics)
val englishLabel = resources.getString(labelRes)
nameMap[englishLabel] = packageName
englishLabel = resources.getString(labelRes)
} catch (e: PackageManager.NameNotFoundException) {
Log.w(LOG_TAG, "failed to get app label in English for packageName=$packageName", e)
}
englishLabel
}
packages[packageName] = hashMapOf(
"packageName" to packageName,
"categoryLauncher" to intent.hasCategory(Intent.CATEGORY_LAUNCHER),
"isSystem" to (appInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0),
"currentLabel" to currentLabel,
"englishLabel" to englishLabel,
)
}
}
}
result.success(nameMap)
addPackageDetails(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER))
addPackageDetails(Intent(Intent.ACTION_MAIN))
result.success(ArrayList(packages.values))
}
private fun getAppIcon(call: MethodCall, result: MethodChannel.Result) {

View file

@ -1,9 +1,11 @@
package deckers.thibault.aves.channel.calls
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.reflect.KSuspendFunction2
// ensure `result` methods are called on the main looper thread
class Coresult internal constructor(private val methodResult: MethodChannel.Result) : MethodChannel.Result {
@ -20,4 +22,24 @@ class Coresult internal constructor(private val methodResult: MethodChannel.Resu
override fun notImplemented() {
mainScope.launch { methodResult.notImplemented() }
}
companion object {
fun safe(call: MethodCall, result: MethodChannel.Result, function: (call: MethodCall, result: MethodChannel.Result) -> Unit) {
val res = Coresult(result)
try {
function(call, res)
} catch (e: Exception) {
res.error("safe-exception", e.message, e.stackTraceToString())
}
}
suspend fun safesus(call: MethodCall, result: MethodChannel.Result, function: KSuspendFunction2<MethodCall, MethodChannel.Result, Unit>) {
val res = Coresult(result)
try {
function(call, res)
} catch (e: Exception) {
res.error("safe-exception", e.message, e.stackTraceToString())
}
}
}
}

View file

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

View file

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

View file

@ -1,8 +1,15 @@
package deckers.thibault.aves.channel.calls
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import android.media.MediaExtractor
import android.media.MediaFormat
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.Log
import androidx.exifinterface.media.ExifInterface
import com.adobe.internal.xmp.XMPException
@ -18,6 +25,7 @@ import com.drew.metadata.iptc.IptcDirectory
import com.drew.metadata.mp4.media.Mp4UuidBoxDirectory
import com.drew.metadata.webp.WebpDirectory
import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.metadata.*
import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
@ -38,13 +46,14 @@ import deckers.thibault.aves.metadata.MetadataExtractorHelper.isGeoTiff
import deckers.thibault.aves.metadata.XMP.getSafeDateMillis
import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText
import deckers.thibault.aves.metadata.XMP.isPanorama
import deckers.thibault.aves.model.provider.FieldMap
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.provider.FileImageProvider
import deckers.thibault.aves.model.provider.ImageProvider
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isHeifLike
import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isMultimedia
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
@ -66,14 +75,15 @@ import kotlin.math.roundToLong
class MetadataHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getAllMetadata" -> GlobalScope.launch(Dispatchers.IO) { getAllMetadata(call, Coresult(result)) }
"getCatalogMetadata" -> GlobalScope.launch(Dispatchers.IO) { getCatalogMetadata(call, Coresult(result)) }
"getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { getOverlayMetadata(call, Coresult(result)) }
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { getMultiPageInfo(call, Coresult(result)) }
"getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { getPanoramaInfo(call, Coresult(result)) }
"getEmbeddedPictures" -> GlobalScope.launch(Dispatchers.IO) { getEmbeddedPictures(call, Coresult(result)) }
"getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { getExifThumbnails(call, Coresult(result)) }
"extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { extractXmpDataProp(call, Coresult(result)) }
"getAllMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getAllMetadata) }
"getCatalogMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getCatalogMetadata) }
"getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getOverlayMetadata) }
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) }
"getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPanoramaInfo) }
"getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) }
"getEmbeddedPictures" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getEmbeddedPictures) }
"getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getExifThumbnails) }
"extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractXmpDataProp) }
else -> result.notImplemented()
}
}
@ -430,7 +440,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
}
if (mimeType == MimeTypes.HEIC || mimeType == MimeTypes.HEIF) {
if (isHeifLike(mimeType)) {
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS) {
if (it > 1) flags = flags or MASK_IS_MULTIPAGE
}
@ -521,21 +531,57 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
return
}
val pages = HashMap<Int, Any>()
val pages = ArrayList<Map<String, Any>>()
if (mimeType == MimeTypes.TIFF) {
fun toMap(options: TiffBitmapFactory.Options): Map<String, Any> {
fun toMap(page: Int, options: TiffBitmapFactory.Options): HashMap<String, Any> {
return hashMapOf(
"width" to options.outWidth,
"height" to options.outHeight,
KEY_PAGE to page,
KEY_MIME_TYPE to mimeType,
KEY_WIDTH to options.outWidth,
KEY_HEIGHT to options.outHeight,
)
}
getTiffPageInfo(uri, 0)?.let { first ->
pages[0] = toMap(first)
pages.add(toMap(0, first))
val pageCount = first.outDirectoryCount
for (i in 1 until pageCount) {
getTiffPageInfo(uri, i)?.let { pages[i] = toMap(it) }
getTiffPageInfo(uri, i)?.let { pages.add(toMap(i, it)) }
}
}
} else if (isHeifLike(mimeType)) {
fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) {
if (this.containsKey(key)) save(this.getInteger(key))
}
fun MediaFormat.getSafeLong(key: String, save: (value: Long) -> Unit) {
if (this.containsKey(key)) save(this.getLong(key))
}
val extractor = MediaExtractor()
extractor.setDataSource(context, uri, null)
for (i in 0 until extractor.trackCount) {
try {
val format = extractor.getTrackFormat(i)
format.getString(MediaFormat.KEY_MIME)?.let { mime ->
val trackMime = if (mime == MediaFormat.MIMETYPE_IMAGE_ANDROID_HEIC) MimeTypes.HEIC else mime
val page = hashMapOf<String, Any>(
KEY_PAGE to i,
KEY_MIME_TYPE to trackMime,
)
format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { page[KEY_IS_DEFAULT] = it != 0 }
format.getSafeInt(MediaFormat.KEY_TRACK_ID) { page[KEY_TRACK_ID] = it }
format.getSafeInt(MediaFormat.KEY_WIDTH) { page[KEY_WIDTH] = it }
format.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it }
if (isVideo(trackMime)) {
format.getSafeLong(MediaFormat.KEY_DURATION) { page[KEY_DURATION] = it / 1000 }
}
pages.add(page)
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get track information for uri=$uri, track num=$i", e)
}
}
extractor.release()
}
result.success(pages)
}
@ -555,14 +601,16 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
val metadata = ImageMetadataReader.readMetadata(input)
val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java)
try {
fun getProp(propName: String): Int? = xmpDirs.map { it.xmpMeta.getPropertyInteger(XMP.GPANO_SCHEMA_NS, propName) }.firstOrNull { it != null }
fun getIntProp(propName: String): Int? = xmpDirs.map { it.xmpMeta.getPropertyInteger(XMP.GPANO_SCHEMA_NS, propName) }.firstOrNull { it != null }
fun getStringProp(propName: String): String? = xmpDirs.map { it.xmpMeta.getPropertyString(XMP.GPANO_SCHEMA_NS, propName) }.firstOrNull { it != null }
val fields: FieldMap = hashMapOf(
"croppedAreaLeft" to getProp(XMP.GPANO_CROPPED_AREA_LEFT_PROP_NAME),
"croppedAreaTop" to getProp(XMP.GPANO_CROPPED_AREA_TOP_PROP_NAME),
"croppedAreaWidth" to getProp(XMP.GPANO_CROPPED_AREA_WIDTH_PROP_NAME),
"croppedAreaHeight" to getProp(XMP.GPANO_CROPPED_AREA_HEIGHT_PROP_NAME),
"fullPanoWidth" to getProp(XMP.GPANO_FULL_PANO_WIDTH_PROP_NAME),
"fullPanoHeight" to getProp(XMP.GPANO_FULL_PANO_HEIGHT_PROP_NAME),
"croppedAreaLeft" to getIntProp(XMP.GPANO_CROPPED_AREA_LEFT_PROP_NAME),
"croppedAreaTop" to getIntProp(XMP.GPANO_CROPPED_AREA_TOP_PROP_NAME),
"croppedAreaWidth" to getIntProp(XMP.GPANO_CROPPED_AREA_WIDTH_PROP_NAME),
"croppedAreaHeight" to getIntProp(XMP.GPANO_CROPPED_AREA_HEIGHT_PROP_NAME),
"fullPanoWidth" to getIntProp(XMP.GPANO_FULL_PANO_WIDTH_PROP_NAME),
"fullPanoHeight" to getIntProp(XMP.GPANO_FULL_PANO_HEIGHT_PROP_NAME),
"projectionType" to (getStringProp(XMP.GPANO_PROJECTION_TYPE_PROP_NAME) ?: XMP.GPANO_PROJECTION_TYPE_DEFAULT),
)
result.success(fields)
return
@ -580,6 +628,55 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
result.error("getPanoramaInfo-empty", "failed to read XMP from uri=$uri", null)
}
private fun getContentResolverProp(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val prop = call.argument<String>("prop")
if (mimeType == null || uri == null || prop == null) {
result.error("getContentResolverProp-args", "failed because of missing arguments", null)
return
}
var contentUri: Uri = uri
if (uri.scheme == ContentResolver.SCHEME_CONTENT && MediaStore.AUTHORITY.equals(uri.host, ignoreCase = true)) {
try {
val id = ContentUris.parseId(uri)
contentUri = when {
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
else -> uri
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
contentUri = MediaStore.setRequireOriginal(contentUri)
}
} catch (e: NumberFormatException) {
// ignore
}
}
val projection = arrayOf(prop)
val cursor = context.contentResolver.query(contentUri, projection, null, null, null)
if (cursor != null && cursor.moveToFirst()) {
var value: Any? = null
try {
value = when (cursor.getType(0)) {
Cursor.FIELD_TYPE_NULL -> null
Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(0)
Cursor.FIELD_TYPE_FLOAT -> cursor.getFloat(0)
Cursor.FIELD_TYPE_STRING -> cursor.getString(0)
Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(0)
else -> null
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get value for key=$prop", e)
}
cursor.close()
result.success(value?.toString())
} else {
result.error("getContentResolverProp-null", "failed to get cursor for contentUri=$contentUri", null)
}
}
private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {
@ -619,7 +716,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
exif.thumbnailBitmap?.let { bitmap ->
TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let {
it.getBytes(canHaveAlpha = false, recycle = false)?.let { thumbnails.add(it) }
it.getBytes(canHaveAlpha = false, recycle = false)?.let { bytes -> thumbnails.add(bytes) }
}
}
}
@ -733,7 +830,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private val LOG_TAG = LogUtils.createTag(MetadataHandler::class.java)
const val CHANNEL = "deckers.thibault/aves/metadata"
// catalog metadata
// catalog metadata & page info
private const val KEY_MIME_TYPE = "mimeType"
private const val KEY_DATE_MILLIS = "dateMillis"
private const val KEY_FLAGS = "flags"
@ -742,6 +839,12 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private const val KEY_LONGITUDE = "longitude"
private const val KEY_XMP_SUBJECTS = "xmpSubjects"
private const val KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription"
private const val KEY_HEIGHT = "height"
private const val KEY_WIDTH = "width"
private const val KEY_PAGE = "page"
private const val KEY_TRACK_ID = "trackId"
private const val KEY_IS_DEFAULT = "isDefault"
private const val KEY_DURATION = "durationMillis"
private const val MASK_IS_ANIMATED = 1 shl 0
private const val MASK_IS_FLIPPED = 1 shl 1

View file

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

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.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder
import android.graphics.Rect
import android.net.Uri
import android.util.Size
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.decoder.MultiTrackImage
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.MethodChannel
import java.io.File
import kotlin.math.roundToInt
class RegionFetcher internal constructor(
@ -17,21 +24,42 @@ class RegionFetcher internal constructor(
) {
private var lastDecoderRef: LastDecoderRef? = null
private val pageTempUris = HashMap<Pair<Uri, Int>, Uri>()
private val multiTrackGlideOptions = RequestOptions()
.format(DecodeFormat.PREFER_ARGB_8888)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true)
fun fetch(
uri: Uri,
mimeType: String,
pageId: Int?,
sampleSize: Int,
regionRect: Rect,
imageSize: Size,
result: MethodChannel.Result,
) {
if (MimeTypes.isHeifLike(mimeType) && pageId != null) {
val id = Pair(uri, pageId)
fetch(
uri = pageTempUris.getOrPut(id) { createJpegForPage(uri, pageId) },
mimeType = MimeTypes.JPEG,
pageId = null,
sampleSize = sampleSize,
regionRect = regionRect,
imageSize = imageSize,
result = result,
)
return
}
val options = BitmapFactory.Options().apply {
inSampleSize = sampleSize
}
var currentDecoderRef = lastDecoderRef
if (currentDecoderRef != null && currentDecoderRef.uri != uri) {
currentDecoderRef.decoder.recycle()
currentDecoderRef = null
}
@ -74,6 +102,26 @@ class RegionFetcher internal constructor(
result.error("getRegion-read-exception", "failed to initialize region decoder for uri=$uri regionRect=$regionRect", e.message)
}
}
private fun createJpegForPage(sourceUri: Uri, pageId: Int): Uri {
val target = Glide.with(context)
.asBitmap()
.apply(multiTrackGlideOptions)
.load(MultiTrackImage(context, sourceUri, pageId))
.submit()
try {
val bitmap = target.get()
val tempFile = File.createTempFile("aves", null, context.cacheDir).apply {
deleteOnExit()
outputStream().use { outputStream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
}
}
return Uri.fromFile(tempFile)
} finally {
Glide.with(context).clear(target)
}
}
}
private data class LastDecoderRef(

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

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.graphics.Rect
@ -13,9 +13,9 @@ class TiffRegionFetcher internal constructor(
) {
fun fetch(
uri: Uri,
page: Int,
sampleSize: Int,
regionRect: Rect,
page: Int = 0,
result: MethodChannel.Result,
) {
try {

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.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.decoder.MultiTrackImage
import deckers.thibault.aves.decoder.TiffImage
import deckers.thibault.aves.decoder.VideoThumbnail
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isHeifLike
import deckers.thibault.aves.utils.MimeTypes.isSupportedByFlutter
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
@ -23,7 +26,6 @@ import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import java.io.IOException
import java.io.InputStream
@ -84,7 +86,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
val uri = (arguments["uri"] as String?)?.let { Uri.parse(it) }
val rotationDegrees = arguments["rotationDegrees"] as Int
val isFlipped = arguments["isFlipped"] as Boolean
val page = arguments["page"] as Int
val pageId = arguments["pageId"] as Int?
if (mimeType == null || uri == null) {
error("streamImage-args", "failed because of missing arguments", null)
@ -94,11 +96,9 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
if (isVideo(mimeType)) {
streamVideoByGlide(uri)
} else if (mimeType == MimeTypes.TIFF) {
streamTiffImage(uri, page)
} else if (!isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) {
// decode exotic format on platform side, then encode it in portable format for Flutter
streamImageByGlide(uri, mimeType, rotationDegrees, isFlipped)
streamImageByGlide(uri, pageId, mimeType, rotationDegrees, isFlipped)
} else {
// to be decoded by Flutter
streamImageAsIs(uri)
@ -114,11 +114,19 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
}
}
private fun streamImageByGlide(uri: Uri, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) {
private fun streamImageByGlide(uri: Uri, pageId: Int?, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) {
val model: Any = if (isHeifLike(mimeType) && pageId != null) {
MultiTrackImage(activity, uri, pageId)
} else if (mimeType == MimeTypes.TIFF) {
TiffImage(activity, uri, pageId)
} else {
uri
}
val target = Glide.with(activity)
.asBitmap()
.apply(glideOptions)
.load(uri)
.load(model)
.submit()
try {
var bitmap = target.get()
@ -157,28 +165,6 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
}
}
private fun streamTiffImage(uri: Uri, page: Int = 0) {
val resolver = activity.contentResolver
try {
val fd = resolver.openFileDescriptor(uri, "r")?.detachFd()
if (fd == null) {
error("streamImage-tiff-fd", "failed to get file descriptor for uri=$uri", null)
return
}
val options = TiffBitmapFactory.Options().apply {
inDirectoryNumber = page
}
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
if (bitmap != null) {
success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
} else {
error("streamImage-tiff-null", "failed to get tiff image (dir=$page) from uri=$uri", null)
}
} catch (e: Exception) {
error("streamImage-tiff-exception", "failed to get image from uri=$uri", toErrorDetails(e))
}
}
private fun toErrorDetails(e: Exception): String? {
val errorDetails = e.message
return if (errorDetails?.isNotEmpty() == true) {

View file

@ -5,8 +5,8 @@ import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import deckers.thibault.aves.model.AvesImageEntry
import deckers.thibault.aves.model.provider.FieldMap
import deckers.thibault.aves.model.AvesEntry
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
import deckers.thibault.aves.utils.LogUtils
@ -42,6 +42,7 @@ class ImageOpStreamHandler(private val context: Context, private val arguments:
when (op) {
"delete" -> GlobalScope.launch(Dispatchers.IO) { delete() }
"export" -> GlobalScope.launch(Dispatchers.IO) { export() }
"move" -> GlobalScope.launch(Dispatchers.IO) { move() }
else -> endOfStream()
}
@ -80,36 +81,6 @@ class ImageOpStreamHandler(private val context: Context, private val arguments:
}
}
private suspend fun move() {
if (arguments !is Map<*, *> || entryMapList.isEmpty()) {
endOfStream()
return
}
// assume same provider for all entries
val firstEntry = entryMapList.first()
val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(it) }
if (provider == null) {
error("move-provider", "failed to find provider for entry=$firstEntry", null)
return
}
val copy = arguments["copy"] as Boolean?
var destinationDir = arguments["destinationPath"] as String?
if (copy == null || destinationDir == null) {
error("move-args", "failed because of missing arguments", null)
return
}
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
val entries = entryMapList.map(::AvesImageEntry)
provider.moveMultiple(context, copy, destinationDir, entries, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = success(fields)
override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable)
})
endOfStream()
}
private suspend fun delete() {
if (entryMapList.isEmpty()) {
endOfStream()
@ -144,6 +115,66 @@ class ImageOpStreamHandler(private val context: Context, private val arguments:
endOfStream()
}
private suspend fun export() {
if (arguments !is Map<*, *> || entryMapList.isEmpty()) {
endOfStream()
return
}
var destinationDir = arguments["destinationPath"] as String?
val mimeType = arguments["mimeType"] as String?
if (destinationDir == null || mimeType == null) {
error("export-args", "failed because of missing arguments", null)
return
}
// assume same provider for all entries
val firstEntry = entryMapList.first()
val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(it) }
if (provider == null) {
error("export-provider", "failed to find provider for entry=$firstEntry", null)
return
}
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
val entries = entryMapList.map(::AvesEntry)
provider.exportMultiple(context, mimeType, destinationDir, entries, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = success(fields)
override fun onFailure(throwable: Throwable) = error("export-failure", "failed to export entries", throwable)
})
endOfStream()
}
private suspend fun move() {
if (arguments !is Map<*, *> || entryMapList.isEmpty()) {
endOfStream()
return
}
val copy = arguments["copy"] as Boolean?
var destinationDir = arguments["destinationPath"] as String?
if (copy == null || destinationDir == null) {
error("move-args", "failed because of missing arguments", null)
return
}
// assume same provider for all entries
val firstEntry = entryMapList.first()
val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(it) }
if (provider == null) {
error("move-provider", "failed to find provider for entry=$firstEntry", null)
return
}
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
val entries = entryMapList.map(::AvesEntry)
provider.moveMultiple(context, copy, destinationDir, entries, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = success(fields)
override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable)
})
endOfStream()
}
companion object {
private val LOG_TAG = LogUtils.createTag(ImageOpStreamHandler::class.java)
const val CHANNEL = "deckers.thibault/aves/imageopstream"

View file

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

View file

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

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
import android.content.Context
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import com.bumptech.glide.Glide
import com.bumptech.glide.Priority
import com.bumptech.glide.Registry
@ -34,7 +36,7 @@ internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, InputStream> {
return ModelLoader.LoadData(ObjectKey(model.uri), VideoThumbnailFetcher(model))
}
override fun handles(videoThumbnail: VideoThumbnail): Boolean = true
override fun handles(model: VideoThumbnail): Boolean = true
internal class Factory : ModelLoaderFactory<VideoThumbnail, InputStream> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<VideoThumbnail, InputStream> = VideoThumbnailLoader()
@ -48,9 +50,29 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail) : DataFe
val retriever = openMetadataRetriever(model.context, model.uri)
if (retriever != null) {
try {
val picture = retriever.embeddedPicture ?: retriever.frameAtTime?.getBytes(canHaveAlpha = false, recycle = false)
if (picture != null) {
callback.onDataReady(ByteArrayInputStream(picture))
var bytes = retriever.embeddedPicture
if (bytes == null) {
// try to match the thumbnails returned by the content resolver / Media Store
// the following strategies are from empirical evidence from a few test devices:
// - API 29: sync frame closest to the middle
// - API 26/27: default representative frame at any time position
var timeMillis: Long? = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val durationMillis = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull()
if (durationMillis != null) {
timeMillis = durationMillis / 2
}
}
val frame = if (timeMillis != null) {
retriever.getFrameAtTime(timeMillis * 1000)
} else {
retriever.frameAtTime
}
bytes = frame?.getBytes(canHaveAlpha = false, recycle = false)
}
if (bytes != null) {
callback.onDataReady(ByteArrayInputStream(bytes))
} else {
callback.onLoadFailed(Exception("failed to get embedded picture or any frame"))
}

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
import android.util.Log
import com.adobe.internal.xmp.XMPError
import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.XMPMeta
import deckers.thibault.aves.utils.LogUtils
@ -9,6 +10,8 @@ import java.util.*
object XMP {
private val LOG_TAG = LogUtils.createTag(XMP::class.java)
// standard namespaces
// cf com.adobe.internal.xmp.XMPConst
const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/"
const val PHOTOSHOP_SCHEMA_NS = "http://ns.adobe.com/photoshop/1.0/"
const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/"
@ -51,27 +54,46 @@ object XMP {
const val GPANO_CROPPED_AREA_TOP_PROP_NAME = "GPano:CroppedAreaTopPixels"
const val GPANO_FULL_PANO_HEIGHT_PROP_NAME = "GPano:FullPanoHeightPixels"
const val GPANO_FULL_PANO_WIDTH_PROP_NAME = "GPano:FullPanoWidthPixels"
private const val GPANO_PROJECTION_TYPE_PROP_NAME = "GPano:ProjectionType"
const val GPANO_PROJECTION_TYPE_PROP_NAME = "GPano:ProjectionType"
const val GPANO_PROJECTION_TYPE_DEFAULT = "equirectangular"
private const val PMTM_IS_PANO360 = "pmtm:IsPano360"
// `GPano:ProjectionType` is required by spec but it is sometimes missing, assuming default
// `GPano:FullPanoHeightPixels` is required by spec but it is sometimes missing (e.g. Samsung Camera app panorama mode)
private val gpanoRequiredProps = listOf(
GPANO_CROPPED_AREA_HEIGHT_PROP_NAME,
GPANO_CROPPED_AREA_WIDTH_PROP_NAME,
GPANO_CROPPED_AREA_LEFT_PROP_NAME,
GPANO_CROPPED_AREA_TOP_PROP_NAME,
GPANO_FULL_PANO_HEIGHT_PROP_NAME,
GPANO_FULL_PANO_WIDTH_PROP_NAME,
GPANO_PROJECTION_TYPE_PROP_NAME,
)
// extensions
fun XMPMeta.isPanorama(): Boolean {
// Google
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
try {
if (getPropertyString(PMTM_SCHEMA_NS, PMTM_IS_PANO360) == "Yes") return true
} catch (e: XMPException) {
if (e.errorCode != XMPError.BADSCHEMA) {
// `BADSCHEMA` code is reported when we check a property
// from a non standard namespace, and that namespace is not declared in the XMP
Log.w(LOG_TAG, "failed to check Photomatix panorama props from XMP", e)
}
}
return false
}
@ -102,7 +124,7 @@ object XMP {
}
}
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to get text for XMP schema=$schema, propName=$propName", e)
Log.w(LOG_TAG, "failed to get date for XMP schema=$schema, propName=$propName", e)
}
}
}

View file

@ -1,13 +1,15 @@
package deckers.thibault.aves.model
import android.net.Uri
import deckers.thibault.aves.model.provider.FieldMap
import deckers.thibault.aves.model.FieldMap
class AvesImageEntry(map: FieldMap) {
class AvesEntry(map: FieldMap) {
val uri: Uri = Uri.parse(map["uri"] as String) // content or file URI
val path = map["path"] as String? // best effort to get local path
val pageId = map["pageId"] as Int? // null means the main entry
val mimeType = map["mimeType"] as String
val width = map["width"] as Int
val height = map["height"] as Int
val rotationDegrees = map["rotationDegrees"] as Int
val isFlipped = map["isFlipped"] as Boolean
}

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

View file

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

View file

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

View file

@ -2,18 +2,28 @@ package deckers.thibault.aves.model.provider
import android.content.ContentUris
import android.content.Context
import android.graphics.Bitmap
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.Log
import androidx.exifinterface.media.ExifInterface
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.model.AvesImageEntry
import deckers.thibault.aves.decoder.MultiTrackImage
import deckers.thibault.aves.decoder.TiffImage
import deckers.thibault.aves.model.AvesEntry
import deckers.thibault.aves.model.ExifOrientationOp
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils.copyFileToTemp
import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent
import deckers.thibault.aves.utils.StorageUtils.getDocumentFile
import java.io.File
import java.io.FileNotFoundException
@ -32,10 +42,151 @@ abstract class ImageProvider {
throw UnsupportedOperationException()
}
open suspend fun moveMultiple(context: Context, copy: Boolean, destinationDir: String, entries: List<AvesImageEntry>, callback: ImageOpCallback) {
open suspend fun moveMultiple(context: Context, copy: Boolean, destinationDir: String, entries: List<AvesEntry>, callback: ImageOpCallback) {
callback.onFailure(UnsupportedOperationException())
}
suspend fun exportMultiple(
context: Context,
mimeType: String,
destinationDir: String,
entries: List<AvesEntry>,
callback: ImageOpCallback,
) {
val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir)
if (destinationDirDocFile == null) {
callback.onFailure(Exception("failed to create directory at path=$destinationDir"))
return
}
for (entry in entries) {
val sourceUri = entry.uri
val sourcePath = entry.path
val pageId = entry.pageId
val result = hashMapOf<String, Any?>(
"uri" to sourceUri.toString(),
"pageId" to pageId,
"success" to false,
)
try {
val newFields = exportSingleByTreeDocAndScan(
context = context,
sourceEntry = entry,
destinationDir = destinationDir,
destinationDirDocFile = destinationDirDocFile,
exportMimeType = mimeType,
)
result["newFields"] = newFields
result["success"] = true
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to export to destinationDir=$destinationDir entry with sourcePath=$sourcePath pageId=$pageId", e)
}
callback.onSuccess(result)
}
}
private suspend fun exportSingleByTreeDocAndScan(
context: Context,
sourceEntry: AvesEntry,
destinationDir: String,
destinationDirDocFile: DocumentFileCompat,
exportMimeType: String,
): FieldMap {
val sourceMimeType = sourceEntry.mimeType
val sourceUri = sourceEntry.uri
val pageId = sourceEntry.pageId
var desiredNameWithoutExtension = if (sourceEntry.path != null) {
val sourcePath = sourceEntry.path
val sourceFile = File(sourcePath)
val sourceFileName = sourceFile.name
sourceFileName.replaceFirst("[.][^.]+$".toRegex(), "")
} else {
sourceUri.lastPathSegment!!
}
if (pageId != null) {
val page = if (sourceMimeType == MimeTypes.TIFF) pageId + 1 else pageId
desiredNameWithoutExtension += "_${page.toString().padStart(3, '0')}"
}
val desiredFileName = desiredNameWithoutExtension + when (exportMimeType) {
MimeTypes.JPEG -> ".jpg"
MimeTypes.PNG -> ".png"
MimeTypes.WEBP -> ".webp"
else -> throw Exception("unsupported export MIME type=$exportMimeType")
}
if (File(destinationDir, desiredFileName).exists()) {
throw Exception("file with name=$desiredFileName already exists in destination directory")
}
// the file created from a `TreeDocumentFile` is also a `TreeDocumentFile`
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
// through a document URI, not a tree URI
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
@Suppress("BlockingMethodInNonBlockingContext")
val destinationTreeFile = destinationDirDocFile.createFile(exportMimeType, desiredNameWithoutExtension)
val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri)
val model: Any = if (MimeTypes.isHeifLike(sourceMimeType) && pageId != null) {
MultiTrackImage(context, sourceUri, pageId)
} else if (sourceMimeType == MimeTypes.TIFF) {
TiffImage(context, sourceUri, pageId)
} else {
sourceUri
}
// request a fresh image with the highest quality format
val glideOptions = RequestOptions()
.format(DecodeFormat.PREFER_ARGB_8888)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true)
val target = Glide.with(context)
.asBitmap()
.apply(glideOptions)
.load(model)
.submit()
try {
@Suppress("BlockingMethodInNonBlockingContext")
var bitmap = target.get()
if (MimeTypes.needRotationAfterGlide(sourceMimeType)) {
bitmap = BitmapUtils.applyExifOrientation(context, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped)
}
bitmap ?: throw Exception("failed to get image from uri=$sourceUri page=$pageId")
val quality = 100
val format = when (exportMimeType) {
MimeTypes.JPEG -> Bitmap.CompressFormat.JPEG
MimeTypes.PNG -> Bitmap.CompressFormat.PNG
MimeTypes.WEBP -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (quality == 100) {
Bitmap.CompressFormat.WEBP_LOSSLESS
} else {
Bitmap.CompressFormat.WEBP_LOSSY
}
} else {
@Suppress("DEPRECATION")
Bitmap.CompressFormat.WEBP
}
else -> throw Exception("unsupported export MIME type=$exportMimeType")
}
@Suppress("BlockingMethodInNonBlockingContext")
destinationDocFile.openOutputStream().use {
bitmap.compress(format, quality, it)
}
} finally {
Glide.with(context).clear(target)
}
val fileName = destinationDocFile.name
val destinationFullPath = destinationDir + fileName
return scanNewPath(context, destinationFullPath, exportMimeType)
}
suspend fun rename(context: Context, oldPath: String, oldMediaUri: Uri, mimeType: String, newFilename: String, callback: ImageOpCallback) {
val oldFile = File(oldPath)
val newFile = File(oldFile.parent, newFilename)
@ -147,9 +298,9 @@ abstract class ImageProvider {
// `newURI` is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872")
// but we need an image/video media URI (e.g. "content://media/external/images/media/62872")
contentId = ContentUris.parseId(newUri)
if (isImage(mimeType)) {
if (MimeTypes.isImage(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId)
} else if (isVideo(mimeType)) {
} else if (MimeTypes.isVideo(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId)
}
}
@ -198,5 +349,3 @@ abstract class ImageProvider {
private val LOG_TAG = LogUtils.createTag(ImageProvider::class.java)
}
}
typealias FieldMap = MutableMap<String, Any?>

View file

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

View file

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

View file

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

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
classpath 'com.android.tools.build:gradle:3.6.4'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.google.gms:google-services:4.3.4'
classpath 'com.google.gms:google-services:4.3.5'
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.4.1'
}
}

View file

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

View file

@ -2,10 +2,9 @@ import 'dart:async';
import 'dart:math';
import 'dart:ui' as ui show Codec;
import 'package:aves/model/image_entry.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
class RegionProvider extends ImageProvider<RegionProviderKey> {
final RegionProviderKey key;
@ -23,7 +22,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
codec: _loadAsync(key, decode),
scale: key.scale,
informationCollector: () sync* {
yield ErrorDescription('uri=${key.uri}, regionRect=${key.regionRect}');
yield ErrorDescription('uri=${key.uri}, pageId=${key.pageId}, mimeType=${key.mimeType}, region=${key.region}');
},
);
}
@ -31,6 +30,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
Future<ui.Codec> _loadAsync(RegionProviderKey key, DecoderCallback decode) async {
final uri = key.uri;
final mimeType = key.mimeType;
final pageId = key.pageId;
try {
final bytes = await ImageFileService.getRegion(
uri,
@ -38,9 +38,9 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
key.rotationDegrees,
key.isFlipped,
key.sampleSize,
key.regionRect,
key.region,
key.imageSize,
page: key.page,
pageId: pageId,
taskKey: key,
);
if (bytes == null) {
@ -49,7 +49,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
return await decode(bytes);
} catch (error) {
debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error');
throw StateError('$mimeType region decoding failed');
throw StateError('$mimeType region decoding failed (page $pageId)');
}
}
@ -63,21 +63,23 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
}
class RegionProviderKey {
// do not store the entry as it is, because the key should be constant
// but the entry attributes may change over time
final String uri, mimeType;
final int rotationDegrees, sampleSize, page;
final int pageId, rotationDegrees, sampleSize;
final bool isFlipped;
final Rectangle<int> regionRect;
final Rectangle<int> region;
final Size imageSize;
final double scale;
const RegionProviderKey({
@required this.uri,
@required this.mimeType,
@required this.pageId,
@required this.rotationDegrees,
@required this.isFlipped,
this.page = 0,
@required this.sampleSize,
@required this.regionRect,
@required this.region,
@required this.imageSize,
this.scale = 1.0,
}) : assert(uri != null),
@ -85,49 +87,29 @@ class RegionProviderKey {
assert(rotationDegrees != null),
assert(isFlipped != null),
assert(sampleSize != null),
assert(regionRect != null),
assert(region != null),
assert(imageSize != null),
assert(scale != null);
// do not store the entry as it is, because the key should be constant
// but the entry attributes may change over time
factory RegionProviderKey.fromEntry(
ImageEntry entry, {
int page = 0,
@required int sampleSize,
@required Rectangle<int> rect,
}) {
return RegionProviderKey(
uri: entry.uri,
mimeType: entry.mimeType,
rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
page: page,
sampleSize: sampleSize,
regionRect: rect,
imageSize: Size(entry.width.toDouble(), entry.height.toDouble()),
);
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is RegionProviderKey && other.uri == uri && other.mimeType == mimeType && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.page == page && other.sampleSize == sampleSize && other.regionRect == regionRect && other.imageSize == imageSize && other.scale == scale;
return other is RegionProviderKey && other.uri == uri && other.mimeType == mimeType && other.pageId == pageId && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.sampleSize == sampleSize && other.region == region && other.imageSize == imageSize && other.scale == scale;
}
@override
int get hashCode => hashValues(
uri,
mimeType,
pageId,
rotationDegrees,
isFlipped,
page,
sampleSize,
regionRect,
region,
imageSize,
scale,
);
@override
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, page=$page, sampleSize=$sampleSize, regionRect=$regionRect, imageSize=$imageSize, scale=$scale}';
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, pageId=$pageId, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, region=$region, imageSize=$imageSize, scale=$scale}';
}

View file

@ -1,9 +1,8 @@
import 'dart:ui' as ui show Codec;
import 'package:aves/model/image_entry.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
final ThumbnailProviderKey key;
@ -24,7 +23,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
codec: _loadAsync(key, decode),
scale: key.scale,
informationCollector: () sync* {
yield ErrorDescription('uri=${key.uri}, extent=${key.extent}');
yield ErrorDescription('uri=${key.uri}, pageId=${key.pageId}, mimeType=${key.mimeType}, extent=${key.extent}');
},
);
}
@ -32,16 +31,16 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
Future<ui.Codec> _loadAsync(ThumbnailProviderKey key, DecoderCallback decode) async {
final uri = key.uri;
final mimeType = key.mimeType;
final pageId = key.pageId;
try {
final bytes = await ImageFileService.getThumbnail(
uri,
mimeType,
key.dateModifiedSecs,
key.rotationDegrees,
key.isFlipped,
key.extent,
key.extent,
page: key.page,
uri: uri,
mimeType: mimeType,
pageId: pageId,
rotationDegrees: key.rotationDegrees,
isFlipped: key.isFlipped,
dateModifiedSecs: key.dateModifiedSecs,
extent: key.extent,
taskKey: key,
);
if (bytes == null) {
@ -50,7 +49,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
return await decode(bytes);
} catch (error) {
debugPrint('$runtimeType _loadAsync failed with uri=$uri, error=$error');
throw StateError('$mimeType decoding failed');
throw StateError('$mimeType decoding failed (page $pageId)');
}
}
@ -64,61 +63,49 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
}
class ThumbnailProviderKey {
// do not store the entry as it is, because the key should be constant
// but the entry attributes may change over time
final String uri, mimeType;
final int dateModifiedSecs, rotationDegrees, page;
final int pageId, rotationDegrees;
final bool isFlipped;
final int dateModifiedSecs;
final double extent, scale;
const ThumbnailProviderKey({
@required this.uri,
@required this.mimeType,
@required this.dateModifiedSecs,
@required this.pageId,
@required this.rotationDegrees,
@required this.isFlipped,
this.page = 0,
@required this.dateModifiedSecs,
this.extent = 0,
this.scale = 1,
}) : assert(uri != null),
assert(mimeType != null),
assert(dateModifiedSecs != null),
assert(rotationDegrees != null),
assert(isFlipped != null),
assert(dateModifiedSecs != null),
assert(extent != null),
assert(scale != null);
// do not store the entry as it is, because the key should be constant
// but the entry attributes may change over time
factory ThumbnailProviderKey.fromEntry(ImageEntry entry, {int page = 0, double extent = 0}) {
return ThumbnailProviderKey(
uri: entry.uri,
mimeType: entry.mimeType,
// `dateModifiedSecs` can be missing in viewer mode
dateModifiedSecs: entry.dateModifiedSecs ?? -1,
rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
page: page,
extent: extent,
);
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is ThumbnailProviderKey && other.uri == uri && other.extent == extent && other.mimeType == mimeType && other.dateModifiedSecs == dateModifiedSecs && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.page == page && other.scale == scale;
return other is ThumbnailProviderKey && other.uri == uri && other.mimeType == mimeType && other.pageId == pageId && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.dateModifiedSecs == dateModifiedSecs && other.extent == extent && other.scale == scale;
}
@override
int get hashCode => hashValues(
uri,
mimeType,
dateModifiedSecs,
pageId,
rotationDegrees,
isFlipped,
page,
dateModifiedSecs,
extent,
scale,
);
@override
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, dateModifiedSecs=$dateModifiedSecs, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, page=$page, extent=$extent, scale=$scale}';
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, pageId=$pageId, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, dateModifiedSecs=$dateModifiedSecs, extent=$extent, scale=$scale}';
}

View file

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

View file

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

View file

@ -2,10 +2,13 @@ import 'dart:isolate';
import 'dart:ui';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/media_store_source.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/debouncer.dart';
import 'package:aves/widgets/common/behaviour/route_tracker.dart';
import 'package:aves/widgets/common/behaviour/routes.dart';
import 'package:aves/widgets/common/providers/settings_provider.dart';
import 'package:aves/widgets/home_page.dart';
import 'package:aves/widgets/welcome_page.dart';
import 'package:firebase_analytics/firebase_analytics.dart';
@ -16,6 +19,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:overlay_support/overlay_support.dart';
import 'package:provider/provider.dart';
void main() {
// HttpClient.enableTimelineLogging = true; // enable network traffic logging
@ -43,12 +47,16 @@ class AvesApp extends StatefulWidget {
class _AvesAppState extends State<AvesApp> {
Future<void> _appSetup;
final _mediaStoreSource = MediaStoreSource();
final Debouncer _contentChangeDebouncer = Debouncer(delay: Durations.contentChangeDebounceDelay);
final List<String> changedUris = [];
// observers are not registered when using the same list object with different items
// the list itself needs to be reassigned
List<NavigatorObserver> _navigatorObservers = [];
final _newIntentChannel = EventChannel('deckers.thibault/aves/intent');
final _navigatorKey = GlobalKey<NavigatorState>();
final EventChannel _contentChangeChannel = EventChannel('deckers.thibault/aves/contentchange');
final EventChannel _newIntentChannel = EventChannel('deckers.thibault/aves/intent');
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator');
static const accentColor = Colors.indigoAccent;
@ -94,9 +102,57 @@ class _AvesAppState extends State<AvesApp> {
void initState() {
super.initState();
_appSetup = _setup();
_contentChangeChannel.receiveBroadcastStream().listen((event) => _onContentChange(event as String));
_newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map));
}
@override
Widget build(BuildContext context) {
// place the settings provider above `MaterialApp`
// so it can be used during navigation transitions
return ChangeNotifierProvider<Settings>.value(
value: settings,
child: Provider<CollectionSource>.value(
value: _mediaStoreSource,
child: OverlaySupport(
child: FutureBuilder<void>(
future: _appSetup,
builder: (context, snapshot) {
final home = (!snapshot.hasError && snapshot.connectionState == ConnectionState.done)
? getFirstPage()
: Scaffold(
body: snapshot.hasError ? _buildError(snapshot.error) : SizedBox.shrink(),
);
return MaterialApp(
navigatorKey: _navigatorKey,
home: home,
navigatorObservers: _navigatorObservers,
title: 'Aves',
darkTheme: darkTheme,
themeMode: ThemeMode.dark,
);
},
),
),
),
);
}
Widget _buildError(Object error) {
return Container(
alignment: Alignment.center,
padding: EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(AIcons.error),
SizedBox(height: 16),
Text(error.toString()),
],
),
);
}
Future<void> _setup() async {
await Firebase.initializeApp().then((app) {
final crashlytics = FirebaseCrashlytics.instance;
@ -133,46 +189,11 @@ class _AvesAppState extends State<AvesApp> {
));
}
@override
Widget build(BuildContext context) {
// place the settings provider above `MaterialApp`
// so it can be used during navigation transitions
return SettingsProvider(
child: OverlaySupport(
child: FutureBuilder<void>(
future: _appSetup,
builder: (context, snapshot) {
final home = (!snapshot.hasError && snapshot.connectionState == ConnectionState.done)
? getFirstPage()
: Scaffold(
body: snapshot.hasError ? _buildError(snapshot.error) : SizedBox.shrink(),
);
return MaterialApp(
navigatorKey: _navigatorKey,
home: home,
navigatorObservers: _navigatorObservers,
title: 'Aves',
darkTheme: darkTheme,
themeMode: ThemeMode.dark,
);
},
),
),
);
}
Widget _buildError(Object error) {
return Container(
alignment: Alignment.center,
padding: EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(AIcons.error),
SizedBox(height: 16),
Text(error.toString()),
],
),
);
void _onContentChange(String uri) {
changedUris.add(uri);
_contentChangeDebouncer(() {
_mediaStoreSource.refreshUris(List.of(changedUris));
changedUris.clear();
});
}
}

View file

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/theme/icons.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
@ -35,7 +35,7 @@ class LocationFilter extends CollectionFilter {
String get countryNameAndCode => '$_location$locationSeparator$_countryCode';
@override
bool filter(ImageEntry entry) => _location.isEmpty ? !entry.isLocated : entry.isLocated && ((level == LocationLevel.country && entry.addressDetails.countryCode == _countryCode) || (level == LocationLevel.place && entry.addressDetails.place == _location));
bool filter(AvesEntry entry) => _location.isEmpty ? !entry.isLocated : entry.isLocated && ((level == LocationLevel.country && entry.addressDetails.countryCode == _countryCode) || (level == LocationLevel.place && entry.addressDetails.place == _location));
@override
String get label => _location.isEmpty ? emptyLabel : _location;

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/theme/icons.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
@ -24,7 +24,7 @@ class TagFilter extends CollectionFilter {
};
@override
bool filter(ImageEntry entry) => tag.isEmpty ? entry.xmpSubjects.isEmpty : entry.xmpSubjects.contains(tag);
bool filter(AvesEntry entry) => tag.isEmpty ? entry.xmpSubjects.isEmpty : entry.xmpSubjects.contains(tag);
@override
bool get isUnique => false;

View file

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

View file

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

View file

@ -1,42 +1,83 @@
import 'package:flutter/foundation.dart';
class SinglePageInfo {
final int width, height;
SinglePageInfo({
this.width,
this.height,
});
factory SinglePageInfo.fromMap(Map map) {
return SinglePageInfo(
width: map['width'] as int,
height: map['height'] as int,
);
}
@override
String toString() => '$runtimeType#${shortHash(this)}{width=$width, height=$height}';
}
class MultiPageInfo {
final Map<int, SinglePageInfo> pages;
final List<SinglePageInfo> pages;
int get pageCount => pages.length;
MultiPageInfo({
this.pages,
});
factory MultiPageInfo.fromMap(Map map) {
final pages = <int, SinglePageInfo>{};
map.keys.forEach((key) {
final index = key as int;
pages.putIfAbsent(index, () => SinglePageInfo.fromMap(map[key]));
});
return MultiPageInfo(pages: pages);
}) {
if (pages.isNotEmpty) {
pages.sort();
// make sure there is a page marked as default
if (defaultPage == null) {
final firstPage = pages.removeAt(0);
pages.insert(0, firstPage.copyWith(isDefault: true));
}
}
}
factory MultiPageInfo.fromPageMaps(List<Map> pageMaps) {
return MultiPageInfo(pages: pageMaps.map((page) => SinglePageInfo.fromMap(page)).toList());
}
SinglePageInfo get defaultPage => pages.firstWhere((page) => page.isDefault, orElse: () => null);
SinglePageInfo getByIndex(int index) => pages.firstWhere((page) => page.index == index, orElse: () => null);
SinglePageInfo getById(int pageId) => pages.firstWhere((page) => page.pageId == pageId, orElse: () => null);
@override
String toString() => '$runtimeType#${shortHash(this)}{pages=$pages}';
}
class SinglePageInfo implements Comparable<SinglePageInfo> {
final int index, pageId;
final String mimeType;
final bool isDefault;
final int width, height, durationMillis;
const SinglePageInfo({
this.index,
this.pageId,
this.mimeType,
this.isDefault,
this.width,
this.height,
this.durationMillis,
});
SinglePageInfo copyWith({
bool isDefault,
}) {
return SinglePageInfo(
index: index,
pageId: pageId,
mimeType: mimeType,
isDefault: isDefault ?? this.isDefault,
width: width,
height: height,
durationMillis: durationMillis,
);
}
factory SinglePageInfo.fromMap(Map map) {
final index = map['page'] as int;
return SinglePageInfo(
index: index,
pageId: map['trackId'] as int ?? index,
mimeType: map['mimeType'] as String,
isDefault: map['isDefault'] as bool ?? false,
width: map['width'] as int ?? 0,
height: map['height'] as int ?? 0,
durationMillis: map['durationMillis'] as int,
);
}
@override
String toString() => '$runtimeType#${shortHash(this)}{index=$index, pageId=$pageId, mimeType=$mimeType, isDefault=$isDefault, width=$width, height=$height, durationMillis=$durationMillis}';
@override
int compareTo(SinglePageInfo other) => index.compareTo(other.index);
}

View file

@ -4,24 +4,38 @@ import 'package:flutter/widgets.dart';
class PanoramaInfo {
final Rect croppedAreaRect;
final Size fullPanoSize;
final String projectionType;
PanoramaInfo({
this.croppedAreaRect,
this.fullPanoSize,
this.projectionType,
});
factory PanoramaInfo.fromMap(Map map) {
final cLeft = map['croppedAreaLeft'] as int;
final cTop = map['croppedAreaTop'] as int;
var cLeft = map['croppedAreaLeft'] as int;
var cTop = map['croppedAreaTop'] as int;
final cWidth = map['croppedAreaWidth'] as int;
final cHeight = map['croppedAreaHeight'] as int;
var fWidth = map['fullPanoWidth'] as int;
var fHeight = map['fullPanoHeight'] as int;
final projectionType = map['projectionType'] as String;
// handle missing `fullPanoHeight` (e.g. Samsung camera app panorama mode)
if (fHeight == null && cWidth != null && cHeight != null) {
// assume the cropped area is actually covering 360 degrees horizontally
// even when `croppedAreaLeft` is non zero
fWidth = cWidth;
fHeight = (fWidth / 2).round();
cTop = ((fHeight - cHeight) / 2).round();
cLeft = 0;
}
Rect croppedAreaRect;
if (cLeft != null && cTop != null && cWidth != null && cHeight != null) {
croppedAreaRect = Rect.fromLTWH(cLeft.toDouble(), cTop.toDouble(), cWidth.toDouble(), cHeight.toDouble());
}
final fWidth = map['fullPanoWidth'] as int;
final fHeight = map['fullPanoHeight'] as int;
Size fullPanoSize;
if (fWidth != null && fHeight != null) {
fullPanoSize = Size(fWidth.toDouble(), fHeight.toDouble());
@ -30,11 +44,12 @@ class PanoramaInfo {
return PanoramaInfo(
croppedAreaRect: croppedAreaRect,
fullPanoSize: fullPanoSize,
projectionType: projectionType,
);
}
bool get hasCroppedArea => croppedAreaRect != null && fullPanoSize != null;
@override
String toString() => '$runtimeType#${shortHash(this)}{croppedAreaRect=$croppedAreaRect, fullPanoSize=$fullPanoSize}';
String toString() => '$runtimeType#${shortHash(this)}{croppedAreaRect=$croppedAreaRect, fullPanoSize=$fullPanoSize, projectionType=$projectionType}';
}

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/entry_background.dart';
import 'package:aves/model/settings/home_page.dart';
import 'package:aves/model/settings/map_style.dart';
import 'package:aves/model/settings/screen_on.dart';
import 'package:aves/widgets/viewer/info/location_section.dart';
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';

View file

@ -1,5 +1,5 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/utils/android_file_utils.dart';
@ -50,10 +50,10 @@ mixin AlbumMixin on SourceBase {
}
}
Map<String, ImageEntry> getAlbumEntries() {
Map<String, AvesEntry> getAlbumEntries() {
final entries = sortedEntriesForFilterList;
final regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[];
for (var album in sortedAlbums) {
for (final album in sortedAlbums) {
switch (androidFileUtils.getAlbumType(album)) {
case AlbumType.regular:
regularAlbums.add(album);

View file

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

View file

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

View file

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

View file

@ -1,7 +1,8 @@
import 'dart:async';
import 'dart:math';
import 'package:aves/model/entry.dart';
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
@ -13,6 +14,12 @@ import 'package:flutter_native_timezone/flutter_native_timezone.dart';
import 'package:pedantic/pedantic.dart';
class MediaStoreSource extends CollectionSource {
bool _initialized = false;
@override
bool get initialized => _initialized;
@override
Future<void> init() async {
final stopwatch = Stopwatch()..start();
stateNotifier.value = SourceState.loading;
@ -28,11 +35,13 @@ class MediaStoreSource extends CollectionSource {
settings.catalogTimeZone = currentTimeZone;
}
await loadDates(); // 100ms for 5400 entries
_initialized = true;
debugPrint('$runtimeType init done, elapsed=${stopwatch.elapsed}');
}
@override
Future<void> refresh() async {
assert(_initialized);
debugPrint('$runtimeType refresh start');
final stopwatch = Stopwatch()..start();
stateNotifier.value = SourceState.loading;
@ -40,8 +49,8 @@ class MediaStoreSource extends CollectionSource {
final oldEntries = await metadataDb.loadEntries(); // 400ms for 5500 entries
final knownEntryMap = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId, entry.dateModifiedSecs)));
final obsoleteEntries = (await ImageFileService.getObsoleteEntries(knownEntryMap.keys.toList())).toSet();
oldEntries.removeWhere((entry) => obsoleteEntries.contains(entry.contentId));
final obsoleteContentIds = (await ImageFileService.getObsoleteEntries(knownEntryMap.keys.toList())).toSet();
oldEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId));
// show known entries
addAll(oldEntries);
@ -50,19 +59,20 @@ class MediaStoreSource extends CollectionSource {
debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}');
// clean up obsolete entries
metadataDb.removeIds(obsoleteEntries, updateFavourites: true);
metadataDb.removeIds(obsoleteContentIds, updateFavourites: true);
// fetch new entries
// refresh after the first 10 entries, then after 100 more, then every 1000 entries
var refreshCount = 10;
const refreshCountMax = 1000;
final allNewEntries = <ImageEntry>[], pendingNewEntries = <ImageEntry>[];
final allNewEntries = <AvesEntry>[], pendingNewEntries = <AvesEntry>[];
void addPendingEntries() {
allNewEntries.addAll(pendingNewEntries);
addAll(pendingNewEntries);
pendingNewEntries.clear();
}
ImageFileService.getImageEntries(knownEntryMap).listen(
ImageFileService.getEntries(knownEntryMap).listen(
(entry) {
pendingNewEntries.add(entry);
if (pendingNewEntries.length >= refreshCount) {
@ -95,8 +105,48 @@ class MediaStoreSource extends CollectionSource {
);
}
Future<void> refreshUris(List<String> changedUris) async {
if (!_initialized) return;
final uriByContentId = Map.fromEntries(changedUris.map((uri) {
if (uri == null) return null;
final idString = Uri.parse(uri).pathSegments.last;
final contentId = int.tryParse(idString);
if (contentId == null) return null;
return MapEntry(contentId, uri);
}).where((kv) => kv != null));
// clean up obsolete entries
final obsoleteContentIds = (await ImageFileService.getObsoleteEntries(uriByContentId.keys.toList())).toSet();
uriByContentId.removeWhere((contentId, _) => obsoleteContentIds.contains(contentId));
metadataDb.removeIds(obsoleteContentIds, updateFavourites: true);
// add new entries
final newEntries = <AvesEntry>[];
for (final kv in uriByContentId.entries) {
final contentId = kv.key;
final uri = kv.value;
final sourceEntry = await ImageFileService.getEntry(uri, null);
final existingEntry = rawEntries.firstWhere((entry) => entry.contentId == contentId, orElse: () => null);
if (existingEntry == null || sourceEntry.dateModifiedSecs > existingEntry.dateModifiedSecs) {
newEntries.add(sourceEntry);
}
}
addAll(newEntries);
await metadataDb.saveEntries(newEntries);
updateAlbums();
stateNotifier.value = SourceState.cataloguing;
await catalogEntries();
stateNotifier.value = SourceState.locating;
await locateEntries();
stateNotifier.value = SourceState.ready;
}
@override
Future<void> refreshMetadata(Set<ImageEntry> entries) {
Future<void> refreshMetadata(Set<AvesEntry> entries) {
final contentIds = entries.map((entry) => entry.contentId).toSet();
metadataDb.removeIds(contentIds, updateFavourites: false);
return refresh();

View file

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

View file

@ -1,6 +1,7 @@
import 'dart:typed_data';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
@ -8,12 +9,18 @@ import 'package:flutter/services.dart';
class AndroidAppService {
static const platform = MethodChannel('deckers.thibault/aves/app');
static Future<Map> getAppNames() async {
static Future<Set<Package>> getPackages() async {
try {
final result = await platform.invokeMethod('getAppNames');
return result as Map;
final result = await platform.invokeMethod('getPackages');
final packages = (result as List).cast<Map>().map((map) => Package.fromMap(map)).toSet();
// additional info for known directories
final kakaoTalk = packages.firstWhere((package) => package.packageName == 'com.kakao.talk', orElse: () => null);
if (kakaoTalk != null) {
kakaoTalk.ownedDirs.add('KakaoTalkDownload');
}
return packages;
} on PlatformException catch (e) {
debugPrint('getAppNames failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
debugPrint('getPackages failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
}
return {};
}
@ -81,10 +88,10 @@ class AndroidAppService {
return false;
}
static Future<bool> shareEntries(Iterable<ImageEntry> entries) async {
static Future<bool> shareEntries(Iterable<AvesEntry> entries) async {
// loosen mime type to a generic one, so we can share with badly defined apps
// e.g. Google Lens declares receiving "image/jpeg" only, but it can actually handle more formats
final urisByMimeType = groupBy<ImageEntry, String>(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList()));
final urisByMimeType = groupBy<AvesEntry, String>(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList()));
try {
return await platform.invokeMethod('share', <String, dynamic>{
'title': 'Share via:',

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

View file

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

View file

@ -1,7 +1,7 @@
import 'dart:typed_data';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
@ -26,18 +26,18 @@ class AppShortcutService {
return false;
}
static Future<void> pin(String label, ImageEntry iconEntry, Set<CollectionFilter> filters) async {
static Future<void> pin(String label, AvesEntry entry, Set<CollectionFilter> filters) async {
Uint8List iconBytes;
if (iconEntry != null) {
final size = iconEntry.isVideo ? 0.0 : 256.0;
if (entry != null) {
final size = entry.isVideo ? 0.0 : 256.0;
iconBytes = await ImageFileService.getThumbnail(
iconEntry.uri,
iconEntry.mimeType,
iconEntry.dateModifiedSecs,
iconEntry.rotationDegrees,
iconEntry.isFlipped,
size,
size,
uri: entry.uri,
mimeType: entry.mimeType,
pageId: entry.pageId,
rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
dateModifiedSecs: entry.dateModifiedSecs,
extent: size,
);
}
try {

View file

@ -3,12 +3,12 @@ import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/service_policy.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:streams_channel/streams_channel.dart';
class ImageFileService {
@ -18,10 +18,11 @@ class ImageFileService {
static final StreamsChannel opChannel = StreamsChannel('deckers.thibault/aves/imageopstream');
static const double thumbnailDefaultSize = 64.0;
static Map<String, dynamic> _toPlatformEntryMap(ImageEntry entry) {
static Map<String, dynamic> _toPlatformEntryMap(AvesEntry entry) {
return {
'uri': entry.uri,
'path': entry.path,
'pageId': entry.pageId,
'mimeType': entry.mimeType,
'width': entry.width,
'height': entry.height,
@ -32,13 +33,13 @@ class ImageFileService {
}
// knownEntries: map of contentId -> dateModifiedSecs
static Stream<ImageEntry> getImageEntries(Map<int, int> knownEntries) {
static Stream<AvesEntry> getEntries(Map<int, int> knownEntries) {
try {
return mediaStoreChannel.receiveBroadcastStream(<String, dynamic>{
'knownEntries': knownEntries,
}).map((event) => ImageEntry.fromMap(event));
}).map((event) => AvesEntry.fromMap(event));
} on PlatformException catch (e) {
debugPrint('getImageEntries failed with code=${e.code}, exception=${e.message}, details=${e.details}');
debugPrint('getEntries failed with code=${e.code}, exception=${e.message}, details=${e.details}');
return Stream.error(e);
}
}
@ -55,26 +56,40 @@ class ImageFileService {
return [];
}
static Future<ImageEntry> getImageEntry(String uri, String mimeType) async {
debugPrint('getImageEntry for uri=$uri, mimeType=$mimeType');
static Future<AvesEntry> getEntry(String uri, String mimeType) async {
try {
final result = await platform.invokeMethod('getImageEntry', <String, dynamic>{
final result = await platform.invokeMethod('getEntry', <String, dynamic>{
'uri': uri,
'mimeType': mimeType,
}) as Map;
return ImageEntry.fromMap(result);
return AvesEntry.fromMap(result);
} on PlatformException catch (e) {
debugPrint('getImageEntry failed with code=${e.code}, exception=${e.message}, details=${e.details}');
debugPrint('getEntry failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return null;
}
static Future<Uint8List> getSvg(
String uri,
String mimeType, {
int expectedContentLength,
BytesReceivedCallback onBytesReceived,
}) =>
getImage(
uri,
mimeType,
0,
false,
expectedContentLength: expectedContentLength,
onBytesReceived: onBytesReceived,
);
static Future<Uint8List> getImage(
String uri,
String mimeType,
int rotationDegrees,
bool isFlipped, {
int page = 0,
int pageId,
int expectedContentLength,
BytesReceivedCallback onBytesReceived,
}) {
@ -87,7 +102,7 @@ class ImageFileService {
'mimeType': mimeType,
'rotationDegrees': rotationDegrees ?? 0,
'isFlipped': isFlipped ?? false,
'page': page ?? 0,
'pageId': pageId,
}).listen(
(data) {
final chunk = data as Uint8List;
@ -125,7 +140,7 @@ class ImageFileService {
int sampleSize,
Rectangle<int> regionRect,
Size imageSize, {
int page = 0,
int pageId,
Object taskKey,
int priority,
}) {
@ -135,7 +150,7 @@ class ImageFileService {
final result = await platform.invokeMethod('getRegion', <String, dynamic>{
'uri': uri,
'mimeType': mimeType,
'page': page,
'pageId': pageId,
'sampleSize': sampleSize,
'regionX': regionRect.left,
'regionY': regionRect.top,
@ -155,15 +170,14 @@ class ImageFileService {
);
}
static Future<Uint8List> getThumbnail(
String uri,
String mimeType,
int dateModifiedSecs,
int rotationDegrees,
bool isFlipped,
double width,
double height, {
int page,
static Future<Uint8List> getThumbnail({
@required String uri,
@required String mimeType,
@required int rotationDegrees,
@required int pageId,
@required bool isFlipped,
@required int dateModifiedSecs,
@required double extent,
Object taskKey,
int priority,
}) {
@ -179,9 +193,9 @@ class ImageFileService {
'dateModifiedSecs': dateModifiedSecs,
'rotationDegrees': rotationDegrees,
'isFlipped': isFlipped,
'widthDip': width,
'heightDip': height,
'page': page,
'widthDip': extent,
'heightDip': extent,
'pageId': pageId,
'defaultSizeDip': thumbnailDefaultSize,
});
return result as Uint8List;
@ -191,7 +205,7 @@ class ImageFileService {
return null;
},
// debugLabel: 'getThumbnail width=$width, height=$height entry=${entry.filenameWithoutExtension}',
priority: priority ?? (width == 0 || height == 0 ? ServiceCallPriority.getFastThumbnail : ServiceCallPriority.getSizedThumbnail),
priority: priority ?? (extent == 0 ? ServiceCallPriority.getFastThumbnail : ServiceCallPriority.getSizedThumbnail),
key: taskKey,
);
}
@ -210,7 +224,7 @@ class ImageFileService {
static Future<T> resumeLoading<T>(Object taskKey) => servicePolicy.resume<T>(taskKey);
static Stream<ImageOpEvent> delete(Iterable<ImageEntry> entries) {
static Stream<ImageOpEvent> delete(Iterable<AvesEntry> entries) {
try {
return opChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'delete',
@ -222,7 +236,11 @@ class ImageFileService {
}
}
static Stream<MoveOpEvent> move(Iterable<ImageEntry> entries, {@required bool copy, @required String destinationAlbum}) {
static Stream<MoveOpEvent> move(
Iterable<AvesEntry> entries, {
@required bool copy,
@required String destinationAlbum,
}) {
try {
return opChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'move',
@ -236,7 +254,25 @@ class ImageFileService {
}
}
static Future<Map> rename(ImageEntry entry, String newName) async {
static Stream<ExportOpEvent> export(
Iterable<AvesEntry> entries, {
String mimeType = MimeTypes.jpeg,
@required String destinationAlbum,
}) {
try {
return opChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'export',
'entries': entries.map(_toPlatformEntryMap).toList(),
'mimeType': mimeType,
'destinationPath': destinationAlbum,
}).map((event) => ExportOpEvent.fromMap(event));
} on PlatformException catch (e) {
debugPrint('export failed with code=${e.code}, exception=${e.message}, details=${e.details}');
return Stream.error(e);
}
}
static Future<Map> rename(AvesEntry entry, String newName) async {
try {
// return map with: 'contentId' 'path' 'title' 'uri' (all optional)
final result = await platform.invokeMethod('rename', <String, dynamic>{
@ -250,7 +286,7 @@ class ImageFileService {
return {};
}
static Future<Map> rotate(ImageEntry entry, {@required bool clockwise}) async {
static Future<Map> rotate(AvesEntry entry, {@required bool clockwise}) async {
try {
// return map with: 'rotationDegrees' 'isFlipped'
final result = await platform.invokeMethod('rotate', <String, dynamic>{
@ -264,7 +300,7 @@ class ImageFileService {
return {};
}
static Future<Map> flip(ImageEntry entry) async {
static Future<Map> flip(AvesEntry entry) async {
try {
// return map with: 'rotationDegrees' 'isFlipped'
final result = await platform.invokeMethod('flip', <String, dynamic>{
@ -278,57 +314,6 @@ class ImageFileService {
}
}
@immutable
class ImageOpEvent {
final bool success;
final String uri;
const ImageOpEvent({
this.success,
this.uri,
});
factory ImageOpEvent.fromMap(Map map) {
return ImageOpEvent(
success: map['success'] ?? false,
uri: map['uri'],
);
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is ImageOpEvent && other.success == success && other.uri == uri;
}
@override
int get hashCode => hashValues(success, uri);
@override
String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri}';
}
class MoveOpEvent extends ImageOpEvent {
final Map newFields;
const MoveOpEvent({bool success, String uri, this.newFields})
: super(
success: success,
uri: uri,
);
factory MoveOpEvent.fromMap(Map map) {
return MoveOpEvent(
success: map['success'] ?? false,
uri: map['uri'],
newFields: map['newFields'],
);
}
@override
String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri, newFields=$newFields}';
}
// cf flutter/foundation `consolidateHttpClientResponseBytes`
typedef BytesReceivedCallback = void Function(int cumulative, int total);

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

View file

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

View file

@ -48,4 +48,11 @@ class Durations {
static const doubleBackTimerDelay = Duration(milliseconds: 1000);
static const softKeyboardDisplayDelay = Duration(milliseconds: 300);
static const searchDebounceDelay = Duration(milliseconds: 250);
// Content change monitoring delay should be large enough,
// so that querying the Media Store yields final entries.
// For example, when taking a picture with a Galaxy S10e default camera app,
// querying the Media Store just 1 second after sometimes yields an entry with
// its temporary path: `/data/sec/camera/!@#$%^..._temp.jpg`
static const contentChangeDebounceDelay = Duration(milliseconds: 1500);
}

View file

@ -31,6 +31,7 @@ class AIcons {
static const IconData createAlbum = Icons.add_circle_outline;
static const IconData debug = Icons.whatshot_outlined;
static const IconData delete = Icons.delete_outlined;
static const IconData export = Icons.save_alt_outlined;
static const IconData flip = Icons.flip_outlined;
static const IconData favourite = Icons.favorite_border;
static const IconData favouriteActive = Icons.favorite;
@ -38,7 +39,7 @@ class AIcons {
static const IconData group = Icons.group_work_outlined;
static const IconData info = Icons.info_outlined;
static const IconData layers = Icons.layers_outlined;
static const IconData openInNew = Icons.open_in_new_outlined;
static const IconData openOutside = Icons.open_in_new_outlined;
static const IconData pin = Icons.push_pin_outlined;
static const IconData print = Icons.print_outlined;
static const IconData refresh = Icons.refresh_outlined;

View file

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

View file

@ -29,14 +29,14 @@ class Constants {
Dependency(
name: 'AndroidX Core-KTX',
license: 'Apache 2.0',
licenseUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/LICENSE.txt',
sourceUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/core/core-ktx',
licenseUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt',
sourceUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/core/core-ktx',
),
Dependency(
name: 'AndroidX Exifinterface',
license: 'Apache 2.0',
licenseUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/LICENSE.txt',
sourceUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/exifinterface/exifinterface',
licenseUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt',
sourceUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/exifinterface/exifinterface',
),
Dependency(
name: 'Android-TiffBitmapFactory',
@ -83,18 +83,18 @@ class Constants {
licenseUrl: 'https://github.com/dart-lang/collection/blob/master/LICENSE',
sourceUrl: 'https://github.com/dart-lang/collection',
),
Dependency(
name: 'Connectivity',
license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/connectivity/connectivity/LICENSE',
sourceUrl: 'https://github.com/flutter/plugins/blob/master/packages/connectivity/connectivity',
),
Dependency(
name: 'Decorated Icon',
license: 'MIT',
licenseUrl: 'https://github.com/benPesso/flutter_decorated_icon/blob/master/LICENSE',
sourceUrl: 'https://github.com/benPesso/flutter_decorated_icon',
),
Dependency(
name: 'Draggable Scrollbar',
license: 'MIT',
licenseUrl: 'https://github.com/fluttercommunity/flutter-draggable-scrollbar/blob/master/LICENSE',
sourceUrl: 'https://github.com/fluttercommunity/flutter-draggable-scrollbar',
),
Dependency(
name: 'Event Bus',
license: 'MIT',

View file

@ -1,7 +1,7 @@
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/collection/thumbnail_collection.dart';
import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/behaviour/double_back_pop.dart';
import 'package:aves/widgets/common/gesture_area_protector.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/drawer/app_drawer.dart';
import 'package:flutter/foundation.dart';

View file

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

View file

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

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

View file

@ -1,9 +1,10 @@
import 'dart:async';
import 'dart:math';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:aves/widgets/common/grid/section_layout.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
@ -38,7 +39,7 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
CollectionLens get collection => widget.collection;
List<ImageEntry> get entries => collection.sortedEntries;
List<AvesEntry> get entries => collection.sortedEntries;
ScrollController get scrollController => widget.scrollController;
@ -62,7 +63,7 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
_lastToIndex = _fromIndex;
_scrollableInsets = EdgeInsets.only(
top: appBarHeight,
bottom: context.read<MediaQueryData>().viewInsets.bottom,
bottom: context.read<MediaQueryData>().effectiveBottomPadding,
);
_scrollSpeedFactor = 0;
_pressing = true;
@ -130,12 +131,12 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
}
}
ImageEntry _getEntryAt(Offset localPosition) {
AvesEntry _getEntryAt(Offset localPosition) {
// as of Flutter v1.22.5, `hitTest` on the `ScrollView` render object works fine when it is static,
// but when it is scrolling (through controller animation), result is incomplete and children are missing,
// so we use custom layout computation instead to find the entry.
final offset = Offset(0, scrollController.offset - appBarHeight) + localPosition;
final sectionedListLayout = context.read<SectionedListLayout<ImageEntry>>();
final sectionedListLayout = context.read<SectionedListLayout<AvesEntry>>();
return sectionedListLayout.getItemAt(offset);
}

View file

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

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

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

View file

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

View file

@ -1,17 +1,14 @@
import 'dart:math';
import 'package:aves/image_providers/thumbnail_provider.dart';
import 'package:aves/image_providers/uri_image_provider.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_images.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/collection/thumbnail/error.dart';
import 'package:aves/widgets/common/fx/transition_image.dart';
import 'package:flutter/material.dart';
class RasterImageThumbnail extends StatefulWidget {
final ImageEntry entry;
final AvesEntry entry;
final double extent;
final int page;
final ValueNotifier<bool> isScrollingNotifier;
final Object heroTag;
@ -19,7 +16,6 @@ class RasterImageThumbnail extends StatefulWidget {
Key key,
@required this.entry,
@required this.extent,
this.page = 0,
this.isScrollingNotifier,
this.heroTag,
}) : super(key: key);
@ -31,19 +27,12 @@ class RasterImageThumbnail extends StatefulWidget {
class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
ThumbnailProvider _fastThumbnailProvider, _sizedThumbnailProvider;
ImageEntry get entry => widget.entry;
int get page => widget.page;
AvesEntry get entry => widget.entry;
double get extent => widget.extent;
Object get heroTag => widget.heroTag;
// we standardize the thumbnail loading dimension by taking the nearest larger power of 2
// so that there are less variants of the thumbnails to load and cache
// it increases the chance of cache hit when loading similarly sized columns (e.g. on orientation change)
double get requestExtent => pow(2, (log(extent) / log(2)).ceil()).toDouble();
@override
void initState() {
super.initState();
@ -78,14 +67,8 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
void _initProvider() {
if (!entry.canDecode) return;
_fastThumbnailProvider = ThumbnailProvider(
ThumbnailProviderKey.fromEntry(entry, page: page),
);
if (!entry.isVideo) {
_sizedThumbnailProvider = ThumbnailProvider(
ThumbnailProviderKey.fromEntry(entry, page: page, extent: requestExtent),
);
}
_fastThumbnailProvider = entry.getThumbnail();
_sizedThumbnailProvider = entry.getThumbnail(extent: extent);
}
void _pauseProvider() {
@ -148,22 +131,8 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
: Hero(
tag: heroTag,
flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) {
ImageProvider heroImageProvider = _fastThumbnailProvider;
if (!entry.isVideo) {
final imageProvider = UriImage(
uri: entry.uri,
mimeType: entry.mimeType,
page: page,
rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
expectedContentLength: entry.sizeBytes,
);
if (imageCache.statusForKey(imageProvider).keepAlive) {
heroImageProvider = imageProvider;
}
}
return TransitionImage(
image: heroImageProvider,
image: entry.getBestThumbnail(extent),
animation: animation,
);
},

View file

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

View file

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

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:flushbar/flushbar.dart';
import 'package:flutter/material.dart';
@ -27,19 +25,66 @@ mixin FeedbackMixin {
// report overlay for multiple operations
OverlayEntry _opReportOverlayEntry;
void showOpReport<T extends ImageOpEvent>({
void showOpReport<T>({
@required BuildContext context,
@required Set<ImageEntry> selection,
@required Stream<T> opStream,
@required void Function(Set<T> processed) onDone,
@required int itemCount,
void Function(Set<T> processed) onDone,
}) {
OverlayEntry _opReportOverlayEntry;
_opReportOverlayEntry = OverlayEntry(
builder: (context) => ReportOverlay<T>(
opStream: opStream,
itemCount: itemCount,
onDone: (processed) {
_opReportOverlayEntry.remove();
onDone?.call(processed);
},
),
);
Overlay.of(context).insert(_opReportOverlayEntry);
}
}
class ReportOverlay<T> extends StatefulWidget {
final Stream<T> opStream;
final int itemCount;
final void Function(Set<T> processed) onDone;
const ReportOverlay({
@required this.opStream,
@required this.itemCount,
@required this.onDone,
});
@override
_ReportOverlayState createState() => _ReportOverlayState<T>();
}
class _ReportOverlayState<T> extends State<ReportOverlay<T>> with SingleTickerProviderStateMixin {
final processed = <T>{};
AnimationController _animationController;
Animation<double> _animation;
Stream<T> get opStream => widget.opStream;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: Durations.collectionOpOverlayAnimation,
vsync: this,
);
_animation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutQuad,
);
_animationController.forward();
// do not handle completion inside `StreamBuilder`
// as it could be called multiple times
Future<void> onComplete() => _hideOpReportOverlay().then((_) => onDone(processed));
Future<void> onComplete() => _animationController.reverse().then((_) => widget.onDone(processed));
opStream.listen(
processed.add,
onError: (error) {
@ -48,17 +93,34 @@ mixin FeedbackMixin {
},
onDone: onComplete,
);
}
_opReportOverlayEntry = OverlayEntry(
builder: (context) {
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AbsorbPointer(
child: StreamBuilder<T>(
stream: opStream,
builder: (context, snapshot) {
Widget child = SizedBox.shrink();
if (!snapshot.hasError) {
final percent = processed.length.toDouble() / selection.length;
child = CircularPercentIndicator(
final percent = processed.length.toDouble() / widget.itemCount;
return FadeTransition(
opacity: _animation,
child: Container(
decoration: BoxDecoration(
gradient: RadialGradient(
colors: [
Colors.black,
Colors.black54,
],
),
),
child: Center(
child: CircularPercentIndicator(
percent: percent,
lineWidth: 16,
radius: 160,
@ -67,22 +129,11 @@ mixin FeedbackMixin {
animation: true,
center: Text(NumberFormat.percentPattern().format(percent)),
animateFromLastPercent: true,
);
}
return AnimatedSwitcher(
duration: Durations.collectionOpOverlayAnimation,
child: child,
),
),
),
);
}),
);
},
);
Overlay.of(context).insert(_opReportOverlayEntry);
}
Future<void> _hideOpReportOverlay() async {
await Future.delayed(Durations.collectionOpOverlayAnimation * timeDilation);
_opReportOverlayEntry.remove();
_opReportOverlayEntry = null;
}
}

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/widgets/dialogs/aves_dialog.dart';
import 'package:flutter/material.dart';
mixin PermissionAwareMixin {
Future<bool> checkStoragePermission(BuildContext context, Set<ImageEntry> entries) {
Future<bool> checkStoragePermission(BuildContext context, Set<AvesEntry> entries) {
return checkStoragePermissionForAlbums(context, entries.where((e) => e.path != null).map((e) => e.directory).toSet());
}

View file

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

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),
Builder(
builder: (context) => Icon(
AIcons.openInNew,
AIcons.openOutside,
size: DefaultTextStyle.of(context).style.fontSize,
color: color,
),

View file

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

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