Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2020-11-15 00:04:03 +09:00
commit f678587d6f
78 changed files with 2414 additions and 841 deletions

View file

@ -15,7 +15,7 @@ jobs:
- uses: subosito/flutter-action@v1
with:
channel: stable
flutter-version: '1.22.3'
flutter-version: '1.22.4'
- 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.3'
flutter-version: '1.22.4'
# 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.3.sksl.json
flutter build appbundle --bundle-sksl-path shaders_1.22.3.sksl.json
flutter build apk --bundle-sksl-path shaders_1.22.4.sksl.json
flutter build appbundle --bundle-sksl-path shaders_1.22.4.sksl.json
rm $AVES_STORE_FILE
env:
AVES_STORE_FILE: ${{ github.workspace }}/key.jks

41
CHANGELOG.md Normal file
View file

@ -0,0 +1,41 @@
# Changelog
All notable changes to this project will be documented in this file.
## [Unreleased]
## [v1.2.6] - 2020-11-15
### Added
- Support for TIFF images (single page)
- Viewer overlay: minimap (optional)
### Changed
- Upgraded Flutter to stable v1.22.4
- Viewer: use subsampling and tiling to display large images
### Fixed
- Fixed finding dimensions of entries with incorrect EXIF
## [v1.2.5] - 2020-11-01
### Added
- Search: show recently used filters (optional)
- Search: show filter for entries with no XMP tags
- Search: show filter for entries with no location information
- Analytics: use Firebase Analytics (along Firebase Crashlytics)
### Changed
- Upgraded Flutter to stable v1.22.3
- Viewer overlay: showing shooting details is now optional
### Fixed
- Viewer: leave when the loaded entry is deleted and it is the last one
- Viewer: refresh the viewer overlay and info page when the loaded image is modified
- Info: prevent reporting a "Media" section for images other than HEIC/HEIF
- Fixed opening entries shared via a "file" media content URI
### Removed
- Dependencies: removed Guava as a direct dependency in Android
## [v1.2.4] - 2020-11-01 [YANKED]
## [v1.2.3] - 2020-10-22
...

View file

@ -88,9 +88,8 @@ flutter {
}
repositories {
maven {
url "https://s3.amazonaws.com/repo.commonsware.com"
}
maven { url 'https://jitpack.io' }
maven { url "https://s3.amazonaws.com/repo.commonsware.com" }
}
dependencies {
@ -99,6 +98,11 @@ dependencies {
implementation 'androidx.exifinterface:exifinterface:1.3.1'
implementation 'com.commonsware.cwac:document:0.4.1'
implementation 'com.drewnoakes:metadata-extractor:2.15.0'
// as of v0.9.8.7, `Android-TiffBitmapFactory` master branch is set up to release and distribute via Bintray
// as of 20201113, its `q_support` branch allows decoding TIFF without a `File`, but is not released
// we forked it to bypass official releases, upgrading its Android/Gradle structure to make it compatible with JitPack
// JitPack build result is available at https://jitpack.io/com/github/deckerst/Android-TiffBitmapFactory/<commit>/build.log
implementation 'com.github.deckerst:Android-TiffBitmapFactory:7efb450636'
implementation 'com.github.bumptech.glide:glide:4.11.0'
kapt 'androidx.annotation:annotation:1.1.0'

View file

@ -45,7 +45,6 @@
</queries>
<application
android:name="io.flutter.app.FlutterApplication"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
@ -55,11 +54,16 @@
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:theme="@style/AppTheme"
android:theme="@style/NormalTheme"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.app.android.SplashScreenUntilFirstFrame"
android:value="false" />
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@ -105,11 +109,11 @@
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="${googleApiKey}" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="firebase_crashlytics_collection_enabled"
android:value="false" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />

View file

@ -6,20 +6,19 @@ import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Bitmap
import android.net.Uri
import android.util.Log
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.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils.createTag
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.*
import kotlin.collections.ArrayList
@ -134,12 +133,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
.submit(size, size)
try {
val bitmap = target.get()
if (bitmap != null) {
val stream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 0, stream)
data = stream.toByteArray()
}
data = target.get()?.getBytes(canHaveAlpha = true, recycle = false)
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to decode app icon for packageName=$packageName", e)
}

View file

@ -1,7 +1,9 @@
package deckers.thibault.aves.channel.calls
import android.app.Activity
import android.graphics.Rect
import android.net.Uri
import android.util.Size
import com.bumptech.glide.Glide
import deckers.thibault.aves.model.ExifOrientationOp
import deckers.thibault.aves.model.provider.FieldMap
@ -19,11 +21,14 @@ import kotlin.math.roundToInt
class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
private val density = activity.resources.displayMetrics.density
private val regionFetcher = RegionFetcher(activity)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getObsoleteEntries" -> GlobalScope.launch { getObsoleteEntries(call, Coresult(result)) }
"getImageEntry" -> GlobalScope.launch { getImageEntry(call, Coresult(result)) }
"getThumbnail" -> GlobalScope.launch { getThumbnail(call, Coresult(result)) }
"getRegion" -> GlobalScope.launch { getRegion(call, Coresult(result)) }
"clearSizedThumbnailDiskCache" -> {
GlobalScope.launch { Glide.get(activity).clearDiskCache() }
result.success(null)
@ -53,26 +58,51 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
val widthDip = call.argument<Double>("widthDip")
val heightDip = call.argument<Double>("heightDip")
val defaultSizeDip = call.argument<Double>("defaultSizeDip")
if (uri == null || mimeType == null || dateModifiedSecs == null || rotationDegrees == null || isFlipped == null || widthDip == null || heightDip == null || defaultSizeDip == null) {
result.error("getThumbnail-args", "failed because of missing arguments", null)
return
}
// convert DIP to physical pixels here, instead of using `devicePixelRatio` in Flutter
GlobalScope.launch {
ThumbnailFetcher(
activity,
uri,
mimeType,
dateModifiedSecs,
rotationDegrees,
isFlipped,
width = (widthDip * density).roundToInt(),
height = (heightDip * density).roundToInt(),
defaultSize = (defaultSizeDip * density).roundToInt(),
Coresult(result),
).fetch()
ThumbnailFetcher(
activity,
uri,
mimeType,
dateModifiedSecs,
rotationDegrees,
isFlipped,
width = (widthDip * density).roundToInt(),
height = (heightDip * density).roundToInt(),
defaultSize = (defaultSizeDip * density).roundToInt(),
result,
).fetch()
}
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 sampleSize = call.argument<Int>("sampleSize")
val x = call.argument<Int>("regionX")
val y = call.argument<Int>("regionY")
val width = call.argument<Int>("regionWidth")
val height = call.argument<Int>("regionHeight")
val imageWidth = call.argument<Int>("imageWidth")
val imageHeight = call.argument<Int>("imageHeight")
if (uri == null || mimeType == null || sampleSize == null || x == null || y == null || width == null || height == null || imageWidth == null || imageHeight == null) {
result.error("getRegion-args", "failed because of missing arguments", null)
return
}
regionFetcher.fetch(
uri,
mimeType,
sampleSize,
Rect(x, y, x + width, y + height),
Size(imageWidth, imageHeight),
result,
)
}
private suspend fun getImageEntry(call: MethodCall, result: MethodChannel.Result) {

View file

@ -4,7 +4,7 @@ import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
@ -28,7 +28,9 @@ import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.metadata.ExifInterfaceHelper
import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDouble
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeRational
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDescription
@ -38,26 +40,27 @@ import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
import deckers.thibault.aves.metadata.Metadata.isFlippedForExifCode
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeBoolean
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDescription
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeRational
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
import deckers.thibault.aves.metadata.XMP
import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText
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.isImage
import deckers.thibault.aves.utils.MimeTypes.isMultimedia
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.tiffExtensionPattern
import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.util.*
import kotlin.math.roundToLong
@ -70,6 +73,8 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
"getContentResolverMetadata" -> GlobalScope.launch { getContentResolverMetadata(call, Coresult(result)) }
"getExifInterfaceMetadata" -> GlobalScope.launch { getExifInterfaceMetadata(call, Coresult(result)) }
"getMediaMetadataRetrieverMetadata" -> GlobalScope.launch { getMediaMetadataRetrieverMetadata(call, Coresult(result)) }
"getBitmapFactoryInfo" -> GlobalScope.launch { getBitmapFactoryInfo(call, Coresult(result)) }
"getMetadataExtractorSummary" -> GlobalScope.launch { getMetadataExtractorSummary(call, Coresult(result)) }
"getEmbeddedPictures" -> GlobalScope.launch { getEmbeddedPictures(call, Coresult(result)) }
"getExifThumbnails" -> GlobalScope.launch { getExifThumbnails(call, Coresult(result)) }
"getXmpThumbnails" -> GlobalScope.launch { getXmpThumbnails(call, Coresult(result)) }
@ -182,12 +187,13 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val path = call.argument<String>("path")
if (mimeType == null || uri == null) {
result.error("getCatalogMetadata-args", "failed because of missing arguments", null)
return
}
val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType))
val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType, path))
if (isVideo(mimeType)) {
metadataMap.putAll(getVideoCatalogMetadataByMediaMetadataRetriever(uri))
}
@ -196,7 +202,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
result.success(metadataMap)
}
private fun getCatalogMetadataByMetadataExtractor(uri: Uri, mimeType: String): Map<String, Any> {
private fun getCatalogMetadataByMetadataExtractor(uri: Uri, mimeType: String, path: String?): Map<String, Any> {
val metadataMap = HashMap<String, Any>()
var foundExif = false
@ -209,14 +215,17 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
// File type
for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) {
// `metadata-extractor` sometimes detect the the wrong mime type (e.g. `pef` file as `tiff`)
// the content resolver / media store sometimes report the wrong mime type (e.g. `png` file as `jpeg`)
// `context.getContentResolver().getType()` sometimes return incorrect value
// `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000`
// file extension is unreliable
// in the end, `metadata-extractor` is the most reliable, unless it reports `tiff`
dir.getSafeString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) {
if (it != MimeTypes.TIFF) {
// * `metadata-extractor` sometimes detect the the wrong mime type (e.g. `pef` file as `tiff`)
// * the content resolver / media store sometimes report the wrong mime type (e.g. `png` file as `jpeg`, `tiff` as `srw`)
// * `context.getContentResolver().getType()` sometimes return incorrect value
// * `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000`
// * file extension is unreliable
// In the end, `metadata-extractor` is the most reliable, except for `tiff` (false positives, false negatives),
// in which case we trust the file extension
if (path?.matches(tiffExtensionPattern) == true) {
metadataMap[KEY_MIME_TYPE] = MimeTypes.TIFF
} else {
dir.getSafeString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) {
metadataMap[KEY_MIME_TYPE] = it
}
}
@ -351,37 +360,62 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
val metadataMap = HashMap<String, Any>()
if (isVideo(mimeType) || !isSupportedByMetadataExtractor(mimeType)) {
if (isVideo(mimeType)) {
result.success(metadataMap)
return
}
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
dir.getSafeDescription(ExifSubIFDDirectory.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it }
dir.getSafeDescription(ExifSubIFDDirectory.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it }
dir.getSafeDescription(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT) { metadataMap[KEY_ISO] = "ISO$it" }
dir.getSafeRational(ExifSubIFDDirectory.TAG_EXPOSURE_TIME) {
// TAG_EXPOSURE_TIME as a string is sometimes a ratio, sometimes a decimal
// so we explicitly request it as a rational (e.g. 1/100, 1/14, 71428571/1000000000, 4000/1000, 2000000000/500000000)
// and process it to make sure the numerator is `1` when the ratio value is less than 1
val num = it.numerator
val denom = it.denominator
metadataMap[KEY_EXPOSURE_TIME] = when {
num >= denom -> "${it.toSimpleString(true)}"
num != 1L && num != 0L -> Rational(1, (denom / num.toDouble()).roundToLong()).toString()
else -> it.toString()
}
val saveExposureTime: (value: Rational) -> Unit = {
// `TAG_EXPOSURE_TIME` as a string is sometimes a ratio, sometimes a decimal
// so we explicitly request it as a rational (e.g. 1/100, 1/14, 71428571/1000000000, 4000/1000, 2000000000/500000000)
// and process it to make sure the numerator is `1` when the ratio value is less than 1
val num = it.numerator
val denom = it.denominator
metadataMap[KEY_EXPOSURE_TIME] = when {
num >= denom -> "${it.toSimpleString(true)}"
num != 1L && num != 0L -> Rational(1, (denom / num.toDouble()).roundToLong()).toString()
else -> it.toString()
}
}
var foundExif = false
if (isSupportedByMetadataExtractor(mimeType)) {
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
foundExif = true
dir.getSafeRational(ExifSubIFDDirectory.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it.numerator.toDouble() / it.denominator }
dir.getSafeRational(ExifSubIFDDirectory.TAG_EXPOSURE_TIME, saveExposureTime)
dir.getSafeRational(ExifSubIFDDirectory.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it.numerator.toDouble() / it.denominator }
dir.getSafeInt(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT) { metadataMap[KEY_ISO] = it }
}
}
result.success(metadataMap)
} ?: result.error("getOverlayMetadata-noinput", "failed to get metadata for uri=$uri", null)
} catch (e: Exception) {
result.error("getOverlayMetadata-exception", "failed to get metadata for uri=$uri", e.message)
} catch (e: NoClassDefFoundError) {
result.error("getOverlayMetadata-exception", "failed to get metadata for uri=$uri", e.message)
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e)
}
}
if (!foundExif) {
// fallback to read EXIF via ExifInterface
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
val exif = ExifInterface(input)
exif.getSafeDouble(ExifInterface.TAG_F_NUMBER) { metadataMap[KEY_APERTURE] = it }
exif.getSafeRational(ExifInterface.TAG_EXPOSURE_TIME, saveExposureTime)
exif.getSafeDouble(ExifInterface.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it }
exif.getSafeInt(ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY) { metadataMap[KEY_ISO] = it }
}
} catch (e: Exception) {
// ExifInterface initialization can fail with a RuntimeException
// caused by an internal MediaMetadataRetriever failure
Log.w(LOG_TAG, "failed to get metadata by ExifInterface for uri=$uri", e)
}
}
result.success(metadataMap)
}
private fun getContentResolverMetadata(call: MethodCall, result: MethodChannel.Result) {
@ -483,6 +517,77 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
result.success(metadataMap)
}
private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {
result.error("getBitmapDecoderInfo-args", "failed because of missing arguments", null)
return
}
val metadataMap = HashMap<String, String>()
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeStream(input, null, options)
options.outMimeType?.let { metadataMap["MimeType"] = it }
options.outWidth.takeIf { it >= 0 }?.let { metadataMap["Width"] = it.toString() }
options.outHeight.takeIf { it >= 0 }?.let { metadataMap["Height"] = it.toString() }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
options.outColorSpace?.let { metadataMap["ColorSpace"] = it.toString() }
options.outConfig?.let { metadataMap["Config"] = it.toString() }
}
}
} catch (e: IOException) {
// ignore
}
result.success(metadataMap)
}
private fun getMetadataExtractorSummary(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {
result.error("getMetadataExtractorSummary-args", "failed because of missing arguments", null)
return
}
val metadataMap = HashMap<String, String>()
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
metadataMap["mimeType"] = metadata.getDirectoriesOfType(FileTypeDirectory::class.java).joinToString { dir ->
if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) {
dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)
} else ""
}
metadataMap["typeName"] = metadata.getDirectoriesOfType(FileTypeDirectory::class.java).joinToString { dir ->
if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_TYPE_NAME)) {
dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_TYPE_NAME)
} else ""
}
for (dir in metadata.directories) {
val dirName = dir.name ?: ""
var index = 0
while (metadataMap.containsKey("$dirName ($index)")) index++
var value = "${dir.tagCount} tags"
dir.parent?.let { value += ", parent: ${it.name}" }
metadataMap["$dirName ($index)"] = value
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e)
}
if (metadataMap.isNotEmpty()) {
result.success(metadataMap)
} else {
result.error("getMetadataExtractorSummary-failure", "failed to get metadata for uri=$uri", null)
}
}
private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {
@ -517,14 +622,9 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
StorageUtils.openInputStream(context, uri)?.use { input ->
val exif = ExifInterface(input)
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
exif.thumbnailBitmap?.let {
val bitmap = TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), it, orientation)
if (bitmap != null) {
val stream = ByteArrayOutputStream()
// we compress the bitmap because Dart Image.memory cannot decode the raw bytes
// Bitmap.CompressFormat.PNG is slower than JPEG
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream)
thumbnails.add(stream.toByteArray())
exif.thumbnailBitmap?.let { bitmap ->
TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let {
thumbnails.add(it.getBytes(canHaveAlpha = false, recycle = false))
}
}
}

View file

@ -0,0 +1,82 @@
package deckers.thibault.aves.channel.calls
import android.content.Context
import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder
import android.graphics.Rect
import android.net.Uri
import android.util.Size
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 kotlin.math.roundToInt
class RegionFetcher internal constructor(
private val context: Context,
) {
private var lastDecoderRef: LastDecoderRef? = null
fun fetch(
uri: Uri,
mimeType: String,
sampleSize: Int,
regionRect: Rect,
imageSize: Size,
result: MethodChannel.Result,
) {
val options = BitmapFactory.Options().apply {
inSampleSize = sampleSize
}
var currentDecoderRef = lastDecoderRef
if (currentDecoderRef != null && currentDecoderRef.uri != uri) {
currentDecoderRef.decoder.recycle()
currentDecoderRef = null
}
try {
if (currentDecoderRef == null) {
val newDecoder = StorageUtils.openInputStream(context, uri)?.use { input ->
BitmapRegionDecoder.newInstance(input, false)
}
if (newDecoder == null) {
result.error("getRegion-read-null", "failed to open file for uri=$uri regionRect=$regionRect", null)
return
}
currentDecoderRef = LastDecoderRef(uri, newDecoder)
}
val decoder = currentDecoderRef.decoder
lastDecoderRef = currentDecoderRef
// with raw images, the known image size may not match the decoded image size
// so we scale the requested region accordingly
val effectiveRect = if (imageSize.width != decoder.width || imageSize.height != decoder.height) {
val xf = decoder.width.toDouble() / imageSize.width
val yf = decoder.height.toDouble() / imageSize.height
Rect(
(regionRect.left * xf).roundToInt(),
(regionRect.top * yf).roundToInt(),
(regionRect.right * xf).roundToInt(),
(regionRect.bottom * yf).roundToInt(),
)
} else {
regionRect
}
val bitmap = decoder.decodeRegion(effectiveRect, options)
if (bitmap != null) {
result.success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = true))
} else {
result.error("getRegion-null", "failed to decode region for uri=$uri regionRect=$regionRect", null)
}
} catch (e: Exception) {
result.error("getRegion-read-exception", "failed to initialize region decoder for uri=$uri regionRect=$regionRect", e.message)
}
}
}
private data class LastDecoderRef(
val uri: Uri,
val decoder: BitmapRegionDecoder,
)

View file

@ -1,7 +1,7 @@
package deckers.thibault.aves.channel.calls
import android.app.Activity
import android.content.ContentUris
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
@ -15,14 +15,16 @@ import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.signature.ObjectKey
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.isVideo
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
import io.flutter.plugin.common.MethodChannel
import java.io.ByteArrayOutputStream
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
class ThumbnailFetcher internal constructor(
private val activity: Activity,
private val context: Context,
uri: String,
private val mimeType: String,
private val dateModifiedSecs: Long,
@ -39,59 +41,57 @@ class ThumbnailFetcher internal constructor(
fun fetch() {
var bitmap: Bitmap? = null
var recycle = true
var exception: Exception? = null
// fetch low quality thumbnails when size is not specified
if ((width == defaultSize || height == defaultSize) && !isFlipped) {
// as of Android R, the Media Store content resolver may return a thumbnail
// that is automatically rotated according to EXIF orientation,
// but not flipped when necessary
// so we skip this step for flipped entries
try {
try {
if (mimeType == MimeTypes.TIFF) {
bitmap = getTiff()
} else if ((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,
// so we skip this step for flipped entries.
bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) getByResolver() else getByMediaStore()
} catch (e: Exception) {
exception = e
}
} catch (e: Exception) {
exception = e
}
// fallback if the native methods failed or for higher quality thumbnails
if (bitmap == null) {
try {
bitmap = getByGlide()
recycle = false
} catch (e: Exception) {
exception = e
}
}
if (bitmap != null) {
val stream = ByteArrayOutputStream()
// we compress the bitmap because Dart Image.memory cannot decode the raw bytes
// Bitmap.CompressFormat.PNG is slower than JPEG
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream)
result.success(stream.toByteArray())
return
result.success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = recycle))
} else {
var errorDetails: String? = exception?.message
if (errorDetails?.isNotEmpty() == true) {
errorDetails = errorDetails.split("\n".toRegex(), 2).first()
}
result.error("getThumbnail-null", "failed to get thumbnail for uri=$uri", errorDetails)
}
var errorDetails: String? = exception?.message
if (errorDetails?.isNotEmpty() == true) {
errorDetails = errorDetails.split("\n".toRegex(), 2).first()
}
result.error("getThumbnail-null", "failed to get thumbnail for uri=$uri", errorDetails)
}
@RequiresApi(api = Build.VERSION_CODES.Q)
private fun getByResolver(): Bitmap? {
val resolver = activity.contentResolver
val resolver = context.contentResolver
var bitmap: Bitmap? = resolver.loadThumbnail(uri, Size(width, height), null)
if (needRotationAfterContentResolverThumbnail(mimeType)) {
bitmap = applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped)
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
}
return bitmap
}
private fun getByMediaStore(): Bitmap? {
val contentId = ContentUris.parseId(uri)
val resolver = activity.contentResolver
val resolver = context.contentResolver
return if (isVideo(mimeType)) {
@Suppress("DEPRECATION")
MediaStore.Video.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Video.Thumbnails.MINI_KIND, null)
@ -100,7 +100,7 @@ class ThumbnailFetcher internal constructor(
var bitmap = MediaStore.Images.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Images.Thumbnails.MINI_KIND, null)
// from Android Q, returned thumbnail is already rotated according to EXIF orientation
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && bitmap != null) {
bitmap = applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped)
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
}
bitmap
}
@ -115,13 +115,13 @@ class ThumbnailFetcher internal constructor(
val target = if (isVideo(mimeType)) {
options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
Glide.with(activity)
Glide.with(context)
.asBitmap()
.apply(options)
.load(VideoThumbnail(activity, uri))
.load(VideoThumbnail(context, uri))
.submit(width, height)
} else {
Glide.with(activity)
Glide.with(context)
.asBitmap()
.apply(options)
.load(uri)
@ -131,11 +131,38 @@ class ThumbnailFetcher internal constructor(
return try {
var bitmap = target.get()
if (needRotationAfterGlide(mimeType)) {
bitmap = applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped)
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
}
bitmap
} finally {
Glide.with(activity).clear(target)
Glide.with(context).clear(target)
}
}
private fun getTiff(): Bitmap? {
// determine sample size
var sampleSize = 1
context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true
}
TiffBitmapFactory.decodeFileDescriptor(descriptor.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
return context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = false
inSampleSize = sampleSize
}
return TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
}
}
}

View file

@ -1,7 +1,6 @@
package deckers.thibault.aves.channel.streams
import android.app.Activity
import android.graphics.Bitmap
import android.net.Uri
import android.os.Handler
import android.os.Looper
@ -11,16 +10,17 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.decoder.VideoThumbnail
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.MimeTypes.canHaveAlpha
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isSupportedByFlutter
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
import deckers.thibault.aves.utils.StorageUtils.openInputStream
import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.io.ByteArrayOutputStream
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import java.io.IOException
import java.io.InputStream
@ -72,6 +72,8 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
if (isVideo(mimeType)) {
streamVideoByGlide(uri)
} else if (mimeType == MimeTypes.TIFF) {
streamTiffImage(uri)
} 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)
@ -84,7 +86,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
private fun streamImageAsIs(uri: Uri) {
try {
openInputStream(activity, uri).use { input -> input?.let { streamBytes(it) } }
StorageUtils.openInputStream(activity, uri)?.use { input -> streamBytes(input) }
} catch (e: IOException) {
error("streamImage-image-read-exception", "failed to get image from uri=$uri", e.message)
}
@ -102,24 +104,12 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
bitmap = applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped)
}
if (bitmap != null) {
val stream = ByteArrayOutputStream()
// we compress the bitmap because Dart Image.memory cannot decode the raw bytes
// Bitmap.CompressFormat.PNG is slower than JPEG, but it allows transparency
if (canHaveAlpha(mimeType)) {
bitmap.compress(Bitmap.CompressFormat.PNG, 0, stream)
} else {
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, stream)
}
success(stream.toByteArray())
success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false))
} else {
error("streamImage-image-decode-null", "failed to get image from uri=$uri", null)
}
} catch (e: Exception) {
var errorDetails = e.message
if (errorDetails?.isNotEmpty() == true) {
errorDetails = errorDetails.split("\n".toRegex(), 2).first()
}
error("streamImage-image-decode-exception", "failed to get image from uri=$uri", errorDetails)
error("streamImage-image-decode-exception", "failed to get image from uri=$uri", toErrorDetails(e))
} finally {
Glide.with(activity).clear(target)
}
@ -134,11 +124,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
try {
val bitmap = target.get()
if (bitmap != null) {
val stream = ByteArrayOutputStream()
// we compress the bitmap because Dart Image.memory cannot decode the raw bytes
// Bitmap.CompressFormat.PNG is slower than JPEG
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, stream)
success(stream.toByteArray())
success(bitmap.getBytes(canHaveAlpha = false, recycle = false))
} else {
error("streamImage-video-null", "failed to get image from uri=$uri", null)
}
@ -149,6 +135,48 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
}
}
private fun streamTiffImage(uri: Uri) {
val resolver = activity.contentResolver
try {
var dirCount = 0
resolver.openFileDescriptor(uri, "r")?.use { descriptor ->
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true
}
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
dirCount = options.outDirectoryCount
}
// TODO TLAD handle multipage TIFF
if (dirCount > 0) {
val i = 0
resolver.openFileDescriptor(uri, "r")?.use { descriptor ->
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = false
inDirectoryNumber = i
}
val bitmap = TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
if (bitmap != null) {
success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
} else {
error("streamImage-tiff-null", "failed to get tiff image (dir=$i) 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) {
errorDetails.split("\n".toRegex(), 2).first()
} else {
errorDetails
}
}
private fun streamBytes(inputStream: InputStream) {
val buffer = ByteArray(bufferSize)
var len: Int

View file

@ -1,7 +1,6 @@
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
@ -16,9 +15,9 @@ 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 deckers.thibault.aves.utils.StorageUtils.openMetadataRetriever
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
@GlideModule
@ -49,16 +48,12 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail) : DataFe
val retriever = openMetadataRetriever(model.context, model.uri)
if (retriever != null) {
try {
var picture = retriever.embeddedPicture
if (picture == null) {
// not ideal: bitmap -> byte[] -> bitmap
// but simple fallback and we cache result
val stream = ByteArrayOutputStream()
val bitmap = retriever.frameAtTime
bitmap?.compress(Bitmap.CompressFormat.PNG, 0, stream)
picture = stream.toByteArray()
val picture = retriever.embeddedPicture ?: retriever.frameAtTime?.getBytes(canHaveAlpha = false, recycle = false)
if (picture != null) {
callback.onDataReady(ByteArrayInputStream(picture))
} else {
callback.onLoadFailed(Exception("failed to get embedded picture or any frame"))
}
callback.onDataReady(ByteArrayInputStream(picture))
} catch (e: Exception) {
callback.onLoadFailed(e)
} finally {

View file

@ -10,12 +10,15 @@ import com.drew.metadata.exif.makernotes.OlympusImageProcessingMakernoteDirector
import com.drew.metadata.exif.makernotes.OlympusMakernoteDirectory
import deckers.thibault.aves.utils.LogUtils
import java.util.*
import kotlin.math.abs
import kotlin.math.floor
import kotlin.math.roundToLong
object ExifInterfaceHelper {
private val LOG_TAG = LogUtils.createTag(ExifInterfaceHelper::class.java)
private const val precisionErrorTolerance = 1e-10
// ExifInterface always states it has the following attributes
// and returns "0" instead of "null" when they are actually missing
private val neverNullTags = listOf(
@ -279,7 +282,7 @@ object ExifInterfaceHelper {
private fun toRational(s: String?): Rational? {
s ?: return null
// convert "12345/100"
// e.g. "12345/100" to Rational(12345, 100)
val parts = s.split("/")
if (parts.size == 2) {
val numerator = parts[0].toLongOrNull() ?: return null
@ -287,9 +290,20 @@ object ExifInterfaceHelper {
return Rational(numerator, denominator)
}
// convert "123.45"
var d = s.toDoubleOrNull() ?: return null
if (d == 0.0) return Rational(0, 1)
// e.g. "0.02564102564102564" to Rational(1, 39)
if (d < 1) {
val numerator = 1L
val f = numerator / d
val denominator = f.roundToLong()
if (abs(f - denominator) < precisionErrorTolerance) {
return Rational(numerator, denominator)
}
}
// e.g. "123.45" to Rational(12345, 100)
var denominator: Long = 1
while (d != floor(d)) {
denominator *= 10
@ -321,6 +335,24 @@ object ExifInterfaceHelper {
}
}
fun ExifInterface.getSafeDouble(tag: String, save: (value: Double) -> Unit) {
if (this.hasAttribute(tag)) {
val value = this.getAttributeDouble(tag, Double.NaN)
if (!value.isNaN()) {
save(value)
}
}
}
fun ExifInterface.getSafeRational(tag: String, save: (value: Rational) -> Unit) {
if (this.hasAttribute(tag)) {
val value = toRational(this.getAttribute(tag))
if (value != null) {
save(value)
}
}
}
fun ExifInterface.getSafeDateMillis(tag: String, save: (value: Long) -> Unit) {
if (this.hasAttribute(tag)) {
// TODO TLAD parse date with "yyyy:MM:dd HH:mm:ss" or find the original long

View file

@ -123,7 +123,8 @@ class SourceImageEntry {
fillVideoByMediaMetadataRetriever(context)
if (isSized && hasDuration) return this
}
if (MimeTypes.isSupportedByMetadataExtractor(sourceMimeType)) {
// skip metadata-extractor for raw images because it reports the decoded dimensions instead of the raw dimensions
if (!MimeTypes.isRaw(sourceMimeType) && MimeTypes.isSupportedByMetadataExtractor(sourceMimeType)) {
fillByMetadataExtractor(context)
if (isSized && foundExif) return this
}
@ -176,7 +177,6 @@ class SourceImageEntry {
dir.getSafeLong(Mp4Directory.TAG_DURATION) { durationMillis = it }
}
} else {
// EXIF, if defined, should override metadata found in other directories
for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
foundExif = true
dir.getSafeInt(ExifIFD0Directory.TAG_IMAGE_WIDTH) { width = it }
@ -185,15 +185,15 @@ class SourceImageEntry {
dir.getSafeDateMillis(ExifIFD0Directory.TAG_DATETIME) { sourceDateTakenMillis = it }
}
if (!foundExif) {
for (dir in metadata.getDirectoriesOfType(JpegDirectory::class.java)) {
dir.getSafeInt(JpegDirectory.TAG_IMAGE_WIDTH) { width = it }
dir.getSafeInt(JpegDirectory.TAG_IMAGE_HEIGHT) { height = it }
}
for (dir in metadata.getDirectoriesOfType(PsdHeaderDirectory::class.java)) {
dir.getSafeInt(PsdHeaderDirectory.TAG_IMAGE_WIDTH) { width = it }
dir.getSafeInt(PsdHeaderDirectory.TAG_IMAGE_HEIGHT) { height = it }
}
// dimensions reported in EXIF do not always match the image
// so we fetch them from the format directory if available
for (dir in metadata.getDirectoriesOfType(JpegDirectory::class.java)) {
dir.getSafeInt(JpegDirectory.TAG_IMAGE_WIDTH) { width = it }
dir.getSafeInt(JpegDirectory.TAG_IMAGE_HEIGHT) { height = it }
}
for (dir in metadata.getDirectoriesOfType(PsdHeaderDirectory::class.java)) {
dir.getSafeInt(PsdHeaderDirectory.TAG_IMAGE_WIDTH) { width = it }
dir.getSafeInt(PsdHeaderDirectory.TAG_IMAGE_HEIGHT) { height = it }
}
}
}
@ -225,8 +225,9 @@ class SourceImageEntry {
private fun fillByBitmapDecode(context: Context) {
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
val options = BitmapFactory.Options()
options.inJustDecodeBounds = true
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeStream(input, null, options)
width = options.outWidth
height = options.outHeight

View file

@ -144,11 +144,14 @@ class MediaStoreImageProvider : ImageProvider() {
"contentId" to contentId,
)
if ((width <= 0 || height <= 0) && needSize(mimeType)
if (MimeTypes.isRaw(mimeType)
|| (width <= 0 || height <= 0) && needSize(mimeType)
|| durationMillis == 0L && needDuration
) {
// some images are incorrectly registered in the Media Store,
// they are valid but miss some attributes, such as width, height, orientation
// Some images are incorrectly registered in the Media Store,
// 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)
entryMap = entry.toMap()
}

View file

@ -5,8 +5,22 @@ import android.graphics.Bitmap
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
import deckers.thibault.aves.metadata.Metadata.getExifCode
import java.io.ByteArrayOutputStream
object BitmapUtils {
fun Bitmap.getBytes(canHaveAlpha: Boolean = false, quality: Int = 100, recycle: Boolean = true): ByteArray {
val stream = ByteArrayOutputStream()
// we compress the bitmap because Flutter cannot decode the raw bytes
// `Bitmap.CompressFormat.PNG` is slower than `JPEG`, but it allows transparency
if (canHaveAlpha) {
this.compress(Bitmap.CompressFormat.PNG, quality, stream)
} else {
this.compress(Bitmap.CompressFormat.JPEG, quality, stream)
}
if (recycle) this.recycle()
return stream.toByteArray()
}
fun applyExifOrientation(context: Context, bitmap: Bitmap?, rotationDegrees: Int?, isFlipped: Boolean?): Bitmap? {
if (bitmap == null || rotationDegrees == null || isFlipped == null) return bitmap
if (rotationDegrees == 0 && !isFlipped) return bitmap

View file

@ -16,7 +16,16 @@ object MimeTypes {
const val WEBP = "image/webp"
// raw raster
private const val ARW = "image/x-sony-arw"
private const val CR2 = "image/x-canon-cr2"
private const val DNG = "image/x-adobe-dng"
private const val NEF = "image/x-nikon-nef"
private const val NRW = "image/x-nikon-nrw"
private const val ORF = "image/x-olympus-orf"
private const val PEF = "image/x-pentax-pef"
private const val RAF = "image/x-fuji-raf"
private const val RW2 = "image/x-panasonic-rw2"
private const val SRW = "image/x-samsung-srw"
// vector
const val SVG = "image/svg+xml"
@ -35,6 +44,13 @@ object MimeTypes {
else -> isVideo(mimeType)
}
fun isRaw(mimeType: String?): Boolean {
return when (mimeType) {
ARW, CR2, DNG, NEF, NRW, ORF, PEF, RAF, RW2, SRW -> true
else -> false
}
}
// returns whether the specified MIME type represents
// a raster image format that allows an alpha channel
fun canHaveAlpha(mimeType: String?) = when (mimeType) {
@ -42,7 +58,7 @@ object MimeTypes {
else -> false
}
// as of Flutter v1.22.0
// as of Flutter v1.22.0, with additional custom handling for SVG
fun isSupportedByFlutter(mimeType: String, rotationDegrees: Int?, isFlipped: Boolean?) = when (mimeType) {
JPEG, GIF, WEBP, BMP, WBMP, ICO, SVG -> true
PNG -> rotationDegrees ?: 0 == 0 && !(isFlipped ?: false)
@ -71,4 +87,8 @@ object MimeTypes {
DNG, PNG -> true
else -> false
}
// extensions
val tiffExtensionPattern = Regex(".*\\.tiff?", RegexOption.IGNORE_CASE)
}

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="@android:style/Theme.Black.NoTitleBar">
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowTranslucentNavigation">@bool/translucentNavBar</item> <!-- API19+, tinted background & request the SYSTEM_UI_FLAG_LAYOUT_STABLE and SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN flags -->
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item> <!-- API28+, draws next to the notch in fullscreen -->
</style>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="@android:style/Theme.Black.NoTitleBar">
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowTranslucentNavigation">@bool/translucentNavBar</item> <!-- API19+, tinted background & request the SYSTEM_UI_FLAG_LAYOUT_STABLE and SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN flags -->
</style>
</resources>

View file

@ -43,7 +43,7 @@ class LocationFilter extends CollectionFilter {
@override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) {
final flag = countryCodeToFlag(_countryCode);
// as of Flutter v1.22.0-12.1.pre emoji shadows are rendered as colorful duplicates,
// as of Flutter v1.22.3, emoji shadows are rendered as colorful duplicates,
// not filled with the shadow color as expected, so we remove them
if (flag != null) return Text(flag, style: TextStyle(fontSize: size, shadows: []));
return Icon(_location.isEmpty ? AIcons.locationOff : AIcons.location, size: size);

View file

@ -1,5 +1,6 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/mime_types.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
@ -34,7 +35,7 @@ class MimeFilter extends CollectionFilter {
_label ??= lowMime.split('/')[0].toUpperCase();
} else {
_filter = (entry) => entry.mimeType == lowMime;
_label = displayType(lowMime);
_label = MimeTypes.displayType(lowMime);
}
_icon ??= AIcons.vector;
}
@ -50,18 +51,6 @@ class MimeFilter extends CollectionFilter {
'mime': mime,
};
static String displayType(String mime) {
final patterns = [
RegExp('.*/'), // remove type, keep subtype
RegExp('(X-|VND.(WAP.)?)'), // noisy prefixes
'+XML', // noisy suffix
RegExp('ADOBE\\\.'), // for PSD
];
mime = mime.toUpperCase();
patterns.forEach((pattern) => mime = mime.replaceFirst(pattern, ''));
return mime;
}
@override
bool filter(ImageEntry entry) => _filter(entry);

View file

@ -169,6 +169,27 @@ class ImageEntry {
// guess whether this is a photo, according to file type (used as a hint to e.g. display megapixels)
bool get isPhoto => [MimeTypes.heic, MimeTypes.heif, MimeTypes.jpeg].contains(mimeType) || isRaw;
// Android's `BitmapRegionDecoder` documentation states that "only the JPEG and PNG formats are supported"
// but in practice (tested on API 25, 27, 29), it successfully decodes the formats listed below,
// and it actually fails to decode GIF, DNG and animated WEBP. Other formats were not tested.
bool get canTile =>
[
MimeTypes.heic,
MimeTypes.heif,
MimeTypes.jpeg,
MimeTypes.webp,
MimeTypes.arw,
MimeTypes.cr2,
MimeTypes.nef,
MimeTypes.nrw,
MimeTypes.orf,
MimeTypes.pef,
MimeTypes.raf,
MimeTypes.rw2,
MimeTypes.srw,
].contains(mimeType) &&
!isAnimated;
bool get isRaw => MimeTypes.rawImages.contains(mimeType);
bool get isVideo => mimeType.startsWith('video');

View file

@ -1,5 +1,6 @@
import 'package:flutter/widgets.dart';
import 'package:geocoder/model.dart';
import 'package:intl/intl.dart';
class DateMetadata {
final int contentId, dateMillis;
@ -109,23 +110,29 @@ class CatalogMetadata {
class OverlayMetadata {
final String aperture, exposureTime, focalLength, iso;
static final apertureFormat = NumberFormat('0.0', 'en_US');
static final focalLengthFormat = NumberFormat('0.#', 'en_US');
OverlayMetadata({
String aperture,
this.exposureTime,
this.focalLength,
this.iso,
}) : aperture = aperture.replaceFirst('f', 'ƒ');
double aperture,
String exposureTime,
double focalLength,
int iso,
}) : aperture = aperture != null ? 'ƒ/${apertureFormat.format(aperture)}' : null,
exposureTime = exposureTime,
focalLength = focalLength != null ? '${focalLengthFormat.format(focalLength)} mm' : null,
iso = iso != null ? 'ISO$iso' : null;
factory OverlayMetadata.fromMap(Map map) {
return OverlayMetadata(
aperture: map['aperture'] ?? '',
exposureTime: map['exposureTime'] ?? '',
focalLength: map['focalLength'] ?? '',
iso: map['iso'] ?? '',
aperture: map['aperture'] as double,
exposureTime: map['exposureTime'] as String,
focalLength: map['focalLength'] as double,
iso: map['iso'] as int,
);
}
bool get isEmpty => aperture.isEmpty && exposureTime.isEmpty && focalLength.isEmpty && iso.isEmpty;
bool get isEmpty => aperture == null && exposureTime == null && focalLength == null && iso == null;
@override
String toString() {

View file

@ -41,5 +41,17 @@ class MimeTypes {
// groups
static const List<String> rawImages = [arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f];
static const List<String> undecodable = [crw, psd, tiff]; // TODO TLAD make it dynamic if it depends on OS/lib versions
static const List<String> undecodable = [crw, psd]; // TODO TLAD make it dynamic if it depends on OS/lib versions
static String displayType(String mime) {
final patterns = [
RegExp('.*/'), // remove type, keep subtype
RegExp('(X-|VND.(WAP.)?)'), // noisy prefixes
'+XML', // noisy suffix
RegExp('ADOBE\\\.'), // for PSD
];
mime = mime.toUpperCase();
patterns.forEach((pattern) => mime = mime.replaceFirst(pattern, ''));
return mime;
}
}

View file

@ -45,6 +45,7 @@ class Settings extends ChangeNotifier {
static const pinnedFiltersKey = 'pinned_filters';
// viewer
static const showOverlayMinimapKey = 'show_overlay_minimap';
static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details';
// info
@ -159,6 +160,10 @@ class Settings extends ChangeNotifier {
// viewer
bool get showOverlayMinimap => getBoolOrDefault(showOverlayMinimapKey, false);
set showOverlayMinimap(bool newValue) => setAndNotify(showOverlayMinimapKey, newValue);
bool get showOverlayShootingDetails => getBoolOrDefault(showOverlayShootingDetailsKey, true);
set showOverlayShootingDetails(bool newValue) => setAndNotify(showOverlayShootingDetailsKey, newValue);

View file

@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:aves/model/image_entry.dart';
@ -113,6 +114,43 @@ class ImageFileService {
return Future.sync(() => null);
}
// `rect`: region to decode, with coordinates in reference to `imageSize`
static Future<Uint8List> getRegion(
String uri,
String mimeType,
int rotationDegrees,
bool isFlipped,
int sampleSize,
Rectangle<int> regionRect,
Size imageSize, {
Object taskKey,
int priority,
}) {
return servicePolicy.call(
() async {
try {
final result = await platform.invokeMethod('getRegion', <String, dynamic>{
'uri': uri,
'mimeType': mimeType,
'sampleSize': sampleSize,
'regionX': regionRect.left,
'regionY': regionRect.top,
'regionWidth': regionRect.width,
'regionHeight': regionRect.height,
'imageWidth': imageSize.width.toInt(),
'imageHeight': imageSize.height.toInt(),
});
return result as Uint8List;
} on PlatformException catch (e) {
debugPrint('getRegion failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return null;
},
priority: priority ?? ServiceCallPriority.getRegion,
key: taskKey,
);
}
static Future<Uint8List> getThumbnail(
String uri,
String mimeType,
@ -160,9 +198,11 @@ class ImageFileService {
}
}
static bool cancelRegion(Object taskKey) => servicePolicy.pause(taskKey, [ServiceCallPriority.getRegion]);
static bool cancelThumbnail(Object taskKey) => servicePolicy.pause(taskKey, [ServiceCallPriority.getFastThumbnail, ServiceCallPriority.getSizedThumbnail]);
static Future<T> resumeThumbnail<T>(Object taskKey) => servicePolicy.resume<T>(taskKey);
static Future<T> resumeLoading<T>(Object taskKey) => servicePolicy.resume<T>(taskKey);
static Stream<ImageOpEvent> delete(Iterable<ImageEntry> entries) {
try {

View file

@ -43,6 +43,7 @@ class MetadataService {
final result = await platform.invokeMethod('getCatalogMetadata', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'path': entry.path,
}) as Map;
result['contentId'] = entry.contentId;
return CatalogMetadata.fromMap(result);
@ -64,7 +65,7 @@ class MetadataService {
if (entry.isSvg) return null;
try {
// return map with string descriptions for: 'aperture' 'exposureTime' 'focalLength' 'iso'
// return map with values for: 'aperture' (double), 'exposureTime' (description), 'focalLength' (double), 'iso' (int)
final result = await platform.invokeMethod('getOverlayMetadata', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
@ -76,6 +77,19 @@ class MetadataService {
return null;
}
static Future<Map> getBitmapFactoryInfo(ImageEntry entry) async {
try {
// return map with all data available when decoding image bounds with `BitmapFactory`
final result = await platform.invokeMethod('getBitmapFactoryInfo', <String, dynamic>{
'uri': entry.uri,
}) as Map;
return result;
} on PlatformException catch (e) {
debugPrint('getBitmapFactoryInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return {};
}
static Future<Map> getContentResolverMetadata(ImageEntry entry) async {
try {
// return map with all data available from the content resolver
@ -92,7 +106,7 @@ class MetadataService {
static Future<Map> getExifInterfaceMetadata(ImageEntry entry) async {
try {
// return map with all data available from the ExifInterface library
// return map with all data available from the `ExifInterface` library
final result = await platform.invokeMethod('getExifInterfaceMetadata', <String, dynamic>{
'uri': entry.uri,
}) as Map;
@ -105,7 +119,7 @@ class MetadataService {
static Future<Map> getMediaMetadataRetrieverMetadata(ImageEntry entry) async {
try {
// return map with all data available from the MediaMetadataRetriever
// return map with all data available from `MediaMetadataRetriever`
final result = await platform.invokeMethod('getMediaMetadataRetrieverMetadata', <String, dynamic>{
'uri': entry.uri,
}) as Map;
@ -116,6 +130,19 @@ class MetadataService {
return {};
}
static Future<Map> getMetadataExtractorSummary(ImageEntry 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>{
'uri': entry.uri,
}) as Map;
return result;
} on PlatformException catch (e) {
debugPrint('getMetadataExtractorSummary failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return {};
}
static Future<List<Uint8List>> getEmbeddedPictures(String uri) async {
try {
final result = await platform.invokeMethod('getEmbeddedPictures', <String, dynamic>{

View file

@ -7,10 +7,13 @@ import 'package:tuple/tuple.dart';
final ServicePolicy servicePolicy = ServicePolicy._private();
class ServicePolicy {
final StreamController<QueueState> _queueStreamController = StreamController<QueueState>.broadcast();
final Map<Object, Tuple2<int, _Task>> _paused = {};
final SplayTreeMap<int, Queue<_Task>> _queues = SplayTreeMap();
_Task _running;
Stream<QueueState> get queueStream => _queueStreamController.stream;
ServicePolicy._private();
Future<T> call<T>(
@ -60,6 +63,7 @@ class ServicePolicy {
Queue<_Task> _getQueue(int priority) => _queues.putIfAbsent(priority, () => Queue<_Task>());
void _pickNext() {
_notifyQueueState();
if (_running != null) return;
final queue = _queues.entries.firstWhere((kv) => kv.value.isNotEmpty, orElse: () => null)?.value;
_running = queue?.removeFirst();
@ -90,6 +94,13 @@ class ServicePolicy {
}
bool isPaused(Object key) => _paused.containsKey(key);
void _notifyQueueState() {
if (!_queueStreamController.hasListener) return;
final queueByPriority = Map.fromEntries(_queues.entries.map((kv) => MapEntry(kv.key, kv.value.length)));
_queueStreamController.add(QueueState(queueByPriority));
}
}
class _Task {
@ -104,8 +115,15 @@ class CancelledException {}
class ServiceCallPriority {
static const int getFastThumbnail = 100;
static const int getRegion = 150;
static const int getSizedThumbnail = 200;
static const int normal = 500;
static const int getMetadata = 1000;
static const int getLocation = 1000;
}
class QueueState {
final Map<int, int> queueByPriority;
const QueueState(this.queueByPriority);
}

View file

@ -3,7 +3,7 @@ import 'package:flutter/painting.dart';
import 'package:tuple/tuple.dart';
class Constants {
// as of Flutter v1.11.0, overflowing `Text` miscalculates height and some text (e.g. 'Å') is clipped
// as of Flutter v1.22.3, overflowing `Text` miscalculates height and some text (e.g. 'Å') is clipped
// so we give it a `strutStyle` with a slightly larger height
static const overflowStrutStyle = StrutStyle(height: 1.3);
@ -18,13 +18,20 @@ class Constants {
offset: Offset(0.5, 1.0),
);
static const String unknown = 'unknown';
static const String overlayUnknown = ''; // em dash
static const String infoUnknown = 'unknown';
static const pointNemo = Tuple2(-48.876667, -123.393333);
static const int infoGroupMaxValueLength = 140;
static const List<Dependency> androidDependencies = [
Dependency(
name: 'Android-TiffBitmapFactory',
license: 'MIT',
licenseUrl: 'https://github.com/Beyka/Android-TiffBitmapFactory/blob/master/license.txt',
sourceUrl: 'https://github.com/Beyka/Android-TiffBitmapFactory',
),
Dependency(
name: 'CWAC-Document',
license: 'Apache 2.0',

View file

@ -27,6 +27,7 @@ class Durations {
// fullscreen animations
static const fullscreenPageAnimation = Duration(milliseconds: 300);
static const fullscreenOverlayAnimation = Duration(milliseconds: 200);
static const fullscreenOverlayChangeAnimation = Duration(milliseconds: 150);
// info
static const mapStyleSwitchAnimation = Duration(milliseconds: 300);

View file

@ -1,10 +1,10 @@
import 'dart:math' as math;
import 'dart:math';
import 'package:intl/intl.dart';
import 'package:tuple/tuple.dart';
String _decimal2sexagesimal(final double degDecimal) {
double _round(final double value, {final int decimals = 6}) => (value * math.pow(10, decimals)).round() / math.pow(10, decimals);
double _round(final double value, {final int decimals = 6}) => (value * pow(10, decimals)).round() / pow(10, decimals);
List<int> _split(final double value) {
// NumberFormat is necessary to create digit after comma if the value

11
lib/utils/math_utils.dart Normal file
View file

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

View file

@ -12,11 +12,11 @@ String formatDuration(Duration d) {
}
extension ExtraDateTime on DateTime {
bool isAtSameYearAs(DateTime other) => this != null && other != null && year == other.year;
bool isAtSameYearAs(DateTime other) => this?.year == other?.year;
bool isAtSameMonthAs(DateTime other) => isAtSameYearAs(other) && month == other.month;
bool isAtSameMonthAs(DateTime other) => isAtSameYearAs(other) && this?.month == other?.month;
bool isAtSameDayAs(DateTime other) => isAtSameMonthAs(other) && day == other.day;
bool isAtSameDayAs(DateTime other) => isAtSameMonthAs(other) && this?.day == other?.day;
bool get isToday => isAtSameDayAs(DateTime.now());

View file

@ -1,370 +0,0 @@
import 'dart:collection';
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/settings/settings.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/utils/android_file_utils.dart';
import 'package:aves/utils/file_utils.dart';
import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/fullscreen/info/common.dart';
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_svg/flutter_svg.dart';
class AppDebugPage extends StatefulWidget {
static const routeName = '/debug';
final CollectionSource source;
const AppDebugPage({this.source});
@override
State<StatefulWidget> createState() => AppDebugPageState();
}
class AppDebugPageState extends State<AppDebugPage> {
Future<int> _dbFileSizeLoader;
Future<List<ImageEntry>> _dbEntryLoader;
Future<List<DateMetadata>> _dbDateLoader;
Future<List<CatalogMetadata>> _dbMetadataLoader;
Future<List<AddressDetails>> _dbAddressLoader;
Future<List<FavouriteRow>> _dbFavouritesLoader;
Future<Map> _envLoader;
List<ImageEntry> get entries => widget.source.rawEntries;
@override
void initState() {
super.initState();
_startDbReport();
_envLoader = AndroidAppService.getEnv();
}
@override
Widget build(BuildContext context) {
return MediaQueryDataProvider(
child: DefaultTabController(
length: 4,
child: Scaffold(
appBar: AppBar(
title: Text('Debug'),
bottom: TabBar(
tabs: [
Tab(icon: Icon(AIcons.debug)),
Tab(icon: Icon(AIcons.settings)),
Tab(icon: Icon(AIcons.removableStorage)),
Tab(icon: Icon(AIcons.android)),
],
),
),
body: SafeArea(
child: TabBarView(
children: [
_buildGeneralTabView(),
_buildSettingsTabView(),
_buildStorageTabView(),
_buildEnvTabView(),
],
),
),
),
),
);
}
Widget _buildGeneralTabView() {
final catalogued = entries.where((entry) => entry.isCatalogued);
final withGps = catalogued.where((entry) => entry.hasGps);
final located = withGps.where((entry) => entry.isLocated);
return ListView(
padding: EdgeInsets.all(16),
children: [
Text('Time dilation'),
Slider(
value: timeDilation,
onChanged: (v) => setState(() => timeDilation = v),
min: 1.0,
max: 10.0,
divisions: 9,
label: '$timeDilation',
),
Divider(),
Row(
children: [
Expanded(
child: Text('Crashlytics'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: FirebaseCrashlytics.instance.crash,
child: Text('Crash'),
),
],
),
Row(
children: [
Expanded(
child: Text('Analytics'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => FirebaseAnalytics().logEvent(
name: 'debug_test',
parameters: {'time': DateTime.now().toIso8601String()},
),
child: Text('Send event'),
),
],
),
Text('Firebase data collection: ${Firebase.app().isAutomaticDataCollectionEnabled ? 'enabled' : 'disabled'}'),
Text('Crashlytics collection: ${FirebaseCrashlytics.instance.isCrashlyticsCollectionEnabled ? 'enabled' : 'disabled'}'),
Divider(),
Text('Entries: ${entries.length}'),
Text('Catalogued: ${catalogued.length}'),
Text('With GPS: ${withGps.length}'),
Text('With address: ${located.length}'),
Divider(),
Row(
children: [
Expanded(
child: Text('Image cache:\n\t${imageCache.currentSize}/${imageCache.maximumSize} items\n\t${formatFilesize(imageCache.currentSizeBytes)}/${formatFilesize(imageCache.maximumSizeBytes)}'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () {
imageCache.clear();
setState(() {});
},
child: Text('Clear'),
),
],
),
Row(
children: [
Expanded(
child: Text('SVG cache: ${PictureProvider.cacheCount} items'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () {
PictureProvider.clearCache();
setState(() {});
},
child: Text('Clear'),
),
],
),
Row(
children: [
Expanded(
child: Text('Glide disk cache: ?'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: ImageFileService.clearSizedThumbnailDiskCache,
child: Text('Clear'),
),
],
),
Divider(),
FutureBuilder<int>(
future: _dbFileSizeLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row(
children: [
Expanded(
child: Text('DB file size: ${formatFilesize(snapshot.data)}'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => metadataDb.reset().then((_) => _startDbReport()),
child: Text('Reset'),
),
],
);
},
),
FutureBuilder<List>(
future: _dbEntryLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row(
children: [
Expanded(
child: Text('DB entry rows: ${snapshot.data.length}'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => metadataDb.clearEntries().then((_) => _startDbReport()),
child: Text('Clear'),
),
],
);
},
),
FutureBuilder<List>(
future: _dbDateLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row(
children: [
Expanded(
child: Text('DB date rows: ${snapshot.data.length}'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => metadataDb.clearDates().then((_) => _startDbReport()),
child: Text('Clear'),
),
],
);
},
),
FutureBuilder<List>(
future: _dbMetadataLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row(
children: [
Expanded(
child: Text('DB metadata rows: ${snapshot.data.length}'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => metadataDb.clearMetadataEntries().then((_) => _startDbReport()),
child: Text('Clear'),
),
],
);
},
),
FutureBuilder<List>(
future: _dbAddressLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row(
children: [
Expanded(
child: Text('DB address rows: ${snapshot.data.length}'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => metadataDb.clearAddresses().then((_) => _startDbReport()),
child: Text('Clear'),
),
],
);
},
),
FutureBuilder<List>(
future: _dbFavouritesLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row(
children: [
Expanded(
child: Text('DB favourite rows: ${snapshot.data.length} (${favourites.count} in memory)'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => favourites.clear().then((_) => _startDbReport()),
child: Text('Clear'),
),
],
);
},
),
],
);
}
Widget _buildSettingsTabView() {
return ListView(
padding: EdgeInsets.all(16),
children: [
Row(
children: [
Expanded(
child: Text('Settings'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => settings.reset().then((_) => setState(() {})),
child: Text('Reset'),
),
],
),
InfoRowGroup({
'collectionGroupFactor': '${settings.collectionGroupFactor}',
'collectionSortFactor': '${settings.collectionSortFactor}',
'collectionTileExtent': '${settings.collectionTileExtent}',
'infoMapZoom': '${settings.infoMapZoom}',
'pinnedFilters': '${settings.pinnedFilters}',
'searchHistory': '${settings.searchHistory}',
}),
],
);
}
Widget _buildStorageTabView() {
return ListView(
padding: EdgeInsets.all(16),
children: [
...androidFileUtils.storageVolumes.expand((v) => [
Text(v.path),
InfoRowGroup({
'description': '${v.description}',
'isEmulated': '${v.isEmulated}',
'isPrimary': '${v.isPrimary}',
'isRemovable': '${v.isRemovable}',
'state': '${v.state}',
}),
Divider(),
])
],
);
}
Widget _buildEnvTabView() {
return ListView(
padding: EdgeInsets.all(16),
children: [
FutureBuilder<Map>(
future: _envLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
final data = SplayTreeMap.of(snapshot.data.map((k, v) => MapEntry(k.toString(), v?.toString() ?? 'null')));
return InfoRowGroup(data);
},
),
],
);
}
void _startDbReport() {
_dbFileSizeLoader = metadataDb.dbFileSize();
_dbEntryLoader = metadataDb.loadEntries();
_dbDateLoader = metadataDb.loadDates();
_dbMetadataLoader = metadataDb.loadMetadataEntries();
_dbAddressLoader = metadataDb.loadAddresses();
_dbFavouritesLoader = metadataDb.loadFavourites();
setState(() {});
}
}

View file

@ -56,6 +56,7 @@ class MonthSectionHeader extends StatelessWidget {
static DateFormat ym = DateFormat.yMMMM();
static String _formatDate(DateTime date) {
if (date == null) return 'Unknown';
if (date.isThisMonth) return 'This month';
if (date.isThisYear) return m.format(date);
return ym.format(date);

View file

@ -80,7 +80,7 @@ class SectionHeader extends StatelessWidget {
final para = RenderParagraph(
TextSpan(
children: [
// `RenderParagraph` fails to lay out `WidgetSpan` offscreen as of Flutter v1.17.0
// as of Flutter v1.22.3, `RenderParagraph` fails to lay out `WidgetSpan` offscreen
// so we use a hair space times a magic number to match width
TextSpan(
text: '\u200A' * (hasLeading ? 23 : 1),
@ -214,7 +214,7 @@ class SectionSelectableLeading extends StatelessWidget {
child: IconButton(
iconSize: 26,
padding: EdgeInsets.only(top: 1),
alignment: Alignment.topLeft,
alignment: AlignmentDirectional.topStart,
icon: Icon(selected ? AIcons.selected : AIcons.unselected),
onPressed: onPressed,
tooltip: selected ? 'Deselect section' : 'Select section',

View file

@ -10,9 +10,9 @@ import 'package:aves/widgets/fullscreen/fullscreen_page.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
// use a `SliverList` instead of multiple `SliverGrid` because having one `SliverGrid` per section does not scale up
// with the multiple `SliverGrid` solution, thumbnails at the beginning of each sections are built even though they are offscreen
// because of `RenderSliverMultiBoxAdaptor.addInitialChild` called by `RenderSliverGrid.performLayout` (line 547), as of Flutter v1.17.0
// Use a `SliverList` instead of multiple `SliverGrid` because having one `SliverGrid` per section does not scale up.
// With the multiple `SliverGrid` solution, thumbnails at the beginning of each sections are built even though they are offscreen
// because of `RenderSliverMultiBoxAdaptor.addInitialChild` called by `RenderSliverGrid.performLayout` (line 547), as of Flutter v1.17.0.
class CollectionListSliver extends StatelessWidget {
const CollectionListSliver();

View file

@ -226,10 +226,13 @@ class _ScaleOverlayState extends State<ScaleOverlay> {
Positioned(
left: clampedCenter.dx - extent / 2,
top: clampedCenter.dy - extent / 2,
child: DecoratedThumbnail(
entry: widget.imageEntry,
extent: extent,
showOverlay: false,
child: DefaultTextStyle(
style: TextStyle(),
child: DecoratedThumbnail(
entry: widget.imageEntry,
extent: extent,
showOverlay: false,
),
),
),
],

View file

@ -64,7 +64,7 @@ class DecoratedThumbnail extends StatelessWidget {
);
}
return Container(
decoration: BoxDecoration(
foregroundDecoration: BoxDecoration(
border: Border.all(
color: borderColor,
width: borderWidth,

View file

@ -1,11 +1,17 @@
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/mime_types.dart';
import 'package:flutter/material.dart';
class ErrorThumbnail extends StatelessWidget {
final ImageEntry entry;
final double extent;
final String tooltip;
const ErrorThumbnail({@required this.extent, @required this.tooltip});
const ErrorThumbnail({
@required this.entry,
@required this.extent,
@required this.tooltip,
});
@override
Widget build(BuildContext context) {
@ -13,10 +19,13 @@ class ErrorThumbnail extends StatelessWidget {
child: Tooltip(
message: tooltip,
preferBelow: false,
child: Icon(
AIcons.error,
size: extent / 2,
color: Colors.blueGrey,
child: Text(
MimeTypes.displayType(entry.mimeType),
style: TextStyle(
color: Colors.blueGrey,
fontSize: extent / 5,
),
textAlign: TextAlign.center,
),
),
);

View file

@ -97,7 +97,7 @@ class ThumbnailSelectionOverlay extends StatelessWidget {
);
child = AnimatedContainer(
duration: duration,
alignment: Alignment.topRight,
alignment: AlignmentDirectional.topEnd,
color: selected ? Colors.black54 : Colors.transparent,
child: child,
);

View file

@ -71,8 +71,6 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
_pauseProvider();
}
bool get isSupported => entry.canDecode;
void _initProvider() {
if (!entry.canDecode) return;
@ -101,6 +99,7 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
Widget build(BuildContext context) {
if (!entry.canDecode) {
return ErrorThumbnail(
entry: entry,
extent: extent,
tooltip: '${entry.mimeType} not supported',
);
@ -139,6 +138,7 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
);
},
errorBuilder: (context, error, stackTrace) => ErrorThumbnail(
entry: entry,
extent: extent,
tooltip: error.toString(),
),

View file

@ -9,12 +9,13 @@ class AvesExpansionTile extends StatelessWidget {
const AvesExpansionTile({
@required this.title,
@required this.children,
this.expandedNotifier,
@required this.children,
});
@override
Widget build(BuildContext context) {
final enabled = children?.isNotEmpty == true;
return Theme(
data: Theme.of(context).copyWith(
// color used by the `ExpansionTileCard` for selected text and icons
@ -27,12 +28,17 @@ class AvesExpansionTile extends StatelessWidget {
title: HighlightTitle(
title,
fontSize: 18,
enabled: enabled,
),
expandable: enabled,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Divider(thickness: 1, height: 1),
SizedBox(height: 4),
if (enabled) ...children,
],
),
children: [
Divider(thickness: 1, height: 1),
SizedBox(height: 4),
...children,
],
baseColor: Colors.grey[900],
expandedColor: Colors.grey[850],
),

View file

@ -201,7 +201,10 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
if (widget.heroType == HeroType.always || widget.heroType == HeroType.onTap && _tapped) {
chip = Hero(
tag: filter,
child: chip,
child: DefaultTextStyle(
style: TextStyle(),
child: chip,
),
);
}
return chip;

View file

@ -5,11 +5,15 @@ import 'package:flutter/material.dart';
class HighlightTitle extends StatelessWidget {
final String name;
final double fontSize;
final bool enabled;
const HighlightTitle(
this.name, {
this.fontSize = 20,
});
this.enabled = true,
}) : assert(name != null);
static const disabledColor = Colors.grey;
@override
Widget build(BuildContext context) {
@ -17,7 +21,7 @@ class HighlightTitle extends StatelessWidget {
alignment: AlignmentDirectional.centerStart,
child: Container(
decoration: HighlightDecoration(
color: stringToColor(name),
color: enabled ? stringToColor(name) : disabledColor,
),
margin: EdgeInsets.symmetric(vertical: 4.0),
child: Text(

View file

@ -0,0 +1,131 @@
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';
class RegionProvider extends ImageProvider<RegionProviderKey> {
final RegionProviderKey key;
RegionProvider(this.key) : assert(key != null);
@override
Future<RegionProviderKey> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture<RegionProviderKey>(key);
}
@override
ImageStreamCompleter load(RegionProviderKey key, DecoderCallback decode) {
return MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode),
scale: key.scale,
informationCollector: () sync* {
yield ErrorDescription('uri=${key.uri}, regionRect=${key.regionRect}');
},
);
}
Future<ui.Codec> _loadAsync(RegionProviderKey key, DecoderCallback decode) async {
final uri = key.uri;
final mimeType = key.mimeType;
try {
final bytes = await ImageFileService.getRegion(
uri,
mimeType,
key.rotationDegrees,
key.isFlipped,
key.sampleSize,
key.regionRect,
key.imageSize,
taskKey: key,
);
if (bytes == null) {
throw StateError('$uri ($mimeType) region loading failed');
}
return await decode(bytes);
} catch (error) {
debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error');
throw StateError('$mimeType region decoding failed');
}
}
@override
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, RegionProviderKey key, ImageErrorListener handleError) {
ImageFileService.resumeLoading(key);
super.resolveStreamForKey(configuration, stream, key, handleError);
}
void pause() => ImageFileService.cancelRegion(key);
}
class RegionProviderKey {
final String uri, mimeType;
final int rotationDegrees, sampleSize;
final bool isFlipped;
final Rectangle<int> regionRect;
final Size imageSize;
final double scale;
const RegionProviderKey({
@required this.uri,
@required this.mimeType,
@required this.rotationDegrees,
@required this.isFlipped,
@required this.sampleSize,
@required this.regionRect,
@required this.imageSize,
this.scale = 1.0,
}) : assert(uri != null),
assert(mimeType != null),
assert(rotationDegrees != null),
assert(isFlipped != null),
assert(sampleSize != null),
assert(regionRect != 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, {
@required int sampleSize,
@required Rectangle<int> rect,
}) {
return RegionProviderKey(
uri: entry.uri,
mimeType: entry.mimeType,
rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
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.sampleSize == sampleSize && other.regionRect == regionRect && other.imageSize == imageSize && other.scale == scale;
}
@override
int get hashCode => hashValues(
uri,
mimeType,
rotationDegrees,
isFlipped,
mimeType,
sampleSize,
regionRect,
imageSize,
scale,
);
@override
String toString() {
return 'RegionProviderKey(uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, regionRect=$regionRect, imageSize=$imageSize, scale=$scale)';
}
}

View file

@ -30,8 +30,8 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
}
Future<ui.Codec> _loadAsync(ThumbnailProviderKey key, DecoderCallback decode) async {
var uri = key.uri;
var mimeType = key.mimeType;
final uri = key.uri;
final mimeType = key.mimeType;
try {
final bytes = await ImageFileService.getThumbnail(
uri,
@ -55,7 +55,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
@override
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, ThumbnailProviderKey key, ImageErrorListener handleError) {
ImageFileService.resumeThumbnail(key);
ImageFileService.resumeLoading(key);
super.resolveStreamForKey(configuration, stream, key, handleError);
}
@ -105,7 +105,15 @@ class ThumbnailProviderKey {
}
@override
int get hashCode => hashValues(uri, mimeType, dateModifiedSecs, rotationDegrees, isFlipped, extent, scale);
int get hashCode => hashValues(
uri,
mimeType,
dateModifiedSecs,
rotationDegrees,
isFlipped,
extent,
scale,
);
@override
String toString() {

View file

@ -73,7 +73,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.scale == scale;
return other is UriImage && other.uri == uri && other.scale == scale;
}
@override

View file

@ -0,0 +1,47 @@
import 'dart:collection';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/widgets/common/aves_expansion_tile.dart';
import 'package:aves/widgets/fullscreen/info/common.dart';
import 'package:flutter/material.dart';
class DebugAndroidEnvironmentSection extends StatefulWidget {
@override
_DebugAndroidEnvironmentSectionState createState() => _DebugAndroidEnvironmentSectionState();
}
class _DebugAndroidEnvironmentSectionState extends State<DebugAndroidEnvironmentSection> with AutomaticKeepAliveClientMixin {
Future<Map> _loader;
@override
void initState() {
super.initState();
_loader = AndroidAppService.getEnv();
}
@override
Widget build(BuildContext context) {
super.build(context);
return AvesExpansionTile(
title: 'Android Environment',
children: [
Padding(
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: FutureBuilder<Map>(
future: _loader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
final data = SplayTreeMap.of(snapshot.data.map((k, v) => MapEntry(k.toString(), v?.toString() ?? 'null')));
return InfoRowGroup(data);
},
),
),
],
);
}
@override
bool get wantKeepAlive => true;
}

View file

@ -0,0 +1,107 @@
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/widgets/common/aves_expansion_tile.dart';
import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart';
import 'package:aves/widgets/debug/android_env.dart';
import 'package:aves/widgets/debug/cache.dart';
import 'package:aves/widgets/debug/database.dart';
import 'package:aves/widgets/debug/firebase.dart';
import 'package:aves/widgets/debug/overlay.dart';
import 'package:aves/widgets/debug/settings.dart';
import 'package:aves/widgets/debug/storage.dart';
import 'package:aves/widgets/fullscreen/info/common.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
class AppDebugPage extends StatefulWidget {
static const routeName = '/debug';
final CollectionSource source;
const AppDebugPage({this.source});
@override
State<StatefulWidget> createState() => AppDebugPageState();
}
class AppDebugPageState extends State<AppDebugPage> {
List<ImageEntry> get entries => widget.source.rawEntries;
static OverlayEntry _taskQueueOverlayEntry;
@override
Widget build(BuildContext context) {
return MediaQueryDataProvider(
child: Scaffold(
appBar: AppBar(
title: Text('Debug'),
),
body: SafeArea(
child: ListView(
padding: EdgeInsets.all(8),
children: [
_buildGeneralTabView(),
DebugAndroidEnvironmentSection(),
DebugCacheSection(),
DebugAppDatabaseSection(),
DebugFirebaseSection(),
DebugSettingsSection(),
DebugStorageSection(),
],
),
),
),
);
}
Widget _buildGeneralTabView() {
final catalogued = entries.where((entry) => entry.isCatalogued);
final withGps = catalogued.where((entry) => entry.hasGps);
final located = withGps.where((entry) => entry.isLocated);
return AvesExpansionTile(
title: 'General',
children: [
Padding(
padding: EdgeInsets.all(8),
child: Text('Time dilation'),
),
Slider(
value: timeDilation,
onChanged: (v) => setState(() => timeDilation = v),
min: 1.0,
max: 10.0,
divisions: 9,
label: '$timeDilation',
),
SwitchListTile(
value: _taskQueueOverlayEntry != null,
onChanged: (v) {
_taskQueueOverlayEntry?.remove();
if (v) {
_taskQueueOverlayEntry = OverlayEntry(
builder: (context) => DebugTaskQueueOverlay(),
);
Overlay.of(context).insert(_taskQueueOverlayEntry);
} else {
_taskQueueOverlayEntry = null;
}
setState(() {});
},
title: Text('Show tasks overlay'),
),
Divider(),
Padding(
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup(
{
'Entries': '${entries.length}',
'Catalogued': '${catalogued.length}',
'With GPS': '${withGps.length}',
'With address': '${located.length}',
},
),
),
],
);
}
}

View file

@ -0,0 +1,77 @@
import 'package:aves/services/image_file_service.dart';
import 'package:aves/utils/file_utils.dart';
import 'package:aves/widgets/common/aves_expansion_tile.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
class DebugCacheSection extends StatefulWidget {
@override
_DebugCacheSectionState createState() => _DebugCacheSectionState();
}
class _DebugCacheSectionState extends State<DebugCacheSection> with AutomaticKeepAliveClientMixin {
@override
Widget build(BuildContext context) {
super.build(context);
return AvesExpansionTile(
title: 'Cache',
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Column(
children: [
Row(
children: [
Expanded(
child: Text('Image cache:\n\t${imageCache.currentSize}/${imageCache.maximumSize} items\n\t${formatFilesize(imageCache.currentSizeBytes)}/${formatFilesize(imageCache.maximumSizeBytes)}'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () {
imageCache.clear();
setState(() {});
},
child: Text('Clear'),
),
],
),
Row(
children: [
Expanded(
child: Text('SVG cache: ${PictureProvider.cacheCount} items'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () {
PictureProvider.clearCache();
setState(() {});
},
child: Text('Clear'),
),
],
),
Row(
children: [
Expanded(
child: Text('Glide disk cache: ?'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: ImageFileService.clearSizedThumbnailDiskCache,
child: Text('Clear'),
),
],
),
],
),
),
],
);
}
@override
bool get wantKeepAlive => true;
}

View file

@ -0,0 +1,184 @@
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/utils/file_utils.dart';
import 'package:aves/widgets/common/aves_expansion_tile.dart';
import 'package:flutter/material.dart';
class DebugAppDatabaseSection extends StatefulWidget {
@override
_DebugAppDatabaseSectionState createState() => _DebugAppDatabaseSectionState();
}
class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with AutomaticKeepAliveClientMixin {
Future<int> _dbFileSizeLoader;
Future<List<ImageEntry>> _dbEntryLoader;
Future<List<DateMetadata>> _dbDateLoader;
Future<List<CatalogMetadata>> _dbMetadataLoader;
Future<List<AddressDetails>> _dbAddressLoader;
Future<List<FavouriteRow>> _dbFavouritesLoader;
@override
void initState() {
super.initState();
_startDbReport();
}
@override
Widget build(BuildContext context) {
super.build(context);
return AvesExpansionTile(
title: 'Database',
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Column(
children: [
FutureBuilder<int>(
future: _dbFileSizeLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row(
children: [
Expanded(
child: Text('DB file size: ${formatFilesize(snapshot.data)}'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => metadataDb.reset().then((_) => _startDbReport()),
child: Text('Reset'),
),
],
);
},
),
FutureBuilder<List>(
future: _dbEntryLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row(
children: [
Expanded(
child: Text('entry rows: ${snapshot.data.length}'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => metadataDb.clearEntries().then((_) => _startDbReport()),
child: Text('Clear'),
),
],
);
},
),
FutureBuilder<List>(
future: _dbDateLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row(
children: [
Expanded(
child: Text('date rows: ${snapshot.data.length}'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => metadataDb.clearDates().then((_) => _startDbReport()),
child: Text('Clear'),
),
],
);
},
),
FutureBuilder<List>(
future: _dbMetadataLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row(
children: [
Expanded(
child: Text('metadata rows: ${snapshot.data.length}'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => metadataDb.clearMetadataEntries().then((_) => _startDbReport()),
child: Text('Clear'),
),
],
);
},
),
FutureBuilder<List>(
future: _dbAddressLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row(
children: [
Expanded(
child: Text('address rows: ${snapshot.data.length}'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => metadataDb.clearAddresses().then((_) => _startDbReport()),
child: Text('Clear'),
),
],
);
},
),
FutureBuilder<List>(
future: _dbFavouritesLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row(
children: [
Expanded(
child: Text('favourite rows: ${snapshot.data.length} (${favourites.count} in memory)'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => favourites.clear().then((_) => _startDbReport()),
child: Text('Clear'),
),
],
);
},
),
],
),
),
],
);
}
void _startDbReport() {
_dbFileSizeLoader = metadataDb.dbFileSize();
_dbEntryLoader = metadataDb.loadEntries();
_dbDateLoader = metadataDb.loadDates();
_dbMetadataLoader = metadataDb.loadMetadataEntries();
_dbAddressLoader = metadataDb.loadAddresses();
_dbFavouritesLoader = metadataDb.loadFavourites();
setState(() {});
}
@override
bool get wantKeepAlive => true;
}

View file

@ -0,0 +1,43 @@
import 'package:aves/widgets/common/aves_expansion_tile.dart';
import 'package:aves/widgets/fullscreen/info/common.dart';
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/material.dart';
class DebugFirebaseSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
return AvesExpansionTile(
title: 'Firebase',
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
ElevatedButton(
onPressed: FirebaseCrashlytics.instance.crash,
child: Text('Crash'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => FirebaseAnalytics().logEvent(
name: 'debug_test',
parameters: {'time': DateTime.now().toIso8601String()},
),
child: Text('Send event'),
),
],
),
),
Padding(
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup({
'Firebase data collection enabled': '${Firebase.app().isAutomaticDataCollectionEnabled}',
'Crashlytics collection enabled': '${FirebaseCrashlytics.instance.isCrashlyticsCollectionEnabled}',
}),
)
],
);
}
}

View file

@ -0,0 +1,39 @@
import 'package:aves/services/service_policy.dart';
import 'package:flutter/material.dart';
class DebugTaskQueueOverlay extends StatelessWidget {
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: DefaultTextStyle(
style: TextStyle(),
child: Align(
alignment: AlignmentDirectional.bottomStart,
child: SafeArea(
child: Container(
color: Colors.indigo[900].withAlpha(0xCC),
margin: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
padding: EdgeInsets.all(8),
child: StreamBuilder<QueueState>(
stream: servicePolicy.queueStream,
builder: (context, snapshot) {
if (snapshot.hasError) return SizedBox.shrink();
final queuedEntries = (snapshot.hasData ? snapshot.data.queueByPriority.entries.toList() : []);
queuedEntries.sort((a, b) => a.key.compareTo(b.key));
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(queuedEntries.map((kv) => '${kv.key}: ${kv.value}').join(', ')),
],
);
}),
),
),
),
),
);
}
}

View file

@ -0,0 +1,40 @@
import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/aves_expansion_tile.dart';
import 'package:aves/widgets/fullscreen/info/common.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class DebugSettingsSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<Settings>(builder: (context, settings, child) {
String toMultiline(Iterable l) => l.isNotEmpty ? '\n${l.join('\n')}' : '$l';
return AvesExpansionTile(
title: 'Settings',
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: ElevatedButton(
onPressed: () => settings.reset(),
child: Text('Reset'),
),
),
SwitchListTile(
value: settings.hasAcceptedTerms,
onChanged: (v) => settings.hasAcceptedTerms = v,
title: Text('hasAcceptedTerms'),
),
Padding(
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup({
'collectionTileExtent': '${settings.collectionTileExtent}',
'infoMapZoom': '${settings.infoMapZoom}',
'pinnedFilters': toMultiline(settings.pinnedFilters),
'searchHistory': toMultiline(settings.searchHistory),
}),
),
],
);
});
}
}

View file

@ -0,0 +1,32 @@
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/common/aves_expansion_tile.dart';
import 'package:aves/widgets/fullscreen/info/common.dart';
import 'package:flutter/material.dart';
class DebugStorageSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
return AvesExpansionTile(
title: 'Storage Volumes',
children: [
...androidFileUtils.storageVolumes.expand((v) => [
Padding(
padding: EdgeInsets.all(8),
child: Text(v.path),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: InfoRowGroup({
'description': '${v.description}',
'isEmulated': '${v.isEmulated}',
'isPrimary': '${v.isPrimary}',
'isRemovable': '${v.isRemovable}',
'state': '${v.state}',
}),
),
Divider(),
])
],
);
}
}

View file

@ -10,9 +10,9 @@ import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/tag.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/about/about_page.dart';
import 'package:aves/widgets/app_debug_page.dart';
import 'package:aves/widgets/common/aves_logo.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/debug/app_debug_page.dart';
import 'package:aves/widgets/drawer/collection_tile.dart';
import 'package:aves/widgets/drawer/tile.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';

View file

@ -18,7 +18,7 @@ class MetadataTab extends StatefulWidget {
}
class _MetadataTabState extends State<MetadataTab> {
Future<Map> _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader;
Future<Map> _bitmapFactoryLoader, _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader, _metadataExtractorLoader;
// MediaStore timestamp keys
static const secondTimestampKeys = ['date_added', 'date_modified', 'date_expires', 'isPlayed'];
@ -33,9 +33,11 @@ class _MetadataTabState extends State<MetadataTab> {
}
void _loadMetadata() {
_bitmapFactoryLoader = MetadataService.getBitmapFactoryInfo(entry);
_contentResolverMetadataLoader = MetadataService.getContentResolverMetadata(entry);
_exifInterfaceMetadataLoader = MetadataService.getExifInterfaceMetadata(entry);
_mediaMetadataLoader = MetadataService.getMediaMetadataRetrieverMetadata(entry);
_metadataExtractorLoader = MetadataService.getMetadataExtractorSummary(entry);
setState(() {});
}
@ -60,22 +62,27 @@ class _MetadataTabState extends State<MetadataTab> {
}));
return AvesExpansionTile(
title: title,
children: [
Container(
alignment: AlignmentDirectional.topStart,
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup(
data,
maxValueLength: Constants.infoGroupMaxValueLength,
),
)
],
children: data.isNotEmpty
? [
Padding(
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup(
data,
maxValueLength: Constants.infoGroupMaxValueLength,
),
)
]
: null,
);
}
return ListView(
padding: EdgeInsets.all(8),
children: [
FutureBuilder<Map>(
future: _bitmapFactoryLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Bitmap Factory'),
),
FutureBuilder<Map>(
future: _contentResolverMetadataLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Content Resolver'),
@ -88,6 +95,10 @@ class _MetadataTabState extends State<MetadataTab> {
future: _mediaMetadataLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Media Metadata Retriever'),
),
FutureBuilder<Map>(
future: _metadataExtractorLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Metadata Extractor'),
),
],
);
}

View file

@ -10,6 +10,7 @@ import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/action_delegates/entry_action_delegate.dart';
import 'package:aves/widgets/fullscreen/image_page.dart';
import 'package:aves/widgets/fullscreen/image_view.dart';
import 'package:aves/widgets/fullscreen/info/info_page.dart';
import 'package:aves/widgets/fullscreen/info/notifications.dart';
import 'package:aves/widgets/fullscreen/overlay/bottom.dart';
@ -52,6 +53,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
EdgeInsets _frozenViewInsets, _frozenViewPadding;
EntryActionDelegate _actionDelegate;
final List<Tuple2<String, IjkMediaController>> _videoControllers = [];
final List<Tuple2<String, ValueNotifier<ViewState>>> _viewStateNotifiers = [];
CollectionLens get collection => widget.collection;
@ -97,7 +99,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
collection: collection,
showInfo: () => _goToVerticalPage(infoPage),
);
_initVideoController();
_initViewStateControllers();
_registerWidget(widget);
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) => _initOverlay());
@ -154,7 +156,11 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
},
child: NotificationListener(
onNotification: (notification) {
if (notification is FilterNotification) _goToCollection(notification.filter);
if (notification is FilterNotification) {
_goToCollection(notification.filter);
} else if (notification is ViewStateNotification) {
_updateViewState(notification.uri, notification.viewState);
}
return false;
},
child: Stack(
@ -169,6 +175,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
onHorizontalPageChanged: _onHorizontalPageChanged,
onImageTap: () => _overlayVisible.value = !_overlayVisible.value,
onImagePageRequested: () => _goToVerticalPage(imagePage),
onViewDisposed: (uri) => _updateViewState(uri, null),
),
_buildTopOverlay(),
_buildBottomOverlay(),
@ -178,11 +185,17 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
);
}
void _updateViewState(String uri, ViewState viewState) {
final viewStateNotifier = _viewStateNotifiers.firstWhere((kv) => kv.item1 == uri, orElse: () => null)?.item2;
viewStateNotifier?.value = viewState ?? ViewState.zero;
}
Widget _buildTopOverlay() {
final child = ValueListenableBuilder<ImageEntry>(
valueListenable: _entryNotifier,
builder: (context, entry, child) {
if (entry == null) return SizedBox.shrink();
final viewStateNotifier = _viewStateNotifiers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
return FullscreenTopOverlay(
entry: entry,
scale: _topOverlayScale,
@ -190,6 +203,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
onActionSelected: (action) => _actionDelegate.onActionSelected(context, entry, action),
viewStateNotifier: viewStateNotifier,
);
},
);
@ -324,7 +338,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
if (_entryNotifier.value == newEntry) return;
_entryNotifier.value = newEntry;
_pauseVideoControllers();
_initVideoController();
_initViewStateControllers();
}
void _onLeave() {
@ -381,28 +395,45 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
}
}
// video controller
// state controllers/monitors
void _pauseVideoControllers() => _videoControllers.forEach((e) => e.item2.pause());
Future<void> _initVideoController() async {
void _initViewStateControllers() {
final entry = _entryNotifier.value;
if (entry == null || !entry.isVideo) return;
if (entry == null) return;
final uri = entry.uri;
var controllerEntry = _videoControllers.firstWhere((kv) => kv.item1 == uri, orElse: () => null);
if (controllerEntry != null) {
_videoControllers.remove(controllerEntry);
} else {
// do not set data source of IjkMediaController here
controllerEntry = Tuple2(uri, IjkMediaController());
}
_videoControllers.insert(0, controllerEntry);
while (_videoControllers.length > 3) {
_videoControllers.removeLast().item2.dispose();
_initViewSpecificController<ValueNotifier<ViewState>>(
uri,
_viewStateNotifiers,
() => ValueNotifier<ViewState>(ViewState.zero),
(_) => _.dispose(),
);
if (entry.isVideo) {
_initViewSpecificController<IjkMediaController>(
uri,
_videoControllers,
() => IjkMediaController(),
(_) => _.dispose(),
);
}
setState(() {});
}
void _initViewSpecificController<T>(String uri, List<Tuple2<String, T>> controllers, T Function() builder, void Function(T controller) disposer) {
var controller = controllers.firstWhere((kv) => kv.item1 == uri, orElse: () => null);
if (controller != null) {
controllers.remove(controller);
} else {
controller = Tuple2(uri, builder());
}
controllers.insert(0, controller);
while (controllers.length > 3) {
disposer?.call(controllers.removeLast().item2);
}
}
void _pauseVideoControllers() => _videoControllers.forEach((e) => e.item2.pause());
}
class FullscreenVerticalPageView extends StatefulWidget {
@ -412,6 +443,7 @@ class FullscreenVerticalPageView extends StatefulWidget {
final PageController horizontalPager, verticalPager;
final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged;
final VoidCallback onImageTap, onImagePageRequested;
final void Function(String uri) onViewDisposed;
const FullscreenVerticalPageView({
@required this.collection,
@ -423,6 +455,7 @@ class FullscreenVerticalPageView extends StatefulWidget {
@required this.onHorizontalPageChanged,
@required this.onImageTap,
@required this.onImagePageRequested,
@required this.onViewDisposed,
});
@override
@ -483,6 +516,7 @@ class _FullscreenVerticalPageViewState extends State<FullscreenVerticalPageView>
onTap: widget.onImageTap,
onPageChanged: widget.onHorizontalPageChanged,
videoControllers: widget.videoControllers,
onViewDisposed: widget.onViewDisposed,
)
: SingleImagePage(
entry: entry,

View file

@ -10,17 +10,17 @@ class MultiImagePage extends StatefulWidget {
final CollectionLens collection;
final PageController pageController;
final ValueChanged<int> onPageChanged;
final ValueChanged<PhotoViewScaleState> onScaleChanged;
final VoidCallback onTap;
final List<Tuple2<String, IjkMediaController>> videoControllers;
final void Function(String uri) onViewDisposed;
const MultiImagePage({
this.collection,
this.pageController,
this.onPageChanged,
this.onScaleChanged,
this.onTap,
this.videoControllers,
this.onViewDisposed,
});
@override
@ -49,9 +49,9 @@ class MultiImagePageState extends State<MultiImagePage> with AutomaticKeepAliveC
key: Key('imageview'),
entry: entry,
heroTag: widget.collection.heroTag(entry),
onScaleChanged: widget.onScaleChanged,
onTap: widget.onTap,
videoControllers: widget.videoControllers,
onDisposed: () => widget.onViewDisposed?.call(entry.uri),
),
);
},
@ -66,13 +66,11 @@ class MultiImagePageState extends State<MultiImagePage> with AutomaticKeepAliveC
class SingleImagePage extends StatefulWidget {
final ImageEntry entry;
final ValueChanged<PhotoViewScaleState> onScaleChanged;
final VoidCallback onTap;
final List<Tuple2<String, IjkMediaController>> videoControllers;
const SingleImagePage({
this.entry,
this.onScaleChanged,
this.onTap,
this.videoControllers,
});
@ -90,7 +88,6 @@ class SingleImagePageState extends State<SingleImagePage> with AutomaticKeepAliv
axis: [Axis.vertical],
child: ImageView(
entry: widget.entry,
onScaleChanged: widget.onScaleChanged,
onTap: widget.onTap,
videoControllers: widget.videoControllers,
),

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/collection/empty.dart';
@ -5,133 +7,221 @@ import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart';
import 'package:aves/widgets/common/image_providers/uri_image_provider.dart';
import 'package:aves/widgets/common/image_providers/uri_picture_provider.dart';
import 'package:aves/widgets/fullscreen/tiled_view.dart';
import 'package:aves/widgets/fullscreen/video_view.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:photo_view/photo_view.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class ImageView extends StatelessWidget {
class ImageView extends StatefulWidget {
final ImageEntry entry;
final Object heroTag;
final ValueChanged<PhotoViewScaleState> onScaleChanged;
final VoidCallback onTap;
final List<Tuple2<String, IjkMediaController>> videoControllers;
final VoidCallback onDisposed;
const ImageView({
Key key,
this.entry,
@required this.entry,
this.heroTag,
this.onScaleChanged,
this.onTap,
this.videoControllers,
@required this.onTap,
@required this.videoControllers,
this.onDisposed,
}) : super(key: key);
@override
_ImageViewState createState() => _ImageViewState();
}
class _ImageViewState extends State<ImageView> {
final PhotoViewController _photoViewController = PhotoViewController();
final ValueNotifier<ViewState> _viewStateNotifier = ValueNotifier<ViewState>(ViewState.zero);
StreamSubscription<PhotoViewControllerValue> _subscription;
Size _photoViewChildSize;
static const backgroundDecoration = BoxDecoration(color: Colors.transparent);
static const maxScale = 2.0;
ImageEntry get entry => widget.entry;
VoidCallback get onTap => widget.onTap;
@override
void initState() {
super.initState();
_subscription = _photoViewController.outputStateStream.listen(_onViewChanged);
if (entry.isVideo || (!entry.isSvg && entry.canDecode && useTile)) {
_photoViewChildSize = entry.displaySize;
}
}
@override
void dispose() {
_subscription.cancel();
_subscription = null;
widget.onDisposed?.call();
super.dispose();
}
@override
Widget build(BuildContext context) {
const backgroundDecoration = BoxDecoration(color: Colors.transparent);
// no hero for videos, as a typical video first frame is different from its thumbnail
if (entry.isVideo) {
final videoController = videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
return PhotoView.customChild(
child: videoController != null
? AvesVideo(
entry: entry,
controller: videoController,
)
: SizedBox(),
backgroundDecoration: backgroundDecoration,
scaleStateChangedCallback: onScaleChanged,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => onTap?.call(),
);
}
// if the hero tag is defined in the `loadingBuilder` and also set by the `heroAttributes`,
// the route transition becomes visible if the final is loaded before the hero animation is done.
// if the hero tag wraps the whole `PhotoView` and the `loadingBuilder` is not provided,
// there's a black frame between the hero animation and the final image, even when it's cached.
final fastThumbnailProvider = ThumbnailProvider(ThumbnailProviderKey.fromEntry(entry));
// this loading builder shows a transition image until the final image is ready
// if the image is already in the cache it will show the final image, otherwise the thumbnail
// in any case, we should use `Center` + `AspectRatio` + `Fill` so that the transition image
// appears as the final image with `PhotoViewComputedScale.contained` for `initialScale`
Widget loadingBuilder(BuildContext context, ImageProvider imageProvider) {
return Center(
child: AspectRatio(
// enforce original aspect ratio, as some thumbnails aspect ratios slightly differ
aspectRatio: entry.displayAspectRatio,
child: Image(
image: imageProvider,
fit: BoxFit.fill,
),
),
);
}
Widget child;
if (entry.isSvg) {
final colorFilter = ColorFilter.mode(Color(settings.svgBackground), BlendMode.dstOver);
child = PhotoView.customChild(
child: SvgPicture(
UriPicture(
uri: entry.uri,
mimeType: entry.mimeType,
),
placeholderBuilder: (context) => loadingBuilder(context, fastThumbnailProvider),
colorFilter: colorFilter,
),
backgroundDecoration: backgroundDecoration,
scaleStateChangedCallback: onScaleChanged,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => onTap?.call(),
);
if (entry.isVideo) {
child = _buildVideoView();
} else if (entry.isSvg) {
child = _buildSvgView();
} else if (entry.canDecode) {
final uriImage = UriImage(
uri: entry.uri,
mimeType: entry.mimeType,
rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
expectedContentLength: entry.sizeBytes,
);
child = PhotoView(
// key includes size and orientation to refresh when the image is rotated
key: ValueKey('${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'),
imageProvider: uriImage,
// when the full image is ready, we use it in the `loadingBuilder`
// we still provide a `loadingBuilder` in that case to avoid a black frame after hero animation
loadingBuilder: (context, event) => loadingBuilder(
context,
imageCache.statusForKey(uriImage).keepAlive ? uriImage : fastThumbnailProvider,
),
loadFailedChild: _buildError(),
backgroundDecoration: backgroundDecoration,
scaleStateChangedCallback: onScaleChanged,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => onTap?.call(),
filterQuality: FilterQuality.low,
);
if (useTile) {
child = _buildTiledImageView();
} else {
child = _buildImageView();
}
} else {
child = _buildError();
}
return heroTag != null
// if the hero tag is defined in the `loadingBuilder` and also set by the `heroAttributes`,
// the route transition becomes visible if the final image is loaded before the hero animation is done.
// if the hero tag wraps the whole `PhotoView` and the `loadingBuilder` is not provided,
// there's a black frame between the hero animation and the final image, even when it's cached.
// no hero for videos, as a typical video first frame is different from its thumbnail
return widget.heroTag != null && !entry.isVideo
? Hero(
tag: heroTag,
tag: widget.heroTag,
transitionOnUserGestures: true,
child: child,
)
: child;
}
// the images loaded by `PhotoView` cannot have a width or height larger than 8192
// so the reported offset and scale does not match expected values derived from the original dimensions
// besides, large images should be tiled to be memory-friendly
bool get useTile => entry.canTile && (entry.width > 4096 || entry.height > 4096);
ImageProvider get fastThumbnailProvider => ThumbnailProvider(ThumbnailProviderKey.fromEntry(entry));
// this loading builder shows a transition image until the final image is ready
// if the image is already in the cache it will show the final image, otherwise the thumbnail
// in any case, we should use `Center` + `AspectRatio` + `BoxFit.fill` so that the transition image
// appears as the final image with `PhotoViewComputedScale.contained` for `initialScale`
Widget _loadingBuilder(BuildContext context, ImageProvider imageProvider) {
return Center(
child: AspectRatio(
// enforce original aspect ratio, as some thumbnails aspect ratios slightly differ
aspectRatio: entry.displayAspectRatio,
child: Image(
image: imageProvider,
fit: BoxFit.fill,
),
),
);
}
Widget _buildImageView() {
final uriImage = UriImage(
uri: entry.uri,
mimeType: entry.mimeType,
rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
expectedContentLength: entry.sizeBytes,
);
return PhotoView(
// key includes size and orientation to refresh when the image is rotated
key: ValueKey('${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'),
imageProvider: uriImage,
// when the full image is ready, we use it in the `loadingBuilder`
// we still provide a `loadingBuilder` in that case to avoid a black frame after hero animation
loadingBuilder: (context, event) => _loadingBuilder(
context,
imageCache.statusForKey(uriImage).keepAlive ? uriImage : fastThumbnailProvider,
),
loadFailedChild: _buildError(),
backgroundDecoration: backgroundDecoration,
imageSizedCallback: (size) {
// do not directly update the `ViewState` notifier as this callback is called during build
_photoViewChildSize = size;
},
controller: _photoViewController,
maxScale: maxScale,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => onTap?.call(),
filterQuality: FilterQuality.low,
);
}
Widget _buildTiledImageView() {
return PhotoView.customChild(
// key includes size and orientation to refresh when the image is rotated
key: ValueKey('${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'),
child: Selector<MediaQueryData, Size>(
selector: (context, mq) => mq.size,
builder: (context, mqSize, child) {
return TiledImageView(
entry: entry,
viewportSize: mqSize,
viewStateNotifier: _viewStateNotifier,
baseChild: _loadingBuilder(context, fastThumbnailProvider),
errorBuilder: (context, error, stackTrace) => _buildError(),
);
},
),
childSize: entry.displaySize,
backgroundDecoration: backgroundDecoration,
controller: _photoViewController,
maxScale: maxScale,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => onTap?.call(),
filterQuality: FilterQuality.low,
);
}
Widget _buildSvgView() {
final colorFilter = ColorFilter.mode(Color(settings.svgBackground), BlendMode.dstOver);
return PhotoView.customChild(
child: SvgPicture(
UriPicture(
uri: entry.uri,
mimeType: entry.mimeType,
),
placeholderBuilder: (context) => _loadingBuilder(context, fastThumbnailProvider),
colorFilter: colorFilter,
),
backgroundDecoration: backgroundDecoration,
controller: _photoViewController,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => onTap?.call(),
);
}
Widget _buildVideoView() {
final videoController = widget.videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
return PhotoView.customChild(
child: videoController != null
? AvesVideo(
entry: entry,
controller: videoController,
)
: SizedBox(),
childSize: entry.displaySize,
backgroundDecoration: backgroundDecoration,
controller: _photoViewController,
maxScale: maxScale,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => onTap?.call(),
);
}
Widget _buildError() => GestureDetector(
onTap: () => onTap?.call(),
// use a `Container` with a dummy color to make it expand
@ -145,4 +235,37 @@ class ImageView extends StatelessWidget {
),
),
);
void _onViewChanged(PhotoViewControllerValue v) {
final viewState = ViewState(v.position, v.scale, _photoViewChildSize);
_viewStateNotifier.value = viewState;
ViewStateNotification(entry.uri, viewState).dispatch(context);
}
}
class ViewState {
final Offset position;
final double scale;
final Size size;
static const ViewState zero = ViewState(Offset(0.0, 0.0), 0, null);
const ViewState(this.position, this.scale, this.size);
@override
String toString() {
return '$runtimeType#${shortHash(this)}{position=$position, scale=$scale, size=$size}';
}
}
class ViewStateNotification extends Notification {
final String uri;
final ViewState viewState;
const ViewStateNotification(this.uri, this.viewState);
@override
String toString() {
return '$runtimeType#${shortHash(this)}{uri=$uri, viewState=$viewState}';
}
}

View file

@ -29,7 +29,7 @@ class BasicSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final date = entry.bestDate;
final dateText = date != null ? '${DateFormat.yMMMd().format(date)}${DateFormat.Hm().format(date)}' : Constants.unknown;
final dateText = date != null ? '${DateFormat.yMMMd().format(date)}${DateFormat.Hm().format(date)}' : Constants.infoUnknown;
final showMegaPixels = entry.isPhoto && entry.megaPixels != null && entry.megaPixels > 0;
final resolutionText = '${entry.resolutionText}${showMegaPixels ? ' (${entry.megaPixels} MP)' : ''}';
@ -37,12 +37,12 @@ class BasicSection extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InfoRowGroup({
'Title': entry.bestTitle ?? Constants.unknown,
'Title': entry.bestTitle ?? Constants.infoUnknown,
'Date': dateText,
if (entry.isVideo) ..._buildVideoRows(),
if (!entry.isSvg) 'Resolution': resolutionText,
'Size': entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : Constants.unknown,
'URI': entry.uri ?? Constants.unknown,
'Size': entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : Constants.infoUnknown,
'URI': entry.uri ?? Constants.infoUnknown,
if (entry.path != null) 'Path': entry.path,
}),
_buildChips(),

View file

@ -1,17 +1,8 @@
import 'dart:math';
import 'package:aves/utils/math_utils.dart';
import 'package:latlong/latlong.dart';
const double piOver180 = PI / 180.0;
double toDegrees(double radians) {
return radians / piOver180;
}
double toRadians(double degrees) {
return degrees * piOver180;
}
LatLng calculateEndingGlobalCoordinates(LatLng start, double startBearing, double distance) {
var mSemiMajorAxis = 6378137.0; //WGS84 major axis
var mSemiMinorAxis = (1.0 - 1.0 / 298.257223563) * 6378137.0;

View file

@ -5,6 +5,7 @@ import 'package:aves/services/metadata_service.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/common/aves_expansion_tile.dart';
import 'package:aves/widgets/common/highlight_title.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/fullscreen/info/common.dart';
import 'package:aves/widgets/fullscreen/info/metadata_thumbnail.dart';
@ -122,15 +123,15 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
}
Widget _buildDirTileWithTitle(_MetadataDirectory dir) {
if (dir.name == xmpDirectory) {
return _buildXmpDirTile(dir);
}
Widget thumbnail;
final prefixChildren = <Widget>[];
switch (dir.name) {
case exifThumbnailDirectory:
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry);
break;
case xmpDirectory:
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry);
break;
case mediaDirectory:
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.embedded, entry: entry);
Widget builder(IconData data) => Padding(
@ -153,14 +154,9 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
title: dir.name,
expandedNotifier: _expandedDirectoryNotifier,
children: [
if (prefixChildren.isNotEmpty)
Align(
alignment: AlignmentDirectional.topStart,
child: Wrap(children: prefixChildren),
),
if (prefixChildren.isNotEmpty) Wrap(children: prefixChildren),
if (thumbnail != null) thumbnail,
Container(
alignment: Alignment.topLeft,
Padding(
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup(dir.tags, maxValueLength: Constants.infoGroupMaxValueLength),
),
@ -168,6 +164,42 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
);
}
Widget _buildXmpDirTile(_MetadataDirectory dir) {
final thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry);
final byNamespace = SplayTreeMap.of(
groupBy<MapEntry<String, String>, String>(dir.tags.entries, (kv) {
final fullKey = kv.key;
final i = fullKey.indexOf(':');
if (i == -1) return '';
return fullKey.substring(0, i);
}),
compareAsciiLowerCase,
);
return AvesExpansionTile(
title: dir.name,
expandedNotifier: _expandedDirectoryNotifier,
children: [
if (thumbnail != null) thumbnail,
Padding(
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: byNamespace.entries.expand((kv) {
final ns = kv.key;
final hasNamespace = ns.isNotEmpty;
final i = hasNamespace ? ns.length + 1 : 0;
final tags = Map.fromEntries(kv.value.map((kv) => MapEntry(kv.key.substring(i), kv.value)));
return [
if (hasNamespace) HighlightTitle(ns),
InfoRowGroup(tags, maxValueLength: Constants.infoGroupMaxValueLength),
];
}).toList(),
),
),
],
);
}
void _onMetadataChanged() {
_loadedMetadataUri.value = null;
_metadata = [];
@ -196,6 +228,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
_metadata = [];
_loadedMetadataUri.value = null;
}
_expandedDirectoryNotifier.value = null;
}
@override

View file

@ -6,6 +6,7 @@ import 'package:aves/model/settings/coordinate_format.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/common/fx/blurred.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/fullscreen/overlay/common.dart';
@ -150,25 +151,21 @@ class _FullscreenBottomOverlayContent extends AnimatedWidget {
final positionTitle = [
if (position != null) position,
if (entry.bestTitle != null) entry.bestTitle,
].join(' ');
].join(' ');
final hasShootingDetails = details != null && !details.isEmpty && settings.showOverlayShootingDetails;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (positionTitle.isNotEmpty) Text(positionTitle, strutStyle: Constants.overflowStrutStyle),
if (entry.hasGps)
Container(
padding: EdgeInsets.only(top: _interRowPadding),
child: _LocationRow(entry: entry),
),
_buildSoloLocationRow(),
if (twoColumns)
Padding(
padding: EdgeInsets.only(top: _interRowPadding),
child: Row(
children: [
Container(width: subRowWidth, child: _DateRow(entry)),
if (hasShootingDetails) Container(width: subRowWidth, child: _ShootingRow(details)),
_buildDuoShootingRow(subRowWidth, hasShootingDetails),
],
),
)
@ -178,12 +175,7 @@ class _FullscreenBottomOverlayContent extends AnimatedWidget {
width: subRowWidth,
child: _DateRow(entry),
),
if (hasShootingDetails)
Container(
padding: EdgeInsets.only(top: _interRowPadding),
width: subRowWidth,
child: _ShootingRow(details),
),
_buildSoloShootingRow(subRowWidth, hasShootingDetails),
],
],
);
@ -192,6 +184,58 @@ class _FullscreenBottomOverlayContent extends AnimatedWidget {
),
);
}
Widget _buildSoloLocationRow() => AnimatedSwitcher(
duration: Durations.fullscreenOverlayChangeAnimation,
switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: _soloTransition,
child: entry.hasGps
? Container(
padding: EdgeInsets.only(top: _interRowPadding),
child: _LocationRow(entry: entry),
)
: SizedBox.shrink(),
);
Widget _buildSoloShootingRow(double subRowWidth, bool hasShootingDetails) => AnimatedSwitcher(
duration: Durations.fullscreenOverlayChangeAnimation,
switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: _soloTransition,
child: hasShootingDetails
? Container(
padding: EdgeInsets.only(top: _interRowPadding),
width: subRowWidth,
child: _ShootingRow(details),
)
: SizedBox.shrink(),
);
Widget _buildDuoShootingRow(double subRowWidth, bool hasShootingDetails) => AnimatedSwitcher(
duration: Durations.fullscreenOverlayChangeAnimation,
switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: child,
),
child: hasShootingDetails
? Container(
width: subRowWidth,
child: _ShootingRow(details),
)
: SizedBox.shrink(),
);
static Widget _soloTransition(Widget child, Animation<double> animation) => FadeTransition(
opacity: animation,
child: SizeTransition(
axisAlignment: 1,
sizeFactor: animation,
child: child,
),
);
}
class _LocationRow extends AnimatedWidget {
@ -228,7 +272,7 @@ class _DateRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final date = entry.bestDate;
final dateText = date != null ? '${DateFormat.yMMMd().format(date)}${DateFormat.Hm().format(date)}' : Constants.unknown;
final dateText = date != null ? '${DateFormat.yMMMd().format(date)}${DateFormat.Hm().format(date)}' : Constants.overlayUnknown;
return Row(
children: [
DecoratedIcon(AIcons.date, shadows: [Constants.embossShadow], size: _iconSize),
@ -251,10 +295,10 @@ class _ShootingRow extends StatelessWidget {
children: [
DecoratedIcon(AIcons.shooting, shadows: [Constants.embossShadow], size: _iconSize),
SizedBox(width: _iconPadding),
Expanded(child: Text(details.aperture, strutStyle: Constants.overflowStrutStyle)),
Expanded(child: Text(details.exposureTime, strutStyle: Constants.overflowStrutStyle)),
Expanded(child: Text(details.focalLength, strutStyle: Constants.overflowStrutStyle)),
Expanded(child: Text(details.iso, strutStyle: Constants.overflowStrutStyle)),
Expanded(child: Text(details.aperture ?? Constants.overlayUnknown, strutStyle: Constants.overflowStrutStyle)),
Expanded(child: Text(details.exposureTime ?? Constants.overlayUnknown, strutStyle: Constants.overflowStrutStyle)),
Expanded(child: Text(details.focalLength ?? Constants.overlayUnknown, strutStyle: Constants.overflowStrutStyle)),
Expanded(child: Text(details.iso ?? Constants.overlayUnknown, strutStyle: Constants.overflowStrutStyle)),
],
);
}

View file

@ -0,0 +1,104 @@
import 'dart:math';
import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/fullscreen/image_view.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class Minimap extends StatelessWidget {
final ImageEntry entry;
final ValueNotifier<ViewState> viewStateNotifier;
final Size size;
static const defaultSize = Size(96, 96);
const Minimap({
@required this.entry,
@required this.viewStateNotifier,
this.size = defaultSize,
});
@override
Widget build(BuildContext context) {
return Selector<MediaQueryData, Size>(
selector: (context, mq) => mq.size,
builder: (context, mqSize, child) {
return AnimatedBuilder(
animation: viewStateNotifier,
builder: (context, child) {
final viewState = viewStateNotifier.value;
return CustomPaint(
painter: MinimapPainter(
viewportSize: mqSize,
entrySize: viewState.size ?? entry.displaySize,
viewCenterOffset: viewState.position,
viewScale: viewState.scale,
minimapBorderColor: Colors.white30,
),
size: size,
);
});
});
}
}
class MinimapPainter extends CustomPainter {
final Size entrySize, viewportSize;
final Offset viewCenterOffset;
final double viewScale;
final Color minimapBorderColor, viewportBorderColor;
const MinimapPainter({
@required this.viewportSize,
@required this.entrySize,
@required this.viewCenterOffset,
@required this.viewScale,
this.minimapBorderColor = Colors.white,
this.viewportBorderColor = Colors.white,
});
@override
void paint(Canvas canvas, Size size) {
final viewSize = entrySize * viewScale;
if (viewSize.isEmpty) return;
// hide minimap when image is in full view
if (viewportSize + Offset(precisionErrorTolerance, precisionErrorTolerance) >= viewSize) return;
final canvasScale = size.longestSide / viewSize.longestSide;
final scaledEntrySize = viewSize * canvasScale;
final scaledViewportSize = viewportSize * canvasScale;
final entryRect = Rect.fromCenter(
center: size.center(Offset.zero),
width: scaledEntrySize.width,
height: scaledEntrySize.height,
);
final viewportRect = Rect.fromCenter(
center: size.center(Offset.zero) - viewCenterOffset * canvasScale,
width: min(scaledEntrySize.width, scaledViewportSize.width),
height: min(scaledEntrySize.height, scaledViewportSize.height),
);
canvas.translate((entryRect.width - size.width) / 2, (entryRect.height - size.height) / 2);
final fill = Paint()
..style = PaintingStyle.fill
..color = Color(0x33000000);
final minimapStroke = Paint()
..style = PaintingStyle.stroke
..color = minimapBorderColor;
final viewportStroke = Paint()
..style = PaintingStyle.stroke
..color = viewportBorderColor;
canvas.drawRect(viewportRect, fill);
canvas.drawRect(entryRect, fill);
canvas.drawRect(entryRect, minimapStroke);
canvas.drawRect(viewportRect, viewportStroke);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
}

View file

@ -2,11 +2,14 @@ import 'dart:math';
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/entry_actions.dart';
import 'package:aves/widgets/common/fx/sweeper.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/common/menu_row.dart';
import 'package:aves/widgets/fullscreen/image_view.dart';
import 'package:aves/widgets/fullscreen/overlay/common.dart';
import 'package:aves/widgets/fullscreen/overlay/minimap.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -18,6 +21,7 @@ class FullscreenTopOverlay extends StatelessWidget {
final EdgeInsets viewInsets, viewPadding;
final Function(EntryAction value) onActionSelected;
final bool canToggleFavourite;
final ValueNotifier<ViewState> viewStateNotifier;
static const double padding = 8;
@ -33,6 +37,7 @@ class FullscreenTopOverlay extends StatelessWidget {
@required this.viewInsets,
@required this.viewPadding,
@required this.onActionSelected,
this.viewStateNotifier,
}) : super(key: key);
@override
@ -58,8 +63,7 @@ class FullscreenTopOverlay extends StatelessWidget {
].where(_canDo).take(quickActionCount).toList();
final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_canDo).toList();
final externalAppActions = EntryActions.externalApp.where(_canDo).toList();
return _TopOverlayRow(
final buttonRow = _TopOverlayRow(
quickActions: quickActions,
inAppActions: inAppActions,
externalAppActions: externalAppActions,
@ -67,6 +71,23 @@ class FullscreenTopOverlay extends StatelessWidget {
entry: entry,
onActionSelected: onActionSelected,
);
return settings.showOverlayMinimap && viewStateNotifier != null
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buttonRow,
SizedBox(height: 8),
FadeTransition(
opacity: scale,
child: Minimap(
entry: entry,
viewStateNotifier: viewStateNotifier,
),
)
],
)
: buttonRow;
},
),
),

View file

@ -0,0 +1,279 @@
import 'dart:math';
import 'package:aves/model/image_entry.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:aves/widgets/common/image_providers/region_provider.dart';
import 'package:aves/widgets/fullscreen/image_view.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class TiledImageView extends StatefulWidget {
final ImageEntry entry;
final Size viewportSize;
final ValueNotifier<ViewState> viewStateNotifier;
final Widget baseChild;
final ImageErrorWidgetBuilder errorBuilder;
const TiledImageView({
@required this.entry,
@required this.viewportSize,
@required this.viewStateNotifier,
@required this.baseChild,
@required this.errorBuilder,
});
@override
_TiledImageViewState createState() => _TiledImageViewState();
}
class _TiledImageViewState extends State<TiledImageView> {
double _tileSide, _initialScale;
int _maxSampleSize;
Matrix4 _transform;
ImageEntry get entry => widget.entry;
Size get viewportSize => widget.viewportSize;
ValueNotifier<ViewState> get viewStateNotifier => widget.viewStateNotifier;
// magic number used to derive sample size from scale
static const scaleFactor = 2.0;
@override
void initState() {
super.initState();
_init();
}
@override
void didUpdateWidget(TiledImageView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.viewportSize != widget.viewportSize || oldWidget.entry.displaySize != widget.entry.displaySize) {
_init();
}
}
void _init() {
_tileSide = viewportSize.shortestSide * scaleFactor;
_initialScale = min(viewportSize.width / entry.displaySize.width, viewportSize.height / entry.displaySize.height);
_maxSampleSize = _sampleSizeForScale(_initialScale);
final rotationDegrees = entry.rotationDegrees;
final isFlipped = entry.isFlipped;
_transform = null;
if (rotationDegrees != 0 || isFlipped) {
_transform = Matrix4.identity()
..translate(entry.width / 2.0, entry.height / 2.0)
..scale(isFlipped ? -1.0 : 1.0, 1.0, 1.0)
..rotateZ(-toRadians(rotationDegrees.toDouble()))
..translate(-entry.displaySize.width / 2.0, -entry.displaySize.height / 2.0);
}
}
@override
Widget build(BuildContext context) {
if (viewStateNotifier == null) return SizedBox.shrink();
final displayWidth = entry.displaySize.width.round();
final displayHeight = entry.displaySize.height.round();
return AnimatedBuilder(
animation: viewStateNotifier,
builder: (context, child) {
final viewState = viewStateNotifier.value;
var scale = viewState.scale;
if (scale == 0.0) {
// for initial scale as `PhotoViewComputedScale.contained`
scale = _initialScale;
}
final centerOffset = viewState.position;
final viewOrigin = Offset(
((displayWidth * scale - viewportSize.width) / 2 - centerOffset.dx),
((displayHeight * scale - viewportSize.height) / 2 - centerOffset.dy),
);
final viewRect = viewOrigin & viewportSize;
final tiles = <RegionTile>[];
var minSampleSize = min(_sampleSizeForScale(scale), _maxSampleSize);
for (var sampleSize = _maxSampleSize; sampleSize >= minSampleSize; sampleSize = (sampleSize / 2).floor()) {
// for the largest sample size (matching the initial scale), the whole image is in view
// so we subsample the whole image instead of splitting it in tiles
final useTiles = sampleSize != _maxSampleSize;
final regionSide = (_tileSide * sampleSize).round();
final layerRegionWidth = useTiles ? regionSide : displayWidth;
final layerRegionHeight = useTiles ? regionSide : displayHeight;
for (var x = 0; x < displayWidth; x += layerRegionWidth) {
for (var y = 0; y < displayHeight; y += layerRegionHeight) {
final nextX = x + layerRegionWidth;
final nextY = y + layerRegionHeight;
final thisRegionWidth = layerRegionWidth - (nextX >= displayWidth ? nextX - displayWidth : 0);
final thisRegionHeight = layerRegionHeight - (nextY >= displayHeight ? nextY - displayHeight : 0);
final tileRect = Rect.fromLTWH(x * scale, y * scale, thisRegionWidth * scale, thisRegionHeight * scale);
// only build visible tiles
if (viewRect.overlaps(tileRect)) {
Rectangle<int> regionRect;
if (_transform != null) {
// apply EXIF orientation
final regionRectDouble = Rect.fromLTWH(x.toDouble(), y.toDouble(), thisRegionWidth.toDouble(), thisRegionHeight.toDouble());
final tl = MatrixUtils.transformPoint(_transform, regionRectDouble.topLeft);
final br = MatrixUtils.transformPoint(_transform, regionRectDouble.bottomRight);
regionRect = Rectangle<int>.fromPoints(
Point<int>(tl.dx.round(), tl.dy.round()),
Point<int>(br.dx.round(), br.dy.round()),
);
} else {
regionRect = Rectangle<int>(x, y, thisRegionWidth, thisRegionHeight);
}
tiles.add(RegionTile(
entry: entry,
tileRect: tileRect,
regionRect: regionRect,
sampleSize: sampleSize,
));
}
}
}
}
return Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: displayWidth * scale,
height: displayHeight * scale,
child: widget.baseChild,
),
...tiles,
],
);
});
}
int _sampleSizeForScale(double scale) {
var sample = 0;
if (0 < scale && scale < 1) {
sample = highestPowerOf2((1 / scale) / scaleFactor);
}
return max<int>(1, sample);
}
}
class RegionTile extends StatefulWidget {
final ImageEntry entry;
// `tileRect` uses Flutter view coordinates
// `regionRect` uses the raw image pixel coordinates
final Rect tileRect;
final Rectangle<int> regionRect;
final int sampleSize;
const RegionTile({
@required this.entry,
@required this.tileRect,
@required this.regionRect,
@required this.sampleSize,
});
@override
_RegionTileState createState() => _RegionTileState();
}
class _RegionTileState extends State<RegionTile> {
RegionProvider _provider;
ImageEntry get entry => widget.entry;
@override
void initState() {
super.initState();
_registerWidget(widget);
}
@override
void didUpdateWidget(RegionTile oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.entry != widget.entry || oldWidget.tileRect != widget.tileRect || oldWidget.sampleSize != widget.sampleSize || oldWidget.sampleSize != widget.sampleSize) {
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
}
@override
void dispose() {
_unregisterWidget(widget);
super.dispose();
}
void _registerWidget(RegionTile widget) {
_initProvider();
}
void _unregisterWidget(RegionTile widget) {
_pauseProvider();
}
void _initProvider() {
if (!entry.canDecode) return;
_provider = RegionProvider(RegionProviderKey.fromEntry(
entry,
sampleSize: widget.sampleSize,
rect: widget.regionRect,
));
}
void _pauseProvider() => _provider?.pause();
@override
Widget build(BuildContext context) {
final tileRect = widget.tileRect;
Widget child = Image(
image: _provider,
width: tileRect.width,
height: tileRect.height,
fit: BoxFit.fill,
);
// apply EXIF orientation
final quarterTurns = entry.rotationDegrees ~/ 90;
if (entry.isFlipped) {
final rotated = quarterTurns % 2 != 0;
final w = (rotated ? tileRect.height : tileRect.width) / 2.0;
final h = (rotated ? tileRect.width : tileRect.height) / 2.0;
final flipper = Matrix4.identity()
..translate(w, h)
..scale(-1.0, 1.0, 1.0)
..translate(-w, -h);
child = Transform(
transform: flipper,
child: child,
);
}
if (quarterTurns != 0) {
child = RotatedBox(
quarterTurns: quarterTurns,
child: child,
);
}
return Positioned.fromRect(
rect: tileRect,
child: child,
);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(IntProperty('contentId', widget.entry.contentId));
properties.add(IntProperty('sampleSize', widget.sampleSize));
properties.add(DiagnosticsProperty<Rectangle<int>>('regionRect', widget.regionRect));
}
}

View file

@ -3,6 +3,7 @@ import 'package:aves/model/settings/home_page.dart';
import 'package:aves/model/settings/screen_on.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/common/aves_expansion_tile.dart';
import 'package:aves/widgets/common/aves_selection_dialog.dart';
import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart';
@ -10,6 +11,7 @@ import 'package:aves/widgets/common/highlight_title.dart';
import 'package:aves/widgets/settings/access_grants.dart';
import 'package:aves/widgets/settings/svg_background.dart';
import 'package:flutter/material.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import 'package:provider/provider.dart';
class SettingsPage extends StatefulWidget {
@ -25,24 +27,33 @@ class _SettingsPageState extends State<SettingsPage> {
@override
Widget build(BuildContext context) {
return MediaQueryDataProvider(
child: DefaultTabController(
length: 4,
child: Scaffold(
appBar: AppBar(
title: Text('Settings'),
),
body: SafeArea(
child: Consumer<Settings>(
builder: (context, settings, child) => ListView(
child: Scaffold(
appBar: AppBar(
title: Text('Settings'),
),
body: SafeArea(
child: Consumer<Settings>(
builder: (context, settings, child) => AnimationLimiter(
child: ListView(
padding: EdgeInsets.all(8),
children: [
_buildNavigationSection(context),
_buildDisplaySection(context),
_buildThumbnailsSection(context),
_buildViewerSection(context),
_buildSearchSection(context),
_buildPrivacySection(context),
],
children: AnimationConfiguration.toStaggeredList(
duration: Durations.staggeredAnimation,
delay: Durations.staggeredAnimationDelay,
childAnimationBuilder: (child) => SlideAnimation(
verticalOffset: 50.0,
child: FadeInAnimation(
child: child,
),
),
children: [
_buildNavigationSection(context),
_buildDisplaySection(context),
_buildThumbnailsSection(context),
_buildViewerSection(context),
_buildSearchSection(context),
_buildPrivacySection(context),
],
),
),
),
),
@ -163,6 +174,11 @@ class _SettingsPageState extends State<SettingsPage> {
title: 'Viewer',
expandedNotifier: _expandedNotifier,
children: [
SwitchListTile(
value: settings.showOverlayMinimap,
onChanged: (v) => settings.showOverlayMinimap = v,
title: Text('Show minimap'),
),
SwitchListTile(
value: settings.showOverlayShootingDetails,
onChanged: (v) => settings.showOverlayShootingDetails = v,
@ -201,9 +217,8 @@ class _SettingsPageState extends State<SettingsPage> {
onChanged: (v) => settings.isCrashlyticsEnabled = v,
title: Text('Allow anonymous analytics and crash reporting'),
),
Container(
alignment: AlignmentDirectional.topStart,
padding: EdgeInsets.only(bottom: 16),
Padding(
padding: EdgeInsets.only(top: 8, bottom: 16),
child: GrantedDirectories(),
),
],

View file

@ -2,6 +2,7 @@ import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/utils/color_utils.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/aves_filter_chip.dart';
import 'package:collection/collection.dart';
@ -50,7 +51,10 @@ class FilterTable extends StatelessWidget {
return TableRow(
children: [
Container(
padding: EdgeInsets.only(bottom: 8),
// the `Table` `border` property paints on the cells and does not add margins,
// so we define margins here instead, but they should be symmetric
// to keep all cells vertically aligned on the center/middle
margin: EdgeInsets.symmetric(vertical: 4),
alignment: AlignmentDirectional.centerStart,
child: AvesFilterChip(
filter: filter,
@ -65,7 +69,10 @@ class FilterTable extends StatelessWidget {
progressColor: stringToColor(label),
animation: true,
padding: EdgeInsets.symmetric(horizontal: lineHeight),
center: Text(NumberFormat.percentPattern().format(percent)),
center: Text(
NumberFormat.percentPattern().format(percent),
style: TextStyle(shadows: [Constants.embossShadow]),
),
),
Text(
'$count',

View file

@ -5,6 +5,7 @@ import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/mime_types.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/utils/color_utils.dart';
@ -90,7 +91,10 @@ class StatsPage extends StatelessWidget {
leading: Icon(AIcons.location),
// right padding to match leading, so that inside label is aligned with outside label below
padding: EdgeInsets.symmetric(horizontal: lineHeight) + EdgeInsets.only(right: 24),
center: Text(NumberFormat.percentPattern().format(withGpsPercent)),
center: Text(
NumberFormat.percentPattern().format(withGpsPercent),
style: TextStyle(shadows: [Constants.embossShadow]),
),
),
SizedBox(height: 8),
Text('${withGps.length} ${Intl.plural(withGps.length, one: 'item', other: 'items')} with location'),
@ -257,7 +261,7 @@ class EntryByMimeDatum {
EntryByMimeDatum({
@required this.mimeType,
@required this.entryCount,
}) : displayText = MimeFilter.displayType(mimeType);
}) : displayText = MimeTypes.displayType(mimeType);
Color get color => stringToColor(displayText);

View file

@ -3,6 +3,7 @@ import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/common/aves_logo.dart';
import 'package:aves/widgets/common/labeled_checkbox.dart';
import 'package:aves/widgets/home_page.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
@ -24,6 +25,9 @@ class _WelcomePageState extends State<WelcomePage> {
void initState() {
super.initState();
_termsLoader = rootBundle.loadString('assets/terms.md');
if (!kReleaseMode) {
settings.isCrashlyticsEnabled = false;
}
}
@override
@ -167,9 +171,8 @@ class _WelcomePageState extends State<WelcomePage> {
);
}
// workaround to handle `Flexible` widgets,
// because `AnimationConfiguration.toStaggeredList` does not,
// as of flutter_staggered_animations v0.1.2,
// as of flutter_staggered_animations v0.1.2, `AnimationConfiguration.toStaggeredList` does not handle `Flexible` widgets
// so we use this workaround instead
static List<Widget> _toStaggeredList({
Duration duration,
Duration delay,

View file

@ -14,7 +14,7 @@ packages:
name: analyzer
url: "https://pub.dartlang.org"
source: hosted
version: "0.40.5"
version: "0.40.6"
ansicolor:
dependency: transitive
description:
@ -133,7 +133,7 @@ packages:
name: coverage
url: "https://pub.dartlang.org"
source: hosted
version: "0.14.1"
version: "0.14.2"
crypto:
dependency: transitive
description:
@ -169,7 +169,7 @@ packages:
description:
path: "."
ref: HEAD
resolved-ref: edb6b11bb448fc2f30e566a20605b37093503176
resolved-ref: "51fe2b12588356fade82ce65daef5482beed54e7"
url: "git://github.com/deckerst/expansion_tile_card.git"
source: git
version: "1.0.3"
@ -207,7 +207,7 @@ packages:
name: firebase_analytics
url: "https://pub.dartlang.org"
source: hosted
version: "6.1.0"
version: "6.2.0"
firebase_analytics_platform_interface:
dependency: transitive
description:
@ -228,7 +228,7 @@ packages:
name: firebase_core
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.1"
version: "0.5.2"
firebase_core_platform_interface:
dependency: transitive
description:
@ -242,21 +242,21 @@ packages:
name: firebase_core_web
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.0"
version: "0.2.1"
firebase_crashlytics:
dependency: "direct main"
description:
name: firebase_crashlytics
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.2"
version: "0.2.3"
firebase_crashlytics_platform_interface:
dependency: transitive
description:
name: firebase_crashlytics_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.2"
version: "1.1.3"
flushbar:
dependency: "direct main"
description:
@ -381,7 +381,7 @@ packages:
name: google_maps_flutter
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.5"
version: "1.0.6"
google_maps_flutter_platform_interface:
dependency: transitive
description:
@ -416,7 +416,7 @@ packages:
name: image
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.18"
version: "2.1.19"
intl:
dependency: "direct main"
description:
@ -563,7 +563,7 @@ packages:
name: package_info
url: "https://pub.dartlang.org"
source: hosted
version: "0.4.3"
version: "0.4.3+2"
palette_generator:
dependency: "direct main"
description:
@ -598,7 +598,7 @@ packages:
name: path_provider
url: "https://pub.dartlang.org"
source: hosted
version: "1.6.22"
version: "1.6.24"
path_provider_linux:
dependency: transitive
description:
@ -612,21 +612,21 @@ packages:
name: path_provider_macos
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.4+4"
version: "0.0.4+6"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.3"
version: "1.0.4"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.4+1"
version: "0.0.4+3"
pdf:
dependency: "direct main"
description:
@ -674,7 +674,7 @@ packages:
description:
path: "."
ref: HEAD
resolved-ref: "79a3c20ee7f01e6ffb71464000c2ca8f1e28ec44"
resolved-ref: aa6400bbc85bf6ce953c4609d126796cdb4ca3c2
url: "git://github.com/deckerst/photo_view.git"
source: git
version: "0.9.2"
@ -712,7 +712,7 @@ packages:
name: printing
url: "https://pub.dartlang.org"
source: hosted
version: "3.7.0"
version: "3.7.1"
process:
dependency: transitive
description:
@ -754,7 +754,7 @@ packages:
name: quiver
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.4+1"
version: "2.1.5"
rxdart:
dependency: transitive
description:
@ -775,21 +775,21 @@ packages:
name: shared_preferences
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.12+2"
version: "0.5.12+4"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.2+2"
version: "0.0.2+4"
shared_preferences_macos:
dependency: transitive
description:
name: shared_preferences_macos
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.1+10"
version: "0.0.1+11"
shared_preferences_platform_interface:
dependency: transitive
description:
@ -810,7 +810,7 @@ packages:
name: shared_preferences_windows
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.1+1"
version: "0.0.1+3"
shelf:
dependency: transitive
description:
@ -990,21 +990,21 @@ packages:
name: url_launcher
url: "https://pub.dartlang.org"
source: hosted
version: "5.7.8"
version: "5.7.10"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.1+3"
version: "0.0.1+4"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.1+8"
version: "0.0.1+9"
url_launcher_platform_interface:
dependency: transitive
description:
@ -1018,14 +1018,14 @@ packages:
name: url_launcher_web
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.5"
version: "0.1.5+1"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.1+1"
version: "0.0.1+3"
utf:
dependency: transitive
description:
@ -1060,7 +1060,7 @@ packages:
name: vm_service
url: "https://pub.dartlang.org"
source: hosted
version: "5.4.0"
version: "5.5.0"
vm_service_client:
dependency: transitive
description:
@ -1095,7 +1095,7 @@ packages:
name: webkit_inspection_protocol
url: "https://pub.dartlang.org"
source: hosted
version: "0.7.3"
version: "0.7.4"
win32:
dependency: transitive
description:

View file

@ -15,7 +15,11 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 1.2.5+31
version: 1.2.6+32
# brendan-duncan/image (as of v2.1.19):
# - does not support TIFF with JPEG compression (issue #184)
# - TIFF tile decoding is not public (issue #258)
# video_player (as of v0.10.8+2, backed by ExoPlayer):
# - does not support content URIs (by default, but trivial by fork)
@ -33,6 +37,9 @@ version: 1.2.5+31
# - does not support AC3 (by default, but possible by custom build)
# - can play if only the video or audio stream is supported
environment:
sdk: ">=2.7.0 <3.0.0"
dependencies:
flutter:
sdk: flutter

File diff suppressed because one or more lines are too long

1
shaders_1.22.4.sksl.json Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,18 @@
import 'package:test/test.dart';
import 'package:aves/utils/time_utils.dart';
void main() {
test('Comparison extension functions', () {
expect(DateTime(1593, 7, 8).isAtSameYearAs(null), false);
expect(DateTime(1903, 9, 25).isAtSameYearAs(DateTime(1970, 2, 25)), false);
expect(DateTime(1929, 3, 22).isAtSameYearAs(DateTime(1929, 3, 22)), true);
expect(DateTime(1593, 7, 8).isAtSameMonthAs(null), false);
expect(DateTime(1903, 9, 25).isAtSameMonthAs(DateTime(1970, 2, 25)), false);
expect(DateTime(1929, 3, 22).isAtSameMonthAs(DateTime(1929, 3, 22)), true);
expect(DateTime(1593, 7, 8).isAtSameDayAs(null), false);
expect(DateTime(1903, 9, 25).isAtSameDayAs(DateTime(1970, 2, 25)), false);
expect(DateTime(1929, 3, 22).isAtSameDayAs(DateTime(1929, 3, 22)), true);
});
}

View file

@ -1 +1,6 @@
Thanks for using Aves!
v1.2.6:
- subsampling and tiling of large images
- support for TIFF images (single page only)
- optional minimap in viewer overlay
Full changelog available on Github