Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2020-12-12 11:14:56 +09:00
commit 918409346e
96 changed files with 3547 additions and 923 deletions

View file

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

View file

@ -3,6 +3,21 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
## [v1.2.9] - 2020-12-12
### Added
- Collection: identify 360 photos/videos, GeoTIFF
- Viewer: open panoramas (360 photos)
- Info: open GImage/GAudio/GDepth media and thumbnails embedded in XMP
- Info: SVG metadata
### Changed
- Upgraded Flutter to stable v1.22.5
- Viewer: TIFF subsampling & tiling
- Info: improved XMP layout
### Fixed
- Fixed large TIFF handling
## [v1.2.8] - 2020-11-27
### Added
- Albums / Countries / Tags: pinch to change tile size

View file

@ -12,11 +12,13 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
## Features
- support raster images: BMP, DNG, GIF, HEIC (from Android Pie), ICO, JPEG, PNG, WBMP, WEBP
- support raster images: JPEG, GIF, PNG, HEIC (from Android Pie), WEBP, TIFF, BMP, WBMP, ICO
- support animated images: GIF, WEBP
- support raw images: ARW, CR2, DNG, NEF, NRW, ORF, PEF, RAF, RW2, SRW
- support vector images: SVG
- support videos: MP4, AVI, AVCHD & probably others
- search and filter by country, place, XMP tag, type (animated, raster, vector, video)
- support videos: MP4, AVI, MKV, AVCHD & probably others
- identify panoramas (aka photo spheres), 360° videos, GeoTIFF files
- search and filter by country, place, XMP tag, type (animated, raster, vector…)
- favorites
- statistics
- support Android API 24 ~ 30 (Nougat ~ R)
@ -26,7 +28,10 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
- gesture: double tap on image does not zoom on tapped area (cf [photo_view issue #82](https://github.com/renancaraujo/photo_view/issues/82))
- performance: image info page stutters the first time it loads a Google Maps view (cf [flutter issue #28493](https://github.com/flutter/flutter/issues/28493))
- performance: image decoding is slow
- SVG: unsupported `currentColor` (cf [flutter_svg issue #31](https://github.com/dnfield/flutter_svg/issues/31))
- SVG: unsupported out of order defs/references (cf [flutter_svg issue #102](https://github.com/dnfield/flutter_svg/issues/102))
- SVG: unsupported `<style>` (cf [flutter_svg issue #105](https://github.com/dnfield/flutter_svg/issues/105))
- SVG: limited support for `%`, `mm` or `pt` unit (cf [flutter_svg issue #110](https://github.com/dnfield/flutter_svg/issues/110))
## Test Devices

View file

@ -109,9 +109,6 @@ dependencies {
implementation 'com.github.deckerst:Android-TiffBitmapFactory:7efb450636'
implementation 'com.github.bumptech.glide:glide:4.11.0'
// TODO TLAD remove when this is fixed: https://github.com/firebase/firebase-android-sdk/issues/1662 https://github.com/FirebaseExtended/flutterfire/issues/3990
implementation 'com.google.firebase:firebase-analytics:18.0.0'
kapt 'androidx.annotation:annotation:1.1.0'
kapt 'com.github.bumptech.glide:compiler:4.11.0'

View file

@ -41,6 +41,7 @@ class MainActivity : FlutterActivity() {
MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this))
MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(MetadataHandler(this))
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))
StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(this, args) }
StreamsChannel(messenger, ImageOpStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageOpStreamHandler(this, args) }

View file

@ -29,7 +29,6 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
when (call.method) {
"getAppIcon" -> GlobalScope.launch { getAppIcon(call, Coresult(result)) }
"getAppNames" -> GlobalScope.launch { getAppNames(Coresult(result)) }
"getEnv" -> result.success(System.getenv())
"edit" -> {
val title = call.argument<String>("title")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
@ -154,7 +153,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val intent = Intent(Intent.ACTION_EDIT)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
.setDataAndType(uri, mimeType)
.setDataAndType(getShareableUri(uri), mimeType)
return safeStartActivityChooser(title, intent)
}
@ -163,7 +162,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val intent = Intent(Intent.ACTION_VIEW)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.setDataAndType(uri, mimeType)
.setDataAndType(getShareableUri(uri), mimeType)
return safeStartActivityChooser(title, intent)
}
@ -179,7 +178,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val intent = Intent(Intent.ACTION_ATTACH_DATA)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.setDataAndType(uri, mimeType)
.setDataAndType(getShareableUri(uri), mimeType)
return safeStartActivityChooser(title, intent)
}
@ -187,15 +186,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val intent = Intent(Intent.ACTION_SEND)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.setType(mimeType)
when (uri.scheme?.toLowerCase(Locale.ROOT)) {
ContentResolver.SCHEME_FILE -> {
val path = uri.path ?: return false
val applicationId = context.applicationContext.packageName
val apkUri = FileProvider.getUriForFile(context, "$applicationId.fileprovider", File(path))
intent.putExtra(Intent.EXTRA_STREAM, apkUri)
}
else -> intent.putExtra(Intent.EXTRA_STREAM, uri)
}
.putExtra(Intent.EXTRA_STREAM, getShareableUri(uri))
return safeStartActivityChooser(title, intent)
}
@ -252,6 +243,18 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
return false
}
private fun getShareableUri(uri: Uri): Uri? {
return when (uri.scheme?.toLowerCase(Locale.ROOT)) {
ContentResolver.SCHEME_FILE -> {
uri.path?.let { path ->
val applicationId = context.applicationContext.packageName
FileProvider.getUriForFile(context, "$applicationId.fileprovider", File(path))
}
}
else -> uri
}
}
companion object {
private val LOG_TAG = createTag(AppAdapterHandler::class.java)
const val CHANNEL = "deckers.thibault/aves/app"

View file

@ -0,0 +1,301 @@
package deckers.thibault.aves.channel.calls
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.Log
import androidx.exifinterface.media.ExifInterface
import com.drew.imaging.ImageMetadataReader
import com.drew.metadata.file.FileTypeDirectory
import deckers.thibault.aves.metadata.ExifInterfaceHelper
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.model.provider.FieldMap
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
import deckers.thibault.aves.utils.MimeTypes.isVideo
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 org.beyka.tiffbitmapfactory.TiffBitmapFactory
import java.io.IOException
import java.util.*
class DebugHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getContextDirs" -> result.success(getContextDirs())
"getEnv" -> result.success(System.getenv())
"getBitmapFactoryInfo" -> GlobalScope.launch { getBitmapFactoryInfo(call, Coresult(result)) }
"getContentResolverMetadata" -> GlobalScope.launch { getContentResolverMetadata(call, Coresult(result)) }
"getExifInterfaceMetadata" -> GlobalScope.launch { getExifInterfaceMetadata(call, Coresult(result)) }
"getMediaMetadataRetrieverMetadata" -> GlobalScope.launch { getMediaMetadataRetrieverMetadata(call, Coresult(result)) }
"getMetadataExtractorSummary" -> GlobalScope.launch { getMetadataExtractorSummary(call, Coresult(result)) }
"getTiffStructure" -> GlobalScope.launch { getTiffStructure(call, Coresult(result)) }
else -> result.notImplemented()
}
}
private fun getContextDirs() = hashMapOf(
"dataDir" to context.dataDir,
"cacheDir" to context.cacheDir,
"codeCacheDir" to context.codeCacheDir,
"filesDir" to context.filesDir,
"noBackupFilesDir" to context.noBackupFilesDir,
"obbDir" to context.obbDir,
"externalCacheDir" to context.externalCacheDir,
).mapValues { it.value?.path }
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 getContentResolverMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (mimeType == null || uri == null) {
result.error("getContentResolverMetadata-args", "failed because of missing arguments", null)
return
}
var contentUri: Uri = uri
if (uri.scheme == ContentResolver.SCHEME_CONTENT && MediaStore.AUTHORITY.equals(uri.host, ignoreCase = true)) {
try {
val id = ContentUris.parseId(uri)
contentUri = when {
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
else -> uri
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
contentUri = MediaStore.setRequireOriginal(contentUri)
}
} catch (e: NumberFormatException) {
// ignore
}
}
val cursor = context.contentResolver.query(contentUri, null, null, null, null)
if (cursor != null && cursor.moveToFirst()) {
val metadataMap = HashMap<String, Any?>()
val columnCount = cursor.columnCount
val columnNames = cursor.columnNames
for (i in 0 until columnCount) {
val key = columnNames[i]
try {
metadataMap[key] = when (cursor.getType(i)) {
Cursor.FIELD_TYPE_NULL -> null
Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(i)
Cursor.FIELD_TYPE_FLOAT -> cursor.getFloat(i)
Cursor.FIELD_TYPE_STRING -> cursor.getString(i)
Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(i)
else -> null
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get value for key=$key", e)
}
}
cursor.close()
result.success(metadataMap)
} else {
result.error("getContentResolverMetadata-null", "failed to get cursor for contentUri=$contentUri", null)
}
}
private fun getExifInterfaceMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getExifInterfaceMetadata-args", "failed because of missing arguments", null)
return
}
val metadataMap = HashMap<String, String?>()
if (isSupportedByExifInterface(mimeType, strict = false)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val exif = ExifInterface(input)
for (tag in ExifInterfaceHelper.allTags.keys.filter { exif.hasAttribute(it) }) {
metadataMap[tag] = exif.getAttribute(tag)
}
}
} catch (e: Exception) {
// ExifInterface initialization can fail with a RuntimeException
// caused by an internal MediaMetadataRetriever failure
result.error("getExifInterfaceMetadata-failure", "failed to get exif for uri=$uri", e.message)
return
}
}
result.success(metadataMap)
}
private fun getMediaMetadataRetrieverMetadata(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {
result.error("getMediaMetadataRetrieverMetadata-args", "failed because of missing arguments", null)
return
}
val metadataMap = HashMap<String, String>()
val retriever = StorageUtils.openMetadataRetriever(context, uri)
if (retriever != null) {
try {
for ((code, name) in MediaMetadataRetrieverHelper.allKeys) {
retriever.extractMetadata(code)?.let { metadataMap[name] = it }
}
} catch (e: Exception) {
// ignore
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release()
}
}
result.success(metadataMap)
}
private fun getMetadataExtractorSummary(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getMetadataExtractorSummary-args", "failed because of missing arguments", null)
return
}
val metadataMap = HashMap<String, String>()
if (isSupportedByMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.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)
}
}
result.success(metadataMap)
}
private fun getTiffStructure(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {
result.error("getTiffStructure-args", "failed because of missing arguments", null)
return
}
try {
val metadataMap = HashMap<String, FieldMap>()
var dirCount: Int? = null
context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true
}
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
metadataMap["0"] = tiffOptionsToMap(options)
dirCount = options.outDirectoryCount
}
if (dirCount != null) {
for (i in 1 until dirCount!!) {
context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true
inDirectoryNumber = i
}
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
metadataMap["$i"] = tiffOptionsToMap(options)
}
}
}
result.success(metadataMap)
} catch (e: Exception) {
result.error("getTiffStructure-read", "failed to read tiff", e.message)
}
}
private fun tiffOptionsToMap(options: TiffBitmapFactory.Options): FieldMap = hashMapOf(
"Author" to options.outAuthor,
"BitsPerSample" to options.outBitsPerSample.toString(),
"CompressionScheme" to options.outCompressionScheme?.toString(),
"Copyright" to options.outCopyright,
"CurDirectoryNumber" to options.outCurDirectoryNumber.toString(),
"Datetime" to options.outDatetime,
"DirectoryCount" to options.outDirectoryCount.toString(),
"FillOrder" to options.outFillOrder?.toString(),
"Height" to options.outHeight.toString(),
"HostComputer" to options.outHostComputer,
"ImageDescription" to options.outImageDescription,
"ImageOrientation" to options.outImageOrientation?.toString(),
"NumberOfStrips" to options.outNumberOfStrips.toString(),
"Photometric" to options.outPhotometric?.toString(),
"PlanarConfig" to options.outPlanarConfig?.toString(),
"ResolutionUnit" to options.outResolutionUnit?.toString(),
"RowPerStrip" to options.outRowPerStrip.toString(),
"SamplePerPixel" to options.outSamplePerPixel.toString(),
"Software" to options.outSoftware,
"StripSize" to options.outStripSize.toString(),
"TileHeight" to options.outTileHeight.toString(),
"TileWidth" to options.outTileWidth.toString(),
"Width" to options.outWidth.toString(),
"XResolution" to options.outXResolution.toString(),
"YResolution" to options.outYResolution.toString(),
)
companion object {
private val LOG_TAG = LogUtils.createTag(DebugHandler::class.java)
const val CHANNEL = "deckers.thibault/aves/debug"
}
}

View file

@ -10,6 +10,7 @@ import deckers.thibault.aves.model.provider.FieldMap
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
import deckers.thibault.aves.utils.MimeTypes
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
@ -95,14 +96,24 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
return
}
regionFetcher.fetch(
uri,
mimeType,
sampleSize,
Rect(x, y, x + width, y + height),
Size(imageWidth, imageHeight),
result,
)
val regionRect = Rect(x, y, x + width, y + height)
when (mimeType) {
MimeTypes.TIFF -> TiffRegionFetcher(activity).fetch(
uri,
sampleSize,
regionRect,
page = 0,
result,
)
else -> regionFetcher.fetch(
uri,
mimeType,
sampleSize,
regionRect,
Size(imageWidth, imageHeight),
result,
)
}
}
private suspend fun getImageEntry(call: MethodCall, result: MethodChannel.Result) {

View file

@ -1,14 +1,8 @@
package deckers.thibault.aves.channel.calls
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.Log
import androidx.exifinterface.media.ExifInterface
import com.adobe.internal.xmp.XMPException
@ -23,14 +17,16 @@ import com.drew.metadata.exif.ExifSubIFDDirectory
import com.drew.metadata.exif.GpsDirectory
import com.drew.metadata.file.FileTypeDirectory
import com.drew.metadata.gif.GifAnimationDirectory
import com.drew.metadata.iptc.IptcDirectory
import com.drew.metadata.mp4.media.Mp4UuidBoxDirectory
import com.drew.metadata.webp.WebpDirectory
import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.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.Geotiff
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDescription
@ -43,14 +39,21 @@ import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
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.MetadataExtractorHelper.isGeoTiff
import deckers.thibault.aves.metadata.XMP
import deckers.thibault.aves.metadata.XMP.getSafeDateMillis
import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText
import deckers.thibault.aves.metadata.XMP.isPanorama
import deckers.thibault.aves.model.provider.FieldMap
import deckers.thibault.aves.model.provider.FileImageProvider
import deckers.thibault.aves.model.provider.ImageProvider
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isMultimedia
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.tiffExtensionPattern
@ -60,7 +63,7 @@ import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.io.IOException
import java.io.File
import java.util.*
import kotlin.math.roundToLong
@ -70,14 +73,9 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
"getAllMetadata" -> GlobalScope.launch { getAllMetadata(call, Coresult(result)) }
"getCatalogMetadata" -> GlobalScope.launch { getCatalogMetadata(call, Coresult(result)) }
"getOverlayMetadata" -> GlobalScope.launch { getOverlayMetadata(call, Coresult(result)) }
"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)) }
"extractXmpDataProp" -> GlobalScope.launch { extractXmpDataProp(call, Coresult(result)) }
else -> result.notImplemented()
}
}
@ -85,6 +83,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private fun getAllMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getAllMetadata-args", "failed because of missing arguments", null)
return
@ -96,7 +95,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
if (isSupportedByMetadataExtractor(mimeType)) {
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java)
@ -111,22 +110,36 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
metadataMap[dirName] = dirMap
// tags
dirMap.putAll(dir.tags.map { Pair(it.tagName, it.description) })
if (mimeType == MimeTypes.TIFF && dir is ExifIFD0Directory) {
dirMap.putAll(dir.tags.map {
val name = if (it.hasTagName()) {
it.tagName
} else {
Geotiff.getTagName(it.tagType) ?: it.tagName
}
Pair(name, it.description)
})
} else {
dirMap.putAll(dir.tags.map { Pair(it.tagName, it.description) })
}
if (dir is XmpDirectory) {
try {
val xmpMeta = dir.xmpMeta.apply { sort() }
for (prop in xmpMeta) {
for (prop in dir.xmpMeta) {
if (prop is XMPPropertyInfo) {
val path = prop.path
val value = prop.value
if (path?.isNotEmpty() == true && value?.isNotEmpty() == true) {
dirMap[path] = value
if (path?.isNotEmpty() == true) {
val value = if (XMP.isDataPath(path)) "[skipped]" else prop.value
if (value?.isNotEmpty() == true) {
dirMap[path] = value
}
}
}
}
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
}
// remove this stat as it is not actual XMP data
dirMap.remove(dir.getTagName(XmpDirectory.TAG_XMP_VALUE_COUNT))
}
}
}
@ -137,10 +150,10 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
}
if (!foundExif) {
if (!foundExif && isSupportedByExifInterface(mimeType)) {
// fallback to read EXIF via ExifInterface
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val exif = ExifInterface(input)
val allTags = describeAll(exif).toMutableMap()
if (foundXmp) {
@ -187,32 +200,48 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
return dirMap
}
// set `KEY_DATE_MILLIS` from these fields (by precedence):
// - ME / Exif / DATETIME_ORIGINAL
// - ME / Exif / DATETIME
// - EI / Exif / DATETIME_ORIGINAL
// - EI / Exif / DATETIME
// - ME / XMP / xmp:CreateDate
// - ME / XMP / photoshop:DateCreated
// - MMR / METADATA_KEY_DATE
// set `KEY_XMP_TITLE_DESCRIPTION` from these fields (by precedence):
// - ME / XMP / dc:title
// - ME / XMP / dc:description
// set `KEY_XMP_SUBJECTS` from these fields (by precedence):
// - ME / XMP / dc:subject
// - ME / IPTC / keywords
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")
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getCatalogMetadata-args", "failed because of missing arguments", null)
return
}
val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType, path))
if (isVideo(mimeType)) {
metadataMap.putAll(getVideoCatalogMetadataByMediaMetadataRetriever(uri))
val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType, path, sizeBytes))
if (isMultimedia(mimeType)) {
metadataMap.putAll(getMultimediaCatalogMetadataByMediaMetadataRetriever(uri))
}
// report success even when empty
result.success(metadataMap)
}
private fun getCatalogMetadataByMetadataExtractor(uri: Uri, mimeType: String, path: String?): Map<String, Any> {
private fun getCatalogMetadataByMetadataExtractor(uri: Uri, mimeType: String, path: String?, sizeBytes: Long?): Map<String, Any> {
val metadataMap = HashMap<String, Any>()
var flags = 0
var foundExif = false
if (isSupportedByMetadataExtractor(mimeType)) {
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
@ -247,7 +276,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
dir.getSafeInt(ExifIFD0Directory.TAG_ORIENTATION) {
val orientation = it
metadataMap[KEY_IS_FLIPPED] = isFlippedForExifCode(orientation)
if (isFlippedForExifCode(orientation)) flags = flags or MASK_IS_FLIPPED
metadataMap[KEY_ROTATION_DEGREES] = getRotationDegreesForExifCode(orientation)
}
}
@ -268,30 +297,60 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
if (xmpMeta.doesPropertyExist(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME)) {
val count = xmpMeta.countArrayItems(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME)
val values = (1 until count + 1).map { xmpMeta.getArrayItem(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME, it).value }
metadataMap[KEY_XMP_SUBJECTS] = values.joinToString(separator = ";")
metadataMap[KEY_XMP_SUBJECTS] = values.joinToString(separator = XMP_SUBJECTS_SEPARATOR)
}
xmpMeta.getSafeLocalizedText(XMP.TITLE_PROP_NAME) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it }
xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.TITLE_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it }
if (!metadataMap.containsKey(KEY_XMP_TITLE_DESCRIPTION)) {
xmpMeta.getSafeLocalizedText(XMP.DESCRIPTION_PROP_NAME) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it }
xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.DESCRIPTION_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it }
}
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
xmpMeta.getSafeDateMillis(XMP.XMP_SCHEMA_NS, XMP.CREATE_DATE_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it }
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
xmpMeta.getSafeDateMillis(XMP.PHOTOSHOP_SCHEMA_NS, XMP.PS_DATE_CREATED_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it }
}
}
// identification of panorama (aka photo sphere)
if (xmpMeta.isPanorama()) {
flags = flags or MASK_IS_360
}
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
}
}
// Animated GIF & WEBP
// XMP fallback to IPTC
if (!metadataMap.containsKey(KEY_XMP_SUBJECTS)) {
for (dir in metadata.getDirectoriesOfType(IptcDirectory::class.java)) {
dir.keywords?.let { metadataMap[KEY_XMP_SUBJECTS] = it.joinToString(separator = XMP_SUBJECTS_SEPARATOR) }
}
}
// identification of animated GIF & WEBP, GeoTIFF
when (mimeType) {
MimeTypes.GIF -> {
metadataMap[KEY_IS_ANIMATED] = metadata.containsDirectoryOfType(GifAnimationDirectory::class.java)
if (metadata.containsDirectoryOfType(GifAnimationDirectory::class.java)) flags = flags or MASK_IS_ANIMATED
}
MimeTypes.WEBP -> {
for (dir in metadata.getDirectoriesOfType(WebpDirectory::class.java)) {
dir.getSafeBoolean(WebpDirectory.TAG_IS_ANIMATION) { metadataMap[KEY_IS_ANIMATED] = it }
dir.getSafeBoolean(WebpDirectory.TAG_IS_ANIMATION) {
if (it) flags = flags or MASK_IS_ANIMATED
}
}
}
else -> {
MimeTypes.TIFF -> {
for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
if (dir.isGeoTiff()) flags = flags or MASK_IS_GEOTIFF
}
}
}
// identification of spherical video (aka 360° video)
if (metadata.getDirectoriesOfType(Mp4UuidBoxDirectory::class.java).any {
it.getString(Mp4UuidBoxDirectory.TAG_UUID) == Metadata.SPHERICAL_VIDEO_V1_UUID
}) {
flags = flags or MASK_IS_360
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get catalog metadata by metadata-extractor for uri=$uri, mimeType=$mimeType", e)
@ -300,17 +359,17 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
}
if (!foundExif) {
if (!foundExif && isSupportedByExifInterface(mimeType)) {
// fallback to read EXIF via ExifInterface
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val exif = ExifInterface(input)
exif.getSafeDateMillis(ExifInterface.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it }
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
exif.getSafeDateMillis(ExifInterface.TAG_DATETIME) { metadataMap[KEY_DATE_MILLIS] = it }
}
exif.getSafeInt(ExifInterface.TAG_ORIENTATION, acceptZero = false) {
metadataMap[KEY_IS_FLIPPED] = exif.isFlipped
if (exif.isFlipped) flags = flags or MASK_IS_FLIPPED
metadataMap[KEY_ROTATION_DEGREES] = exif.rotationDegrees
}
val latLong = exif.latLong
@ -325,26 +384,30 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
Log.w(LOG_TAG, "failed to get metadata by ExifInterface for uri=$uri", e)
}
}
metadataMap[KEY_FLAGS] = flags
return metadataMap
}
private fun getVideoCatalogMetadataByMediaMetadataRetriever(uri: Uri): Map<String, Any> {
private fun getMultimediaCatalogMetadataByMediaMetadataRetriever(uri: Uri): Map<String, Any> {
val metadataMap = HashMap<String, Any>()
val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return metadataMap
try {
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { metadataMap[KEY_ROTATION_DEGREES] = it }
retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { metadataMap[KEY_DATE_MILLIS] = it }
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { metadataMap[KEY_DATE_MILLIS] = it }
}
val locationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION)
if (locationString != null) {
val matcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString)
if (matcher.find() && matcher.groupCount() >= 2) {
// keep `0.0` as `0.0`, not `0`
val latitude = matcher.group(1)?.toDoubleOrNull() ?: 0.0
val longitude = matcher.group(2)?.toDoubleOrNull() ?: 0.0
if (latitude != 0.0 || longitude != 0.0) {
metadataMap[KEY_LATITUDE] = latitude
metadataMap[KEY_LONGITUDE] = longitude
if (!metadataMap.containsKey(KEY_LATITUDE)) {
val locationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION)
if (locationString != null) {
val matcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString)
if (matcher.find() && matcher.groupCount() >= 2) {
val latitude = matcher.group(1)?.toDoubleOrNull()
val longitude = matcher.group(2)?.toDoubleOrNull()
if (latitude != null && longitude != null) {
metadataMap[KEY_LATITUDE] = latitude
metadataMap[KEY_LONGITUDE] = longitude
}
}
}
}
@ -360,6 +423,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getOverlayMetadata-args", "failed because of missing arguments", null)
return
@ -387,7 +451,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
var foundExif = false
if (isSupportedByMetadataExtractor(mimeType)) {
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
foundExif = true
@ -404,10 +468,10 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
}
if (!foundExif) {
if (!foundExif && isSupportedByExifInterface(mimeType)) {
// fallback to read EXIF via ExifInterface
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val exif = ExifInterface(input)
exif.getSafeDouble(ExifInterface.TAG_F_NUMBER) { metadataMap[KEY_APERTURE] = it }
exif.getSafeRational(ExifInterface.TAG_EXPOSURE_TIME, saveExposureTime)
@ -424,176 +488,6 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
result.success(metadataMap)
}
private fun getContentResolverMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (mimeType == null || uri == null) {
result.error("getContentResolverMetadata-args", "failed because of missing arguments", null)
return
}
var contentUri: Uri = uri
if (uri.scheme == ContentResolver.SCHEME_CONTENT && MediaStore.AUTHORITY.equals(uri.host, ignoreCase = true)) {
try {
val id = ContentUris.parseId(uri)
contentUri = when {
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
else -> uri
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
contentUri = MediaStore.setRequireOriginal(contentUri)
}
} catch (e: NumberFormatException) {
// ignore
}
}
val cursor = context.contentResolver.query(contentUri, null, null, null, null)
if (cursor != null && cursor.moveToFirst()) {
val metadataMap = HashMap<String, Any?>()
val columnCount = cursor.columnCount
val columnNames = cursor.columnNames
for (i in 0 until columnCount) {
val key = columnNames[i]
try {
metadataMap[key] = when (cursor.getType(i)) {
Cursor.FIELD_TYPE_NULL -> null
Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(i)
Cursor.FIELD_TYPE_FLOAT -> cursor.getFloat(i)
Cursor.FIELD_TYPE_STRING -> cursor.getString(i)
Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(i)
else -> null
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get value for key=$key", e)
}
}
cursor.close()
result.success(metadataMap)
} else {
result.error("getContentResolverMetadata-null", "failed to get cursor for contentUri=$contentUri", null)
}
}
private fun getExifInterfaceMetadata(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {
result.error("getExifInterfaceMetadata-args", "failed because of missing arguments", null)
return
}
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
val exif = ExifInterface(input)
val metadataMap = HashMap<String, String?>()
for (tag in ExifInterfaceHelper.allTags.keys.filter { exif.hasAttribute(it) }) {
metadataMap[tag] = exif.getAttribute(tag)
}
result.success(metadataMap)
} ?: result.error("getExifInterfaceMetadata-noinput", "failed to get exif for uri=$uri", null)
} catch (e: Exception) {
// ExifInterface initialization can fail with a RuntimeException
// caused by an internal MediaMetadataRetriever failure
result.error("getExifInterfaceMetadata-failure", "failed to get exif for uri=$uri", e.message)
}
}
private fun getMediaMetadataRetrieverMetadata(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {
result.error("getMediaMetadataRetrieverMetadata-args", "failed because of missing arguments", null)
return
}
val metadataMap = HashMap<String, String>()
val retriever = StorageUtils.openMetadataRetriever(context, uri)
if (retriever != null) {
try {
for ((code, name) in MediaMetadataRetrieverHelper.allKeys) {
retriever.extractMetadata(code)?.let { metadataMap[name] = it }
}
} catch (e: Exception) {
// ignore
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release()
}
}
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) {
@ -617,70 +511,111 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
private fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getExifThumbnails-args", "failed because of missing arguments", null)
return
}
val thumbnails = ArrayList<ByteArray>()
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
val exif = ExifInterface(input)
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
exif.thumbnailBitmap?.let { bitmap ->
TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let {
thumbnails.add(it.getBytes(canHaveAlpha = false, recycle = false))
}
}
}
} catch (e: Exception) {
// ExifInterface initialization can fail with a RuntimeException
// caused by an internal MediaMetadataRetriever failure
}
result.success(thumbnails)
}
private fun getXmpThumbnails(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (mimeType == null || uri == null) {
result.error("getXmpThumbnails-args", "failed because of missing arguments", null)
return
}
val thumbnails = ArrayList<ByteArray>()
if (isSupportedByMetadataExtractor(mimeType)) {
if (isSupportedByExifInterface(mimeType)) {
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
val xmpMeta = dir.xmpMeta
try {
if (xmpMeta.doesPropertyExist(XMP.XMP_SCHEMA_NS, XMP.THUMBNAIL_PROP_NAME)) {
val count = xmpMeta.countArrayItems(XMP.XMP_SCHEMA_NS, XMP.THUMBNAIL_PROP_NAME)
for (i in 1 until count + 1) {
val structName = "${XMP.THUMBNAIL_PROP_NAME}[$i]"
val image = xmpMeta.getStructField(XMP.XMP_SCHEMA_NS, structName, XMP.IMG_SCHEMA_NS, XMP.THUMBNAIL_IMAGE_PROP_NAME)
if (image != null) {
thumbnails.add(XMPUtils.decodeBase64(image.value))
}
}
}
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val exif = ExifInterface(input)
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
exif.thumbnailBitmap?.let { bitmap ->
TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let {
thumbnails.add(it.getBytes(canHaveAlpha = false, recycle = false))
}
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to extract xmp thumbnail", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to extract xmp thumbnail", e)
// ExifInterface initialization can fail with a RuntimeException
// caused by an internal MediaMetadataRetriever failure
}
}
result.success(thumbnails)
}
private fun extractXmpDataProp(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val dataPropPath = call.argument<String>("propPath")
val embedMimeType = call.argument<String>("propMimeType")
if (mimeType == null || uri == null || dataPropPath == null || embedMimeType == null) {
result.error("extractXmpDataProp-args", "failed because of missing arguments", null)
return
}
if (isSupportedByMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
// data can be large and stored in "Extended XMP",
// which is returned as a second XMP directory
val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java)
try {
val pathParts = dataPropPath.split('/')
val embedBytes: ByteArray = if (pathParts.size == 1) {
val propName = pathParts[0]
val propNs = XMP.namespaceForPropPath(propName)
xmpDirs.map { it.xmpMeta.getPropertyBase64(propNs, propName) }.first { it != null }
} else {
val structName = pathParts[0]
val structNs = XMP.namespaceForPropPath(structName)
val fieldName = pathParts[1]
val fieldNs = XMP.namespaceForPropPath(fieldName)
xmpDirs.map { it.xmpMeta.getStructField(structNs, structName, fieldNs, fieldName) }.first { it != null }.let {
XMPUtils.decodeBase64(it.value)
}
}
val embedFile = File.createTempFile("aves", null, context.cacheDir).apply {
deleteOnExit()
outputStream().use { outputStream ->
embedBytes.inputStream().use { inputStream ->
inputStream.copyTo(outputStream)
}
}
}
val embedUri = Uri.fromFile(embedFile)
val embedFields: FieldMap = hashMapOf(
"uri" to embedUri.toString(),
"mimeType" to embedMimeType,
)
if (isImage(embedMimeType) || isVideo(embedMimeType)) {
GlobalScope.launch {
FileImageProvider().fetchSingle(context, embedUri, embedMimeType, object : ImageProvider.ImageOpCallback {
override fun onSuccess(fields: FieldMap) {
embedFields.putAll(fields)
result.success(embedFields)
}
override fun onFailure(throwable: Throwable) = result.error("extractXmpDataProp-failure", "failed to get entry for uri=$embedUri mime=$embedMimeType", throwable.message)
})
}
} else {
result.success(embedFields)
}
return
} catch (e: XMPException) {
result.error("extractXmpDataProp-args", "failed to read XMP directory for uri=$uri prop=$dataPropPath", e.message)
return
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to extract file from XMP", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to extract file from XMP", e)
}
}
result.error("extractXmpDataProp-empty", "failed to extract file from XMP uri=$uri prop=$dataPropPath", null)
}
companion object {
private val LOG_TAG = LogUtils.createTag(MetadataHandler::class.java)
const val CHANNEL = "deckers.thibault/aves/metadata"
@ -688,14 +623,19 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
// catalog metadata
private const val KEY_MIME_TYPE = "mimeType"
private const val KEY_DATE_MILLIS = "dateMillis"
private const val KEY_IS_ANIMATED = "isAnimated"
private const val KEY_IS_FLIPPED = "isFlipped"
private const val KEY_FLAGS = "flags"
private const val KEY_ROTATION_DEGREES = "rotationDegrees"
private const val KEY_LATITUDE = "latitude"
private const val KEY_LONGITUDE = "longitude"
private const val KEY_XMP_SUBJECTS = "xmpSubjects"
private const val KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription"
private const val MASK_IS_ANIMATED = 1 shl 0
private const val MASK_IS_FLIPPED = 1 shl 1
private const val MASK_IS_GEOTIFF = 1 shl 2
private const val MASK_IS_360 = 1 shl 3
private const val XMP_SUBJECTS_SEPARATOR = ";"
// overlay metadata
private const val KEY_APERTURE = "aperture"
private const val KEY_EXPOSURE_TIME = "exposureTime"

View file

@ -13,6 +13,7 @@ import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.decoder.TiffThumbnail
import deckers.thibault.aves.decoder.VideoThumbnail
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.BitmapUtils.getBytes
@ -21,7 +22,6 @@ 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 org.beyka.tiffbitmapfactory.TiffBitmapFactory
class ThumbnailFetcher internal constructor(
private val context: Context,
@ -45,9 +45,7 @@ class ThumbnailFetcher internal constructor(
var exception: Exception? = null
try {
if (mimeType == MimeTypes.TIFF) {
bitmap = getTiff()
} else if ((width == defaultSize || height == defaultSize) && !isFlipped) {
if (mimeType != MimeTypes.TIFF && (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,
@ -121,10 +119,11 @@ class ThumbnailFetcher internal constructor(
.load(VideoThumbnail(context, uri))
.submit(width, height)
} else {
val model: Any = if (mimeType == MimeTypes.TIFF) TiffThumbnail(context, uri) else uri
Glide.with(context)
.asBitmap()
.apply(options)
.load(uri)
.load(model)
.submit(width, height)
}
@ -138,31 +137,4 @@ class ThumbnailFetcher internal constructor(
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

@ -0,0 +1,40 @@
package deckers.thibault.aves.channel.calls
import android.content.Context
import android.graphics.Rect
import android.net.Uri
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import io.flutter.plugin.common.MethodChannel
import org.beyka.tiffbitmapfactory.DecodeArea
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
class TiffRegionFetcher internal constructor(
private val context: Context,
) {
fun fetch(
uri: Uri,
sampleSize: Int,
regionRect: Rect,
page: Int = 0,
result: MethodChannel.Result,
) {
val resolver = context.contentResolver
try {
resolver.openFileDescriptor(uri, "r")?.use { descriptor ->
val options = TiffBitmapFactory.Options().apply {
inDirectoryNumber = page
inSampleSize = sampleSize
inDecodeArea = DecodeArea(regionRect.left, regionRect.top, regionRect.width(), regionRect.height())
}
val bitmap = TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
if (bitmap != null) {
result.success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
} else {
result.error("getRegion-tiff-null", "failed to decode region for uri=$uri page=$page regionRect=$regionRect", null)
}
}
} catch (e: Exception) {
result.error("getRegion-tiff-read-exception", "failed to read from uri=$uri page=$page regionRect=$regionRect", e.message)
}
}
}

View file

@ -95,7 +95,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
private fun streamImageByGlide(uri: Uri, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) {
val target = Glide.with(activity)
.asBitmap()
.apply(options)
.apply(glideOptions)
.load(uri)
.submit()
try {
@ -118,7 +118,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
private fun streamVideoByGlide(uri: Uri) {
val target = Glide.with(activity)
.asBitmap()
.apply(options)
.apply(glideOptions)
.load(VideoThumbnail(activity, uri))
.submit()
try {
@ -135,7 +135,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
}
}
private fun streamTiffImage(uri: Uri) {
private fun streamTiffImage(uri: Uri, page: Int = 0) {
val resolver = activity.contentResolver
try {
var dirCount = 0
@ -148,18 +148,17 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
}
// TODO TLAD handle multipage TIFF
if (dirCount > 0) {
val i = 0
if (dirCount > page) {
resolver.openFileDescriptor(uri, "r")?.use { descriptor ->
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = false
inDirectoryNumber = i
inDirectoryNumber = page
}
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)
error("streamImage-tiff-null", "failed to get tiff image (dir=$page) from uri=$uri", null)
}
}
}
@ -192,7 +191,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
const val bufferSize = 2 shl 17 // 256kB
// request a fresh image with the highest quality format
val options = RequestOptions()
val glideOptions = RequestOptions()
.format(DecodeFormat.PREFER_ARGB_8888)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true)

View file

@ -0,0 +1,91 @@
package deckers.thibault.aves.decoder
import android.content.Context
import android.net.Uri
import com.bumptech.glide.Glide
import com.bumptech.glide.Priority
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.data.DataFetcher
import com.bumptech.glide.load.data.DataFetcher.DataCallback
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.bumptech.glide.module.LibraryGlideModule
import com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import java.io.InputStream
@GlideModule
class TiffThumbnailGlideModule : LibraryGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
registry.append(TiffThumbnail::class.java, InputStream::class.java, TiffThumbnailLoader.Factory())
}
}
class TiffThumbnail(val context: Context, val uri: Uri)
internal class TiffThumbnailLoader : ModelLoader<TiffThumbnail, InputStream> {
override fun buildLoadData(model: TiffThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<InputStream> {
return ModelLoader.LoadData(ObjectKey(model.uri), TiffThumbnailFetcher(model, width, height))
}
override fun handles(tiffThumbnail: TiffThumbnail): Boolean = true
internal class Factory : ModelLoaderFactory<TiffThumbnail, InputStream> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<TiffThumbnail, InputStream> = TiffThumbnailLoader()
override fun teardown() {}
}
}
internal class TiffThumbnailFetcher(val model: TiffThumbnail, val width: Int, val height: Int) : DataFetcher<InputStream> {
override fun loadData(priority: Priority, callback: DataCallback<in InputStream>) {
val context = model.context
val uri = model.uri
// 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
val bitmap = context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = false
inSampleSize = sampleSize
}
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
}
if (bitmap == null) {
callback.onLoadFailed(Exception("null bitmap"))
} else {
callback.onDataReady(bitmap.getBytes().inputStream())
}
}
// already cleaned up in loadData and ByteArrayInputStream will be GC'd
override fun cleanup() {}
// cannot cancel
override fun cancel() {}
override fun getDataClass(): Class<InputStream> = InputStream::class.java
override fun getDataSource(): DataSource = DataSource.LOCAL
}

View file

@ -30,7 +30,7 @@ class VideoThumbnailGlideModule : LibraryGlideModule() {
class VideoThumbnail(val context: Context, val uri: Uri)
internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, InputStream> {
override fun buildLoadData(model: VideoThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<InputStream>? {
override fun buildLoadData(model: VideoThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<InputStream> {
return ModelLoader.LoadData(ObjectKey(model.uri), VideoThumbnailFetcher(model))
}

View file

@ -0,0 +1,52 @@
package deckers.thibault.aves.metadata
object Geotiff {
// ModelPixelScaleTag (optional)
// Tag = 33550 (830E.H)
// Type = DOUBLE
// Count = 3
const val TAG_MODEL_PIXEL_SCALE = 0x830e
// ModelTiepointTag (conditional)
// Tag = 33922 (8482.H)
// Type = DOUBLE
// Count = 6*K, K = number of tiepoints
const val TAG_MODEL_TIEPOINT = 0x8482
// ModelTransformationTag (conditional)
// Tag = 34264 (85D8.H)
// Type = DOUBLE
// Count = 16
const val TAG_MODEL_TRANSFORMATION = 0x85d8
// GeoKeyDirectoryTag (mandatory)
// Tag = 34735 (87AF.H)
// Type = UNSIGNED SHORT
// Count = variable, >= 4
const val TAG_GEO_KEY_DIRECTORY = 0x87af
// GeoDoubleParamsTag (optional)
// Tag = 34736 (87BO.H)
// Type = DOUBLE
// Count = variable
const val TAG_GEO_DOUBLE_PARAMS = 0x87b0
// GeoAsciiParamsTag (optional)
// Tag = 34737 (87B1.H)
// Type = ASCII
// Count = variable
val TAG_GEO_ASCII_PARAMS = 0x87b1
private val tagNameMap = hashMapOf(
TAG_GEO_ASCII_PARAMS to "Geo Ascii Params",
TAG_GEO_DOUBLE_PARAMS to "Geo Double Params",
TAG_GEO_KEY_DIRECTORY to "Geo Key Directory",
TAG_MODEL_PIXEL_SCALE to "Model Pixel Scale",
TAG_MODEL_TIEPOINT to "Model Tiepoint",
TAG_MODEL_TRANSFORMATION to "Model Transformation",
)
fun getTagName(tag: Int): String? {
return tagNameMap[tag]
}
}

View file

@ -1,6 +1,12 @@
package deckers.thibault.aves.metadata
import android.content.Context
import android.net.Uri
import androidx.exifinterface.media.ExifInterface
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils
import java.io.File
import java.io.InputStream
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.*
@ -13,6 +19,9 @@ object Metadata {
// "+51.3328-000.7053+113.474/" (Apple)
val VIDEO_LOCATION_PATTERN: Pattern = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+).*")
// cf https://github.com/google/spatial-media
const val SPHERICAL_VIDEO_V1_UUID = "ffcc8263-f855-4a93-8814-587a02521fdd"
// directory names, as shown when listing all metadata
const val DIR_GPS = "GPS" // from metadata-extractor
const val DIR_XMP = "XMP" // from metadata-extractor
@ -41,6 +50,8 @@ object Metadata {
}
}
// not sure which standards are used for the different video formats,
// but looks like some form of ISO 8601 `basic format`:
// yyyyMMddTHHmmss(.sss)?(Z|+/-hhmm)?
fun parseVideoMetadataDate(metadataDate: String?): Long {
var dateString = metadataDate ?: return 0
@ -83,4 +94,42 @@ object Metadata {
}
return dateMillis
}
// opening large TIFF files yields an OOM (both with `metadata-extractor` v2.15.0 and `ExifInterface` v1.3.1),
// so we define an arbitrary threshold to avoid a crash on launch.
// It is not clear whether it is because of the file itself or its metadata.
const val tiffSizeBytesMax = 100 * (1 shl 20) // MB
// we try and read metadata from large files by copying an arbitrary amount from its beginning
// to a temporary file, and reusing that preview file for all metadata reading purposes
private const val previewSize = 5 * (1 shl 20) // MB
private val previewFiles = HashMap<Uri, File>()
private fun getSafeUri(context: Context, uri: Uri, mimeType: String, sizeBytes: Long?): Uri {
if (mimeType != MimeTypes.TIFF) return uri
if (sizeBytes != null && sizeBytes < tiffSizeBytesMax) return uri
var previewFile = previewFiles[uri]
if (previewFile == null) {
previewFile = File.createTempFile("aves", null, context.cacheDir).apply {
deleteOnExit()
outputStream().use { outputStream ->
StorageUtils.openInputStream(context, uri)?.use { inputStream ->
val b = ByteArray(previewSize)
inputStream.read(b, 0, previewSize)
outputStream.write(b)
}
}
}
previewFiles[uri] = previewFile
}
return Uri.fromFile(previewFile)
}
fun openSafeInputStream(context: Context, uri: Uri, mimeType: String, sizeBytes: Long?): InputStream? {
val safeUri = getSafeUri(context, uri, mimeType, sizeBytes)
return StorageUtils.openInputStream(context, safeUri)
}
}

View file

@ -2,6 +2,7 @@ package deckers.thibault.aves.metadata
import com.drew.lang.Rational
import com.drew.metadata.Directory
import com.drew.metadata.exif.ExifIFD0Directory
import java.util.*
object MetadataExtractorHelper {
@ -34,4 +35,25 @@ object MetadataExtractorHelper {
fun Directory.getSafeDateMillis(tag: Int, save: (value: Long) -> Unit) {
if (this.containsTag(tag)) save(this.getDate(tag, null, TimeZone.getDefault()).time)
}
// geotiff
/*
cf http://docs.opengeospatial.org/is/19-008r4/19-008r4.html#_underlying_tiff_requirements
- One of ModelTiepointTag or ModelTransformationTag SHALL be included in an Image File Directory (IFD)
- If the ModelTransformationTag is included in an IFD, then a ModelPixelScaleTag SHALL NOT be included
- If the ModelPixelScaleTag is included in an IFD, then a ModelTiepointTag SHALL also be included.
*/
fun ExifIFD0Directory.isGeoTiff(): Boolean {
if (!this.containsTag(Geotiff.TAG_GEO_KEY_DIRECTORY)) return false
val modelTiepoint = this.containsTag(Geotiff.TAG_MODEL_TIEPOINT)
val modelTransformation = this.containsTag(Geotiff.TAG_MODEL_TRANSFORMATION)
if (!modelTiepoint && !modelTransformation) return false
val modelPixelScale = this.containsTag(Geotiff.TAG_MODEL_PIXEL_SCALE)
if ((modelTransformation && modelPixelScale) || (modelPixelScale && !modelTiepoint)) return false
return true
}
}

View file

@ -4,30 +4,105 @@ import android.util.Log
import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.XMPMeta
import deckers.thibault.aves.utils.LogUtils
import java.util.*
object XMP {
private val LOG_TAG = LogUtils.createTag(XMP::class.java)
const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/"
const val PHOTOSHOP_SCHEMA_NS = "http://ns.adobe.com/photoshop/1.0/"
const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/"
const val IMG_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/g/img/"
const val SUBJECT_PROP_NAME = "dc:subject"
const val TITLE_PROP_NAME = "dc:title"
const val DESCRIPTION_PROP_NAME = "dc:description"
const val THUMBNAIL_PROP_NAME = "xmp:Thumbnails"
const val THUMBNAIL_IMAGE_PROP_NAME = "xmpGImg:image"
const val PS_DATE_CREATED_PROP_NAME = "photoshop:DateCreated"
const val CREATE_DATE_PROP_NAME = "xmp:CreateDate"
private const val GENERIC_LANG = ""
private const val SPECIFIC_LANG = "en-US"
fun XMPMeta.getSafeLocalizedText(propName: String, save: (value: String) -> Unit) {
private val schemas = hashMapOf(
"GAudio" to "http://ns.google.com/photos/1.0/audio/",
"GDepth" to "http://ns.google.com/photos/1.0/depthmap/",
"GImage" to "http://ns.google.com/photos/1.0/image/",
"xmp" to XMP_SCHEMA_NS,
"xmpGImg" to "http://ns.adobe.com/xap/1.0/g/img/",
)
fun namespaceForPropPath(propPath: String) = schemas[propPath.split(":")[0]]
// embedded media data properties
// cf https://developers.google.com/depthmap-metadata
// cf https://developers.google.com/vr/reference/cardboard-camera-vr-photo-format
private val knownDataPaths = listOf("GAudio:Data", "GImage:Data", "GDepth:Data", "GDepth:Confidence")
fun isDataPath(path: String) = knownDataPaths.contains(path)
// panorama
// cf https://developers.google.com/streetview/spherical-metadata
private const val GPANO_SCHEMA_NS = "http://ns.google.com/photos/1.0/panorama/"
private const val PMTM_SCHEMA_NS = "http://www.hdrsoft.com/photomatix_settings01"
private const val GPANO_CROPPED_AREA_HEIGHT_PROP_NAME = "GPano:CroppedAreaImageHeightPixels"
private const val GPANO_CROPPED_AREA_WIDTH_PROP_NAME = "GPano:CroppedAreaImageWidthPixels"
private const val GPANO_CROPPED_AREA_LEFT_PROP_NAME = "GPano:CroppedAreaLeftPixels"
private const val GPANO_CROPPED_AREA_TOP_PROP_NAME = "GPano:CroppedAreaTopPixels"
private const val GPANO_FULL_PANO_HEIGHT_PIXELS_PROP_NAME = "GPano:FullPanoHeightPixels"
private const val GPANO_FULL_PANO_WIDTH_PIXELS_PROP_NAME = "GPano:FullPanoWidthPixels"
private const val GPANO_PROJECTION_TYPE_PROP_NAME = "GPano:ProjectionType"
private const val PMTM_IS_PANO360 = "pmtm:IsPano360"
private val gpanoRequiredProps = listOf(
GPANO_CROPPED_AREA_HEIGHT_PROP_NAME,
GPANO_CROPPED_AREA_WIDTH_PROP_NAME,
GPANO_CROPPED_AREA_LEFT_PROP_NAME,
GPANO_CROPPED_AREA_TOP_PROP_NAME,
GPANO_FULL_PANO_HEIGHT_PIXELS_PROP_NAME,
GPANO_FULL_PANO_WIDTH_PIXELS_PROP_NAME,
GPANO_PROJECTION_TYPE_PROP_NAME,
)
// extensions
fun XMPMeta.isPanorama(): Boolean {
// Google
if (gpanoRequiredProps.all { doesPropertyExist(GPANO_SCHEMA_NS, it) }) return true
// Photomatix
if (getPropertyString(PMTM_SCHEMA_NS, PMTM_IS_PANO360) == "Yes") return true
return false
}
fun XMPMeta.getSafeLocalizedText(schema: String, propName: String, acceptBlank: Boolean = true, save: (value: String) -> Unit) {
try {
if (this.doesPropertyExist(DC_SCHEMA_NS, propName)) {
val item = this.getLocalizedText(DC_SCHEMA_NS, propName, GENERIC_LANG, SPECIFIC_LANG)
if (doesPropertyExist(schema, propName)) {
val item = getLocalizedText(schema, propName, GENERIC_LANG, SPECIFIC_LANG)
// double check retrieved items as the property sometimes is reported to exist but it is actually null
if (item != null) save(item.value)
if (item != null && (acceptBlank || item.value.isNotBlank())) {
save(item.value)
}
}
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to get text for XMP propName=$propName", e)
Log.w(LOG_TAG, "failed to get text for XMP schema=$schema, propName=$propName", e)
}
}
fun XMPMeta.getSafeDateMillis(schema: String, propName: String, save: (value: Long) -> Unit) {
try {
if (doesPropertyExist(schema, propName)) {
val item = getPropertyDate(schema, propName)
// double check retrieved items as the property sometimes is reported to exist but it is actually null
if (item != null) {
// strip time zone from XMP dates so that we show date/times as local ones
// this aligns with Exif date/times, which are specified without time zones
item.timeZone = TimeZone.getDefault()
save(item.calendar.timeInMillis)
}
}
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to get text for XMP schema=$schema, propName=$propName", e)
}
}
}

View file

@ -20,6 +20,7 @@ import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDateMi
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeLong
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeString
import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
@ -27,6 +28,7 @@ import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeLong
import deckers.thibault.aves.model.provider.FieldMap
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import java.io.IOException
class SourceImageEntry {
@ -129,7 +131,10 @@ class SourceImageEntry {
fillByExifInterface(context)
}
if (!isSized) {
fillByBitmapDecode(context)
when (sourceMimeType) {
MimeTypes.TIFF -> fillByTiffDecode(context)
else -> fillByBitmapDecode(context)
}
}
return this
}
@ -155,10 +160,12 @@ class SourceImageEntry {
// finds: width, height, orientation, date, duration
private fun fillByMetadataExtractor(context: Context) {
// skip raw images because `metadata-extractor` reports the decoded dimensions instead of the raw dimensions
if (!MimeTypes.isSupportedByMetadataExtractor(sourceMimeType) || MimeTypes.isRaw(sourceMimeType)) return
if (!MimeTypes.isSupportedByMetadataExtractor(sourceMimeType)
|| MimeTypes.isRaw(sourceMimeType)
) return
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
Metadata.openSafeInputStream(context, uri, sourceMimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
// do not switch on specific mime types, as the reported mime type could be wrong
@ -207,10 +214,10 @@ class SourceImageEntry {
// finds: width, height, orientation, date
private fun fillByExifInterface(context: Context) {
if (!ExifInterface.isSupportedMimeType(sourceMimeType)) return;
if (!MimeTypes.isSupportedByExifInterface(sourceMimeType)) return
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
Metadata.openSafeInputStream(context, uri, sourceMimeType, sizeBytes)?.use { input ->
val exif = ExifInterface(input)
foundExif = true
exif.getSafeInt(ExifInterface.TAG_IMAGE_WIDTH, acceptZero = false) { width = it }
@ -240,6 +247,22 @@ class SourceImageEntry {
}
}
private fun fillByTiffDecode(context: Context) {
try {
context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true
}
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
width = options.outWidth
height = options.outHeight
}
} catch (e: Exception) {
// ignore
}
}
companion object {
// convenience method
private fun toLong(o: Any?): Long? = when (o) {

View file

@ -1,5 +1,7 @@
package deckers.thibault.aves.utils
import androidx.exifinterface.media.ExifInterface
object MimeTypes {
private const val IMAGE = "image"
@ -65,12 +67,16 @@ object MimeTypes {
else -> false
}
// as of metadata-extractor v2.14.0
// as of `metadata-extractor` v2.14.0
fun isSupportedByMetadataExtractor(mimeType: String) = when (mimeType) {
WBMP, MP2T, WEBM -> false
else -> true
}
// as of `ExifInterface` v1.3.1, `isSupportedMimeType` reports
// no support for TIFF images, but it can actually open them (maybe other formats too)
fun isSupportedByExifInterface(mimeType: String, strict: Boolean = true) = ExifInterface.isSupportedMimeType(mimeType) || !strict
// Glide automatically applies EXIF orientation when decoding images of known formats
// but we need to rotate the decoded bitmap for the other formats
// maybe related to ExifInterface version used by Glide:

View file

@ -3,4 +3,10 @@
<external-path
name="external_files"
path="." />
<!-- for images & other media embedded in XMP
and exported for viewing and sharing -->
<cache-path
name="xmp_props"
path="." />
</paths>

View file

@ -1,6 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '1.4.10'
ext.kotlin_version = '1.4.20'
repositories {
google()
jcenter()

View file

@ -10,6 +10,9 @@ class MimeFilter extends CollectionFilter {
// fake mime type
static const animated = 'aves/animated'; // subset of `image/gif` and `image/webp`
static const panorama = 'aves/panorama'; // subset of images
static const sphericalVideo = 'aves/spherical_video'; // subset of videos
static const geotiff = 'aves/geotiff'; // subset of `image/tiff`
final String mime;
bool Function(ImageEntry) _filter;
@ -22,6 +25,18 @@ class MimeFilter extends CollectionFilter {
_filter = (entry) => entry.isAnimated;
_label = 'Animated';
_icon = AIcons.animated;
} else if (mime == panorama) {
_filter = (entry) => entry.isImage && entry.is360;
_label = 'Panorama';
_icon = AIcons.threesixty;
} else if (mime == sphericalVideo) {
_filter = (entry) => entry.isVideo && entry.is360;
_label = '360° Video';
_icon = AIcons.threesixty;
} else if (mime == geotiff) {
_filter = (entry) => entry.isGeotiff;
_label = 'GeoTIFF';
_icon = AIcons.geo;
} else if (lowMime.endsWith('/*')) {
lowMime = lowMime.substring(0, lowMime.length - 2);
_filter = (entry) => entry.mimeType.startsWith(lowMime);

View file

@ -26,7 +26,7 @@ class QueryFilter extends CollectionFilter {
// allow untrimmed queries wrapped with `"..."`
final matches = exactRegex.allMatches(upQuery);
if (matches.length == 1) {
upQuery = matches.elementAt(0).group(1);
upQuery = matches.first.group(1);
}
_filter = not ? (entry) => !entry.search(upQuery) : (entry) => entry.search(upQuery);

View file

@ -173,12 +173,12 @@ class ImageEntry {
bool get isSvg => mimeType == MimeTypes.svg;
// 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;
bool get isPhoto => [MimeTypes.heic, MimeTypes.heif, MimeTypes.jpeg, MimeTypes.tiff].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 =>
bool get _supportedByBitmapRegionDecoder =>
[
MimeTypes.heic,
MimeTypes.heif,
@ -196,14 +196,22 @@ class ImageEntry {
].contains(mimeType) &&
!isAnimated;
bool get canTile => _supportedByBitmapRegionDecoder || mimeType == MimeTypes.tiff;
bool get isRaw => MimeTypes.rawImages.contains(mimeType);
bool get isVideo => mimeType.startsWith('video');
bool get isImage => MimeTypes.isImage(mimeType);
bool get isVideo => MimeTypes.isVideo(mimeType);
bool get isCatalogued => _catalogMetadata != null;
bool get isAnimated => _catalogMetadata?.isAnimated ?? false;
bool get isGeotiff => _catalogMetadata?.isGeotiff ?? false;
bool get is360 => _catalogMetadata?.is360 ?? false;
bool get canEdit => path != null;
bool get canPrint => !isVideo;
@ -299,7 +307,7 @@ class ImageEntry {
bool get isLocated => _addressDetails != null;
LatLng get latLng => isCatalogued ? LatLng(_catalogMetadata.latitude, _catalogMetadata.longitude) : null;
LatLng get latLng => hasGps ? LatLng(_catalogMetadata.latitude, _catalogMetadata.longitude) : null;
String get geoUri {
if (!hasGps) return null;
@ -373,12 +381,17 @@ class ImageEntry {
: call());
if (addresses != null && addresses.isNotEmpty) {
final address = addresses.first;
final cc = address.countryCode;
final cn = address.countryName;
final aa = address.adminArea;
addressDetails = AddressDetails(
contentId: contentId,
countryCode: address.countryCode,
countryName: address.countryName,
adminArea: address.adminArea,
locality: address.locality,
countryCode: cc,
countryName: cn,
adminArea: aa,
// if country & admin fields are null, it is likely the ocean,
// which is identified by `featureName` but we default to the address line anyway
locality: address.locality ?? (cc == null && cn == null && aa == null ? address.addressLine : null),
);
}
} catch (error, stackTrace) {

View file

@ -30,28 +30,41 @@ class DateMetadata {
class CatalogMetadata {
final int contentId, dateMillis;
final bool isAnimated;
final bool isAnimated, isGeotiff, is360;
bool isFlipped;
int rotationDegrees;
final String mimeType, xmpSubjects, xmpTitleDescription;
final double latitude, longitude;
double latitude, longitude;
Address address;
static const double _precisionErrorTolerance = 1e-9;
static const _isAnimatedMask = 1 << 0;
static const _isFlippedMask = 1 << 1;
static const _isGeotiffMask = 1 << 2;
static const _is360Mask = 1 << 3;
CatalogMetadata({
this.contentId,
this.mimeType,
this.dateMillis,
this.isAnimated,
this.isFlipped,
this.isGeotiff,
this.is360,
this.rotationDegrees,
this.xmpSubjects,
this.xmpTitleDescription,
double latitude,
double longitude,
})
// Geocoder throws an IllegalArgumentException when a coordinate has a funky values like 1.7056881853375E7
: latitude = latitude == null || latitude < -90.0 || latitude > 90.0 ? null : latitude,
longitude = longitude == null || longitude < -180.0 || longitude > 180.0 ? null : longitude;
}) {
// Geocoder throws an `IllegalArgumentException` when a coordinate has a funky values like `1.7056881853375E7`
// We also exclude zero coordinates, taking into account precision errors (e.g. {5.952380952380953e-11,-2.7777777777777777e-10}),
// but Flutter's `precisionErrorTolerance` (1e-10) is slightly too lenient for this case.
if (latitude != null && longitude != null && (latitude.abs() > _precisionErrorTolerance || longitude.abs() > _precisionErrorTolerance)) {
this.latitude = latitude < -90.0 || latitude > 90.0 ? null : latitude;
this.longitude = longitude < -180.0 || longitude > 180.0 ? null : longitude;
}
}
CatalogMetadata copyWith({
@required int contentId,
@ -62,6 +75,8 @@ class CatalogMetadata {
dateMillis: dateMillis,
isAnimated: isAnimated,
isFlipped: isFlipped,
isGeotiff: isGeotiff,
is360: is360,
rotationDegrees: rotationDegrees,
xmpSubjects: xmpSubjects,
xmpTitleDescription: xmpTitleDescription,
@ -70,15 +85,16 @@ class CatalogMetadata {
);
}
factory CatalogMetadata.fromMap(Map map, {bool boolAsInteger = false}) {
final isAnimated = map['isAnimated'] ?? (boolAsInteger ? 0 : false);
final isFlipped = map['isFlipped'] ?? (boolAsInteger ? 0 : false);
factory CatalogMetadata.fromMap(Map map) {
final flags = map['flags'] ?? 0;
return CatalogMetadata(
contentId: map['contentId'],
mimeType: map['mimeType'],
dateMillis: map['dateMillis'] ?? 0,
isAnimated: boolAsInteger ? isAnimated != 0 : isAnimated,
isFlipped: boolAsInteger ? isFlipped != 0 : isFlipped,
isAnimated: flags & _isAnimatedMask != 0,
isFlipped: flags & _isFlippedMask != 0,
isGeotiff: flags & _isGeotiffMask != 0,
is360: flags & _is360Mask != 0,
// `rotationDegrees` should default to `sourceRotationDegrees`, not 0
rotationDegrees: map['rotationDegrees'],
xmpSubjects: map['xmpSubjects'] ?? '',
@ -88,12 +104,11 @@ class CatalogMetadata {
);
}
Map<String, dynamic> toMap({bool boolAsInteger = false}) => {
Map<String, dynamic> toMap() => {
'contentId': contentId,
'mimeType': mimeType,
'dateMillis': dateMillis,
'isAnimated': boolAsInteger ? (isAnimated ? 1 : 0) : isAnimated,
'isFlipped': boolAsInteger ? (isFlipped ? 1 : 0) : isFlipped,
'flags': (isAnimated ? _isAnimatedMask : 0) | (isFlipped ? _isFlippedMask : 0) | (isGeotiff ? _isGeotiffMask : 0) | (is360 ? _is360Mask : 0),
'rotationDegrees': rotationDegrees,
'xmpSubjects': xmpSubjects,
'xmpTitleDescription': xmpTitleDescription,
@ -103,7 +118,7 @@ class CatalogMetadata {
@override
String toString() {
return 'CatalogMetadata{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}';
return 'CatalogMetadata{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}';
}
}

View file

@ -2,6 +2,7 @@ import 'dart:io';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/metadata_db_upgrade.dart';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';
@ -48,8 +49,7 @@ class MetadataDb {
'contentId INTEGER PRIMARY KEY'
', mimeType TEXT'
', dateMillis INTEGER'
', isAnimated INTEGER'
', isFlipped INTEGER'
', flags INTEGER'
', rotationDegrees INTEGER'
', xmpSubjects TEXT'
', xmpTitleDescription TEXT'
@ -69,65 +69,8 @@ class MetadataDb {
', path TEXT'
')');
},
onUpgrade: (db, oldVersion, newVersion) async {
// warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported
// on SQLite <3.25.0, bundled on older Android devices
while (oldVersion < newVersion) {
if (oldVersion == 1) {
// rename column 'orientationDegrees' to 'sourceRotationDegrees'
await db.transaction((txn) async {
const newEntryTable = '${entryTable}TEMP';
await db.execute('CREATE TABLE $newEntryTable('
'contentId INTEGER PRIMARY KEY'
', uri TEXT'
', path TEXT'
', sourceMimeType TEXT'
', width INTEGER'
', height INTEGER'
', sourceRotationDegrees INTEGER'
', sizeBytes INTEGER'
', title TEXT'
', dateModifiedSecs INTEGER'
', sourceDateTakenMillis INTEGER'
', durationMillis INTEGER'
')');
await db.rawInsert('INSERT INTO $newEntryTable(contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis)'
' SELECT contentId,uri,path,sourceMimeType,width,height,orientationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis'
' FROM $entryTable;');
await db.execute('DROP TABLE $entryTable;');
await db.execute('ALTER TABLE $newEntryTable RENAME TO $entryTable;');
});
// rename column 'videoRotation' to 'rotationDegrees'
await db.transaction((txn) async {
const newMetadataTable = '${metadataTable}TEMP';
await db.execute('CREATE TABLE $newMetadataTable('
'contentId INTEGER PRIMARY KEY'
', mimeType TEXT'
', dateMillis INTEGER'
', isAnimated INTEGER'
', rotationDegrees INTEGER'
', xmpSubjects TEXT'
', xmpTitleDescription TEXT'
', latitude REAL'
', longitude REAL'
')');
await db.rawInsert('INSERT INTO $newMetadataTable(contentId,mimeType,dateMillis,isAnimated,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude)'
' SELECT contentId,mimeType,dateMillis,isAnimated,videoRotation,xmpSubjects,xmpTitleDescription,latitude,longitude'
' FROM $metadataTable;');
await db.rawInsert('UPDATE $newMetadataTable SET rotationDegrees = NULL WHERE rotationDegrees = 0;');
await db.execute('DROP TABLE $metadataTable;');
await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;');
});
// new column 'isFlipped'
await db.execute('ALTER TABLE $metadataTable ADD COLUMN isFlipped INTEGER;');
oldVersion++;
}
}
},
version: 2,
onUpgrade: MetadataDbUpgrader.upgradeDb,
version: 3,
);
}
@ -238,7 +181,7 @@ class MetadataDb {
// final stopwatch = Stopwatch()..start();
final db = await _database;
final maps = await db.query(metadataTable);
final metadataEntries = maps.map((map) => CatalogMetadata.fromMap(map, boolAsInteger: true)).toList();
final metadataEntries = maps.map((map) => CatalogMetadata.fromMap(map)).toList();
// debugPrint('$runtimeType loadMetadataEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries');
return metadataEntries;
}
@ -246,11 +189,15 @@ class MetadataDb {
Future<void> saveMetadata(Iterable<CatalogMetadata> metadataEntries) async {
if (metadataEntries == null || metadataEntries.isEmpty) return;
final stopwatch = Stopwatch()..start();
final db = await _database;
final batch = db.batch();
metadataEntries.where((metadata) => metadata != null).forEach((metadata) => _batchInsertMetadata(batch, metadata));
await batch.commit(noResult: true);
debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries');
try {
final db = await _database;
final batch = db.batch();
metadataEntries.where((metadata) => metadata != null).forEach((metadata) => _batchInsertMetadata(batch, metadata));
await batch.commit(noResult: true);
debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries');
} catch (exception, stack) {
debugPrint('$runtimeType failed to save metadata with exception=$exception\n$stack');
}
}
Future<void> updateMetadataId(int oldId, CatalogMetadata metadata) async {
@ -273,7 +220,7 @@ class MetadataDb {
}
batch.insert(
metadataTable,
metadata.toMap(boolAsInteger: true),
metadata.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}

View file

@ -0,0 +1,100 @@
import 'package:aves/model/metadata_db.dart';
import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart';
class MetadataDbUpgrader {
static const entryTable = MetadataDb.entryTable;
static const metadataTable = MetadataDb.metadataTable;
// warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported
// on SQLite <3.25.0, bundled on older Android devices
static Future<void> upgradeDb(Database db, int oldVersion, int newVersion) async {
while (oldVersion < newVersion) {
switch (oldVersion) {
case 1:
await _upgradeFrom1(db);
break;
case 2:
await _upgradeFrom2(db);
break;
}
oldVersion++;
}
}
static Future<void> _upgradeFrom1(Database db) async {
debugPrint('upgrading DB from v1');
// rename column 'orientationDegrees' to 'sourceRotationDegrees'
await db.transaction((txn) async {
const newEntryTable = '${entryTable}TEMP';
await db.execute('CREATE TABLE $newEntryTable('
'contentId INTEGER PRIMARY KEY'
', uri TEXT'
', path TEXT'
', sourceMimeType TEXT'
', width INTEGER'
', height INTEGER'
', sourceRotationDegrees INTEGER'
', sizeBytes INTEGER'
', title TEXT'
', dateModifiedSecs INTEGER'
', sourceDateTakenMillis INTEGER'
', durationMillis INTEGER'
')');
await db.rawInsert('INSERT INTO $newEntryTable(contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis)'
' SELECT contentId,uri,path,sourceMimeType,width,height,orientationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis'
' FROM $entryTable;');
await db.execute('DROP TABLE $entryTable;');
await db.execute('ALTER TABLE $newEntryTable RENAME TO $entryTable;');
});
// rename column 'videoRotation' to 'rotationDegrees'
await db.transaction((txn) async {
const newMetadataTable = '${metadataTable}TEMP';
await db.execute('CREATE TABLE $newMetadataTable('
'contentId INTEGER PRIMARY KEY'
', mimeType TEXT'
', dateMillis INTEGER'
', isAnimated INTEGER'
', rotationDegrees INTEGER'
', xmpSubjects TEXT'
', xmpTitleDescription TEXT'
', latitude REAL'
', longitude REAL'
')');
await db.rawInsert('INSERT INTO $newMetadataTable(contentId,mimeType,dateMillis,isAnimated,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude)'
' SELECT contentId,mimeType,dateMillis,isAnimated,videoRotation,xmpSubjects,xmpTitleDescription,latitude,longitude'
' FROM $metadataTable;');
await db.rawInsert('UPDATE $newMetadataTable SET rotationDegrees = NULL WHERE rotationDegrees = 0;');
await db.execute('DROP TABLE $metadataTable;');
await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;');
});
// new column 'isFlipped'
await db.execute('ALTER TABLE $metadataTable ADD COLUMN isFlipped INTEGER;');
}
static Future<void> _upgradeFrom2(Database db) async {
debugPrint('upgrading DB from v2');
// merge columns 'isAnimated' and 'isFlipped' into 'flags'
await db.transaction((txn) async {
const newMetadataTable = '${metadataTable}TEMP';
await db.execute('CREATE TABLE $newMetadataTable('
'contentId INTEGER PRIMARY KEY'
', mimeType TEXT'
', dateMillis INTEGER'
', flags INTEGER'
', rotationDegrees INTEGER'
', xmpSubjects TEXT'
', xmpTitleDescription TEXT'
', latitude REAL'
', longitude REAL'
')');
await db.rawInsert('INSERT INTO $newMetadataTable(contentId,mimeType,dateMillis,flags,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude)'
' SELECT contentId,mimeType,dateMillis,ifnull(isAnimated,0)+ifnull(isFlipped,0)*2,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude'
' FROM $metadataTable;');
await db.execute('DROP TABLE $metadataTable;');
await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;');
});
}
}

View file

@ -1,6 +1,7 @@
import 'package:flutter/painting.dart';
class BrandColors {
static const Color adobeAfterEffects = Color(0xFF9A9AFF);
static const Color adobeIllustrator = Color(0xFFFF9B00);
static const Color adobePhotoshop = Color(0xFF2DAAFF);
static const Color android = Color(0xFF3DDC84);
@ -9,6 +10,8 @@ class BrandColors {
static Color get(String text) {
if (text != null) {
switch (text.toLowerCase()) {
case 'after effects':
return adobeAfterEffects;
case 'illustrator':
return adobeIllustrator;
case 'photoshop':

639
lib/ref/exif.dart Normal file
View file

@ -0,0 +1,639 @@
class Exif {
static String getColorSpaceDescription(String valueString) {
final value = int.tryParse(valueString);
if (value == null) return valueString;
switch (value) {
case 1:
return 'sRGB';
case 65535:
return 'Uncalibrated';
default:
return 'Unknown ($value)';
}
}
static String getContrastDescription(String valueString) {
final value = int.tryParse(valueString);
if (value == null) return valueString;
switch (value) {
case 0:
return 'Normal';
case 1:
return 'Soft';
case 2:
return 'Hard';
default:
return 'Unknown ($value)';
}
}
// adapted from `metadata-extractor`
static String getCompressionDescription(String valueString) {
final value = int.tryParse(valueString);
if (value == null) return valueString;
switch (value) {
case 1:
return 'Uncompressed';
case 2:
return 'CCITT 1D';
case 3:
return 'T4/Group 3 Fax';
case 4:
return 'T6/Group 4 Fax';
case 5:
return 'LZW';
case 6:
return 'JPEG (old-style)';
case 7:
return 'JPEG';
case 8:
return 'Adobe Deflate';
case 9:
return 'JBIG B&W';
case 10:
return 'JBIG Color';
case 99:
return 'JPEG';
case 262:
return 'Kodak 262';
case 32766:
return 'Next';
case 32767:
return 'Sony ARW Compressed';
case 32769:
return 'Packed RAW';
case 32770:
return 'Samsung SRW Compressed';
case 32771:
return 'CCIRLEW';
case 32772:
return 'Samsung SRW Compressed 2';
case 32773:
return 'PackBits';
case 32809:
return 'Thunderscan';
case 32867:
return 'Kodak KDC Compressed';
case 32895:
return 'IT8CTPAD';
case 32896:
return 'IT8LW';
case 32897:
return 'IT8MP';
case 32898:
return 'IT8BL';
case 32908:
return 'PixarFilm';
case 32909:
return 'PixarLog';
case 32946:
return 'Deflate';
case 32947:
return 'DCS';
case 34661:
return 'JBIG';
case 34676:
return 'SGILog';
case 34677:
return 'SGILog24';
case 34712:
return 'JPEG 2000';
case 34713:
return 'Nikon NEF Compressed';
case 34715:
return 'JBIG2 TIFF FX';
case 34718:
return 'Microsoft Document Imaging (MDI) Binary Level Codec';
case 34719:
return 'Microsoft Document Imaging (MDI) Progressive Transform Codec';
case 34720:
return 'Microsoft Document Imaging (MDI) Vector';
case 34892:
return 'Lossy JPEG';
case 65000:
return 'Kodak DCR Compressed';
case 65535:
return 'Pentax PEF Compressed';
default:
return 'Unknown ($value)';
}
}
static String getCustomRenderedDescription(String valueString) {
final value = int.tryParse(valueString);
if (value == null) return valueString;
switch (value) {
case 0:
return 'Normal process';
case 1:
return 'Custom process';
default:
return 'Unknown ($value)';
}
}
static String getExifVersionDescription(String valueString) {
if (valueString?.length == 4) {
final major = int.tryParse(valueString.substring(0, 2));
final minor = int.tryParse(valueString.substring(2, 4));
if (major != null && minor != null) {
return '$major.$minor';
}
}
return valueString;
}
static String getExposureModeDescription(String valueString) {
final value = int.tryParse(valueString);
if (value == null) return valueString;
switch (value) {
case 0:
return 'Auto exposure';
case 1:
return 'Manual exposure';
case 2:
return 'Auto bracket';
default:
return 'Unknown ($value)';
}
}
static String getExposureProgramDescription(String valueString) {
final value = int.tryParse(valueString);
if (value == null) return valueString;
switch (value) {
case 1:
return 'Manual';
case 2:
return 'Normal program';
case 3:
return 'Aperture priority';
case 4:
return 'Shutter priority';
case 5:
return 'Creative program';
case 6:
return 'Action program';
case 7:
return 'Portrait mode';
case 8:
return 'Landscape mode';
default:
return 'Unknown ($value)';
}
}
// adapted from `metadata-extractor`
static String getFileSourceDescription(String valueString) {
final value = int.tryParse(valueString);
if (value == null) return valueString;
switch (value) {
case 1:
return 'Film Scanner';
case 2:
return 'Reflection Print Scanner';
case 3:
return 'Digital Still Camera (DSC)';
default:
return 'Unknown ($value)';
}
}
static String getLightSourceDescription(String valueString) {
final value = int.tryParse(valueString);
if (value == null) return valueString;
switch (value) {
case 0:
return 'Unknown';
case 1:
return 'Daylight';
case 2:
return 'Fluorescent';
case 3:
return 'Tungsten (Incandescent)';
case 4:
return 'Flash';
case 9:
return 'Fine Weather';
case 10:
return 'Cloudy Weather';
case 11:
return 'Shade';
case 12:
return 'Daylight Fluorescent (D 5700 7100K)';
case 13:
return 'Day White Fluorescent (N 4600 5400K)';
case 14:
return 'Cool White Fluorescent (W 3900 4500K)';
case 15:
return 'White Fluorescent (WW 3200 3700K)';
case 16:
return 'Warm White Fluorescent (WW 2600 - 3250K)';
case 17:
return 'Standard light A';
case 18:
return 'Standard light B';
case 19:
return 'Standard light C';
case 20:
return 'D55';
case 21:
return 'D65';
case 22:
return 'D75';
case 23:
return 'D50';
case 24:
return 'ISO Studio Tungsten';
case 255:
return 'Other';
default:
return 'Unknown ($value)';
}
}
// adapted from `metadata-extractor`
static String getOrientationDescription(String valueString) {
final value = int.tryParse(valueString);
if (value == null) return valueString;
switch (value) {
case 1:
return 'Top, left side (Horizontal / normal)';
case 2:
return 'Top, right side (Mirror horizontal)';
case 3:
return 'Bottom, right side (Rotate 180)';
case 4:
return 'Bottom, left side (Mirror vertical)';
case 5:
return 'Left side, top (Mirror horizontal and rotate 270 CW)';
case 6:
return 'Right side, top (Rotate 90 CW)';
case 7:
return 'Right side, bottom (Mirror horizontal and rotate 90 CW)';
case 8:
return 'Left side, bottom (Rotate 270 CW)';
default:
return 'Unknown ($value)';
}
}
// adapted from `metadata-extractor`
static String getPhotometricInterpretationDescription(String valueString) {
final value = int.tryParse(valueString);
if (value == null) return valueString;
switch (value) {
case 0:
return 'WhiteIsZero';
case 1:
return 'BlackIsZero';
case 2:
return 'RGB';
case 3:
return 'RGB Palette';
case 4:
return 'Transparency Mask';
case 5:
return 'CMYK';
case 6:
return 'YCbCr';
case 8:
return 'CIELab';
case 9:
return 'ICCLab';
case 10:
return 'ITULab';
case 32803:
return 'Color Filter Array';
case 32844:
return 'Pixar LogL';
case 32845:
return 'Pixar LogLuv';
case 32892:
return 'Linear Raw';
default:
return 'Unknown ($value)';
}
}
// adapted from `metadata-extractor`
static String getPlanarConfigurationDescription(String valueString) {
final value = int.tryParse(valueString);
if (value == null) return valueString;
switch (value) {
case 1:
return 'Chunky (contiguous for each subsampling pixel)';
case 2:
return 'Separate (Y-plane/Cb-plane/Cr-plane format)';
default:
return 'Unknown ($value)';
}
}
// adapted from `metadata-extractor`
static String getResolutionUnitDescription(String valueString) {
final value = int.tryParse(valueString);
if (value == null) return valueString;
switch (value) {
case 1:
return '(No unit)';
case 2:
return 'Inch';
case 3:
return 'cm';
default:
return 'Unknown ($value)';
}
}
static String getGainControlDescription(String valueString) {
final value = int.tryParse(valueString);
if (value == null) return valueString;
switch (value) {
case 0:
return 'None';
case 1:
return 'Low gain up';
case 2:
return 'High gain up';
case 3:
return 'Low gain down';
case 4:
return 'High gain down';
default:
return 'Unknown ($value)';
}
}
static String getMeteringModeDescription(String valueString) {
final value = int.tryParse(valueString);
if (value == null) return valueString;
switch (value) {
case 0:
return 'Unknown';
case 1:
return 'Average';
case 2:
return 'Center weighted average';
case 3:
return 'Spot';
case 4:
return 'Multi-spot';
case 5:
return 'Pattern';
case 6:
return 'Partial';
case 255:
return 'Other';
default:
return 'Unknown ($value)';
}
}
static String getSaturationDescription(String valueString) {
final value = int.tryParse(valueString);
if (value == null) return valueString;
switch (value) {
case 0:
return 'Normal';
case 1:
return 'Low saturation';
case 2:
return 'High saturation';
default:
return 'Unknown ($value)';
}
}
static String getSceneCaptureTypeDescription(String valueString) {
final value = int.tryParse(valueString);
if (value == null) return valueString;
switch (value) {
case 0:
return 'Standard';
case 1:
return 'Landscape';
case 2:
return 'Portrait';
case 3:
return 'Night scene';
default:
return 'Unknown ($value)';
}
}
static String getSceneTypeDescription(String valueString) {
final value = int.tryParse(valueString);
if (value == null) return valueString;
switch (value) {
case 1:
return 'Directly photographed image';
default:
return 'Unknown ($value)';
}
}
static String getSensingMethodDescription(String valueString) {
final value = int.tryParse(valueString);
if (value == null) return valueString;
switch (value) {
case 1:
return 'Not defined';
case 2:
return 'One-chip colour area sensor';
case 3:
return 'Two-chip colour area sensor';
case 4:
return 'Three-chip colour area sensor';
case 5:
return 'Colour sequential area sensor';
case 7:
return 'Trilinear sensor';
case 8:
return 'Colour sequential linear sensor';
default:
return 'Unknown ($value)';
}
}
static String getSharpnessDescription(String valueString) {
final value = int.tryParse(valueString);
if (value == null) return valueString;
switch (value) {
case 0:
return 'Normal';
case 1:
return 'Soft';
case 2:
return 'Hard';
default:
return 'Unknown ($value)';
}
}
static String getSubjectDistanceRangeDescription(String valueString) {
final value = int.tryParse(valueString);
if (value == null) return valueString;
switch (value) {
case 0:
return 'Unknown';
case 1:
return 'Macro';
case 2:
return 'Close view';
case 3:
return 'Distant view';
default:
return 'Unknown ($value)';
}
}
static String getWhiteBalanceDescription(String valueString) {
final value = int.tryParse(valueString);
if (value == null) return valueString;
switch (value) {
case 0:
return 'Auto';
case 1:
return 'Manual';
default:
return 'Unknown ($value)';
}
}
static String getYCbCrPositioningDescription(String valueString) {
final value = int.tryParse(valueString);
if (value == null) return valueString;
switch (value) {
case 1:
return 'Centered';
case 2:
return 'Co-sited';
default:
return 'Unknown ($value)';
}
}
// Flash
static String getFlashModeDescription(String valueString) {
final value = int.tryParse(valueString);
if (value == null) return valueString;
switch (value) {
case 0:
return 'Unknown';
case 1:
return 'Compulsory flash firing';
case 2:
return 'Compulsory flash suppression';
case 3:
return 'Auto mode';
default:
return 'Unknown ($value)';
}
}
static String getFlashReturnDescription(String valueString) {
final value = int.tryParse(valueString);
if (value == null) return valueString;
switch (value) {
case 0:
return 'No strobe return detection';
case 2:
return 'Strobe return light not detected';
case 3:
return 'Strobe return light detected';
default:
return 'Unknown ($value)';
}
}
// GPS
static String getGPSAltitudeRefDescription(String valueString) {
final value = int.tryParse(valueString);
if (value == null) return valueString;
switch (value) {
case 0:
return 'Above sea level';
case 1:
return 'Below sea level';
default:
return 'Unknown ($value)';
}
}
static String getGPSDifferentialDescription(String valueString) {
final value = int.tryParse(valueString);
if (value == null) return valueString;
switch (value) {
case 0:
return 'Without correction';
case 1:
return 'Correction applied';
default:
return 'Unknown ($value)';
}
}
static String getGPSDirectionRefDescription(String value) {
switch (value) {
case 'T':
return 'True direction';
case 'M':
return 'Magnetic direction';
default:
return 'Unknown ($value)';
}
}
static String getGPSMeasureModeDescription(String valueString) {
final value = int.tryParse(valueString);
if (value == null) return valueString;
switch (value) {
case 2:
return 'Two-dimensional measurement';
case 3:
return 'Three-dimensional measurement';
default:
return 'Unknown ($value)';
}
}
static String getGPSDestDistanceRefDescription(String value) {
switch (value) {
case 'K':
return 'kilometers';
case 'M':
return 'miles';
case 'N':
return 'knots';
default:
return 'Unknown ($value)';
}
}
static String getGPSSpeedRefDescription(String value) {
switch (value) {
case 'K':
return 'kilometers per hour';
case 'M':
return 'miles per hour';
case 'N':
return 'knots';
default:
return 'Unknown ($value)';
}
}
static String getGPSStatusDescription(String value) {
switch (value) {
case 'A':
return 'Measurement in progress';
case 'V':
return 'Measurement is interoperability';
default:
return 'Unknown ($value)';
}
}
}

View file

@ -1,44 +1,48 @@
class MimeTypes {
static const String anyImage = 'image/*';
static const anyImage = 'image/*';
static const String gif = 'image/gif';
static const String heic = 'image/heic';
static const String heif = 'image/heif';
static const String jpeg = 'image/jpeg';
static const String png = 'image/png';
static const String svg = 'image/svg+xml';
static const String webp = 'image/webp';
static const gif = 'image/gif';
static const heic = 'image/heic';
static const heif = 'image/heif';
static const jpeg = 'image/jpeg';
static const png = 'image/png';
static const svg = 'image/svg+xml';
static const webp = 'image/webp';
static const String tiff = 'image/tiff';
static const String psd = 'image/vnd.adobe.photoshop';
static const tiff = 'image/tiff';
static const psd = 'image/vnd.adobe.photoshop';
static const String arw = 'image/x-sony-arw';
static const String cr2 = 'image/x-canon-cr2';
static const String crw = 'image/x-canon-crw';
static const String dcr = 'image/x-kodak-dcr';
static const String dng = 'image/x-adobe-dng';
static const String erf = 'image/x-epson-erf';
static const String k25 = 'image/x-kodak-k25';
static const String kdc = 'image/x-kodak-kdc';
static const String mrw = 'image/x-minolta-mrw';
static const String nef = 'image/x-nikon-nef';
static const String nrw = 'image/x-nikon-nrw';
static const String orf = 'image/x-olympus-orf';
static const String pef = 'image/x-pentax-pef';
static const String raf = 'image/x-fuji-raf';
static const String raw = 'image/x-panasonic-raw';
static const String rw2 = 'image/x-panasonic-rw2';
static const String sr2 = 'image/x-sony-sr2';
static const String srf = 'image/x-sony-srf';
static const String srw = 'image/x-samsung-srw';
static const String x3f = 'image/x-sigma-x3f';
static const arw = 'image/x-sony-arw';
static const cr2 = 'image/x-canon-cr2';
static const crw = 'image/x-canon-crw';
static const dcr = 'image/x-kodak-dcr';
static const dng = 'image/x-adobe-dng';
static const erf = 'image/x-epson-erf';
static const k25 = 'image/x-kodak-k25';
static const kdc = 'image/x-kodak-kdc';
static const mrw = 'image/x-minolta-mrw';
static const nef = 'image/x-nikon-nef';
static const nrw = 'image/x-nikon-nrw';
static const orf = 'image/x-olympus-orf';
static const pef = 'image/x-pentax-pef';
static const raf = 'image/x-fuji-raf';
static const raw = 'image/x-panasonic-raw';
static const rw2 = 'image/x-panasonic-rw2';
static const sr2 = 'image/x-sony-sr2';
static const srf = 'image/x-sony-srf';
static const srw = 'image/x-samsung-srw';
static const x3f = 'image/x-sigma-x3f';
static const String anyVideo = 'video/*';
static const anyVideo = 'video/*';
static const String avi = 'video/avi';
static const String mp2t = 'video/mp2t'; // .m2ts
static const String mp4 = 'video/mp4';
static const avi = 'video/avi';
static const mp2t = 'video/mp2t'; // .m2ts
static const mp4 = 'video/mp4';
// 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 bool isImage(String mimeType) => mimeType.startsWith('image');
static bool isVideo(String mimeType) => mimeType.startsWith('video');
}

View file

@ -1,29 +1,34 @@
class XMP {
static const namespaceSeparator = ':';
static const propNamespaceSeparator = ':';
static const structFieldSeparator = '/';
// cf https://exiftool.org/TagNames/XMP.html
static const Map<String, String> namespaces = {
'aux': 'Auxiliary Exif',
'adsml-at': 'AdsML',
'aux': 'Exif Aux',
'avm': 'Astronomy Visualization',
'Camera': 'Camera',
'creatorAtom': 'After Effects',
'crs': 'Camera Raw Settings',
'dc': 'Dublin Core',
'exif': 'Exif',
'drone-dji': 'DJI Drone',
'exifEX': 'Exif Ex',
'GettyImagesGIFT': 'Getty Images',
'GIMP': 'GIMP',
'GFocus': 'Google Focus',
'GPano': 'Google Panorama',
'illustrator': 'Illustrator',
'Iptc4xmpCore': 'IPTC Core',
'lr': 'Lightroom',
'MicrosoftPhoto': 'Microsoft Photo',
'panorama': 'Panorama',
'pdf': 'PDF',
'pdfx': 'PDF/X',
'PanoStudioXMP': 'PanoramaStudio',
'photomechanic': 'Photo Mechanic',
'photoshop': 'Photoshop',
'tiff': 'TIFF',
'xmp': 'Basic',
'plus': 'PLUS',
'pmtm': 'Photomatix',
'xmpBJ': 'Basic Job Ticket',
'xmpDM': 'Dynamic Media',
'xmpMM': 'Media Management',
'xmpRights': 'Rights Management',
'xmpTPg': 'Paged-Text',
};

View file

@ -31,16 +31,6 @@ class AndroidAppService {
return null;
}
static Future<Map> getEnv() async {
try {
final result = await platform.invokeMethod('getEnv');
return result as Map;
} on PlatformException catch (e) {
debugPrint('getEnv failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
}
return {};
}
static Future<bool> edit(String uri, String mimeType) async {
try {
return await platform.invokeMethod('edit', <String, dynamic>{
@ -91,7 +81,7 @@ class AndroidAppService {
return false;
}
static Future<bool> share(Iterable<ImageEntry> entries) async {
static Future<bool> shareEntries(Iterable<ImageEntry> entries) async {
// loosen mime type to a generic one, so we can share with badly defined apps
// e.g. Google Lens declares receiving "image/jpeg" only, but it can actually handle more formats
final urisByMimeType = groupBy<ImageEntry, String>(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList()));
@ -101,7 +91,21 @@ class AndroidAppService {
'urisByMimeType': urisByMimeType,
});
} on PlatformException catch (e) {
debugPrint('share failed with code=${e.code}, exception=${e.message}, details=${e.details}');
debugPrint('shareEntries failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return false;
}
static Future<bool> shareSingle(String uri, String mimeType) async {
try {
return await platform.invokeMethod('share', <String, dynamic>{
'title': 'Share via:',
'urisByMimeType': {
mimeType: [uri]
},
});
} on PlatformException catch (e) {
debugPrint('shareSingle failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return false;
}

View file

@ -0,0 +1,112 @@
import 'package:aves/model/image_entry.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
class AndroidDebugService {
static const platform = MethodChannel('deckers.thibault/aves/debug');
static Future<Map> getContextDirs() async {
try {
final result = await platform.invokeMethod('getContextDirs');
return result as Map;
} on PlatformException catch (e) {
debugPrint('getContextDirs failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
}
return {};
}
static Future<Map> getEnv() async {
try {
final result = await platform.invokeMethod('getEnv');
return result as Map;
} on PlatformException catch (e) {
debugPrint('getEnv failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
}
return {};
}
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
final result = await platform.invokeMethod('getContentResolverMetadata', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
}) as Map;
return result;
} on PlatformException catch (e) {
debugPrint('getContentResolverMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return {};
}
static Future<Map> getExifInterfaceMetadata(ImageEntry entry) async {
try {
// return map with all data available from the `ExifInterface` library
final result = await platform.invokeMethod('getExifInterfaceMetadata', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
}) as Map;
return result;
} on PlatformException catch (e) {
debugPrint('getExifInterfaceMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return {};
}
static Future<Map> getMediaMetadataRetrieverMetadata(ImageEntry entry) async {
try {
// return map with all data available from `MediaMetadataRetriever`
final result = await platform.invokeMethod('getMediaMetadataRetrieverMetadata', <String, dynamic>{
'uri': entry.uri,
}) as Map;
return result;
} on PlatformException catch (e) {
debugPrint('getMediaMetadataRetrieverMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
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>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
}) as Map;
return result;
} on PlatformException catch (e) {
debugPrint('getMetadataExtractorSummary failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return {};
}
static Future<Map> getTiffStructure(ImageEntry entry) async {
if (entry.mimeType != MimeTypes.tiff) return {};
try {
final result = await platform.invokeMethod('getTiffStructure', <String, dynamic>{
'uri': entry.uri,
}) as Map;
return result;
} on PlatformException catch (e) {
debugPrint('getTiffStructure failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return {};
}
}

View file

@ -17,6 +17,7 @@ class MetadataService {
final result = await platform.invokeMethod('getAllMetadata', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
});
return result as Map;
} on PlatformException catch (e) {
@ -44,6 +45,7 @@ class MetadataService {
'mimeType': entry.mimeType,
'uri': entry.uri,
'path': entry.path,
'sizeBytes': entry.sizeBytes,
}) as Map;
result['contentId'] = entry.contentId;
return CatalogMetadata.fromMap(result);
@ -69,6 +71,7 @@ class MetadataService {
final result = await platform.invokeMethod('getOverlayMetadata', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
}) as Map;
return OverlayMetadata.fromMap(result);
} on PlatformException catch (e) {
@ -77,72 +80,6 @@ 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
final result = await platform.invokeMethod('getContentResolverMetadata', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
}) as Map;
return result;
} on PlatformException catch (e) {
debugPrint('getContentResolverMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return {};
}
static Future<Map> getExifInterfaceMetadata(ImageEntry entry) async {
try {
// return map with all data available from the `ExifInterface` library
final result = await platform.invokeMethod('getExifInterfaceMetadata', <String, dynamic>{
'uri': entry.uri,
}) as Map;
return result;
} on PlatformException catch (e) {
debugPrint('getExifInterfaceMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return {};
}
static Future<Map> getMediaMetadataRetrieverMetadata(ImageEntry entry) async {
try {
// return map with all data available from `MediaMetadataRetriever`
final result = await platform.invokeMethod('getMediaMetadataRetrieverMetadata', <String, dynamic>{
'uri': entry.uri,
}) as Map;
return result;
} on PlatformException catch (e) {
debugPrint('getMediaMetadataRetrieverMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
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>{
@ -155,10 +92,12 @@ class MetadataService {
return [];
}
static Future<List<Uint8List>> getExifThumbnails(String uri) async {
static Future<List<Uint8List>> getExifThumbnails(ImageEntry entry) async {
try {
final result = await platform.invokeMethod('getExifThumbnails', <String, dynamic>{
'uri': uri,
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
});
return (result as List).cast<Uint8List>();
} on PlatformException catch (e) {
@ -167,16 +106,19 @@ class MetadataService {
return [];
}
static Future<List<Uint8List>> getXmpThumbnails(ImageEntry entry) async {
static Future<Map> extractXmpDataProp(ImageEntry entry, String propPath, String propMimeType) async {
try {
final result = await platform.invokeMethod('getXmpThumbnails', <String, dynamic>{
final result = await platform.invokeMethod('extractXmpDataProp', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
'propPath': propPath,
'propMimeType': propMimeType,
});
return (result as List).cast<Uint8List>();
return result;
} on PlatformException catch (e) {
debugPrint('getXmpThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}');
debugPrint('extractXmpDataProp failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return [];
return null;
}
}

View file

@ -36,6 +36,7 @@ class Durations {
// info
static const mapStyleSwitchAnimation = Duration(milliseconds: 300);
static const xmpStructArrayCardTransition = Duration(milliseconds: 300);
// delays & refresh intervals
static const opToastDisplay = Duration(seconds: 2);

View file

@ -26,11 +26,9 @@ class AIcons {
// actions
static const IconData addShortcut = Icons.add_to_home_screen_outlined;
static const IconData clear = Icons.clear_outlined;
static const IconData collapse = Icons.expand_less_outlined;
static const IconData createAlbum = Icons.add_circle_outline;
static const IconData debug = Icons.whatshot_outlined;
static const IconData delete = Icons.delete_outlined;
static const IconData expand = Icons.expand_more_outlined;
static const IconData flip = Icons.flip_outlined;
static const IconData favourite = Icons.favorite_border;
static const IconData favouriteActive = Icons.favorite;
@ -52,6 +50,10 @@ class AIcons {
static const IconData stats = Icons.pie_chart_outlined;
static const IconData zoomIn = Icons.add_outlined;
static const IconData zoomOut = Icons.remove_outlined;
static const IconData collapse = Icons.expand_less_outlined;
static const IconData expand = Icons.expand_more_outlined;
static const IconData previous = Icons.chevron_left_outlined;
static const IconData next = Icons.chevron_right_outlined;
// albums
static const IconData album = Icons.photo_album_outlined;
@ -61,7 +63,9 @@ class AIcons {
// thumbnail overlay
static const IconData animated = Icons.slideshow;
static const IconData geo = Icons.language_outlined;
static const IconData play = Icons.play_circle_outline;
static const IconData threesixty = Icons.threesixty_outlined;
static const IconData selected = Icons.check_circle_outline;
static const IconData unselected = Icons.radio_button_unchecked;
}

View file

@ -18,8 +18,8 @@ class Constants {
offset: Offset(0.5, 1.0),
);
static const String overlayUnknown = ''; // em dash
static const String infoUnknown = 'unknown';
static const overlayUnknown = ''; // em dash
static const infoUnknown = 'unknown';
static final pointNemo = LatLng(-48.876667, -123.393333);
@ -209,6 +209,12 @@ class Constants {
licenseUrl: 'https://github.com/flutter/packages/blob/master/packages/palette_generator/LICENSE',
sourceUrl: 'https://github.com/flutter/packages/tree/master/packages/palette_generator',
),
Dependency(
name: 'Panorama',
license: 'Apache 2.0',
licenseUrl: 'https://github.com/zesage/panorama/blob/master/LICENSE',
sourceUrl: 'https://github.com/zesage/panorama',
),
Dependency(
name: 'PDF for Dart and Flutter',
license: 'Apache 2.0',
@ -287,6 +293,12 @@ class Constants {
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher/LICENSE',
sourceUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher',
),
Dependency(
name: 'XML',
license: 'MIT',
licenseUrl: 'https://github.com/renggli/dart-xml/blob/master/LICENSE',
sourceUrl: 'https://github.com/renggli/dart-xml',
),
];
}

View file

@ -0,0 +1,9 @@
extension ExtraString on String {
static final _sentenceCaseStep1 = RegExp(r'([A-Z][a-z]|\[)');
static final _sentenceCaseStep2 = RegExp(r'([a-z])([A-Z])');
String toSentenceCase() {
var s = replaceFirstMapped(RegExp('.'), (m) => m.group(0).toUpperCase());
return s.replaceAllMapped(_sentenceCaseStep1, (m) => ' ${m.group(1)}').replaceAllMapped(_sentenceCaseStep2, (m) => '${m.group(1)} ${m.group(2)}').trim();
}
}

View file

@ -87,6 +87,7 @@ class _LicensesState extends State<Licenses> {
Widget _buildHeader() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsetsDirectional.only(start: 8),

View file

@ -34,7 +34,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
_showDeleteDialog(context);
break;
case EntryAction.share:
AndroidAppService.share(selection).then((success) {
AndroidAppService.shareEntries(selection).then((success) {
if (!success) showNoMatchingAppDialog(context);
});
break;

View file

@ -3,7 +3,6 @@ import 'dart:math';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/collection/grid/header_generic.dart';
import 'package:aves/widgets/collection/thumbnail_collection.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -15,14 +14,14 @@ class SectionedListLayoutProvider extends StatelessWidget {
final Widget Function(ImageEntry entry) thumbnailBuilder;
final Widget child;
SectionedListLayoutProvider({
const SectionedListLayoutProvider({
@required this.collection,
@required this.scrollableWidth,
@required this.tileExtent,
@required this.columnCount,
@required this.thumbnailBuilder,
@required this.child,
}) : assert(scrollableWidth != 0),
columnCount = max((scrollableWidth / tileExtent).round(), ThumbnailCollection.columnCountMin);
}) : assert(scrollableWidth != 0);
@override
Widget build(BuildContext context) {

View file

@ -36,6 +36,7 @@ class ThumbnailEntryOverlay extends StatelessWidget {
children: [
if (entry.hasGps && settings.showThumbnailLocation) GpsIcon(iconSize: iconSize),
if (entry.isRaw && settings.showThumbnailRaw) RawIcon(iconSize: iconSize),
if (entry.isGeotiff) GeotiffIcon(iconSize: iconSize),
if (entry.isAnimated)
AnimatedImageIcon(iconSize: iconSize)
else if (entry.isVideo)
@ -49,7 +50,9 @@ class ThumbnailEntryOverlay extends StatelessWidget {
iconSize: iconSize,
showDuration: settings.showThumbnailVideoDuration,
),
),
)
else if (entry.is360)
SphericalImageIcon(iconSize: iconSize),
],
);
});

View file

@ -145,7 +145,7 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
tag: heroTag,
flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) {
ImageProvider heroImageProvider = _fastThumbnailProvider;
if (!entry.isVideo && !entry.isSvg) {
if (!entry.isVideo) {
final imageProvider = UriImage(
uri: entry.uri,
mimeType: entry.mimeType,

View file

@ -31,9 +31,9 @@ class ThumbnailCollection extends StatelessWidget {
final ValueNotifier<bool> _isScrollingNotifier = ValueNotifier(false);
final GlobalKey _scrollableKey = GlobalKey();
static const columnCountMin = 2;
static const columnCountDefault = 4;
static const extentMin = 46.0;
static const spacing = 0.0;
@override
Widget build(BuildContext context) {
@ -47,11 +47,10 @@ class ThumbnailCollection extends StatelessWidget {
final tileExtentManager = TileExtentManager(
settingsRouteKey: context.currentRouteName,
columnCountMin: columnCountMin,
extentNotifier: _tileExtentNotifier,
columnCountDefault: columnCountDefault,
extentMin: extentMin,
extentNotifier: _tileExtentNotifier,
spacing: 0,
spacing: spacing,
)..applyTileExtent(viewportSize: viewportSize);
final cacheExtent = tileExtentManager.getEffectiveExtentMax(viewportSize) * 2;
@ -77,7 +76,18 @@ class ThumbnailCollection extends StatelessWidget {
scrollableKey: _scrollableKey,
appBarHeightNotifier: _appBarHeightNotifier,
viewportSize: viewportSize,
showScaledGrid: true,
gridBuilder: (center, extent, child) => CustomPaint(
// painting the thumbnail half-border on top of the grid yields artifacts,
// so we use a `foregroundPainter` to cover them instead
foregroundPainter: GridPainter(
center: center,
extent: extent,
spacing: tileExtentManager.spacing,
strokeWidth: DecoratedThumbnail.borderWidth * 2,
color: DecoratedThumbnail.borderColor,
),
child: child,
),
scaledBuilder: (entry, extent) => DecoratedThumbnail(
entry: entry,
extent: extent,
@ -98,6 +108,7 @@ class ThumbnailCollection extends StatelessWidget {
collection: collection,
scrollableWidth: viewportSize.width,
tileExtent: tileExtent,
columnCount: tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent),
thumbnailBuilder: (entry) => GridThumbnail(
key: ValueKey(entry.contentId),
collection: collection,

View file

@ -56,7 +56,7 @@ mixin FeedbackMixin {
stream: opStream,
builder: (context, snapshot) {
Widget child = SizedBox.shrink();
if (!snapshot.hasError && snapshot.connectionState == ConnectionState.active) {
if (!snapshot.hasError) {
final percent = processed.length.toDouble() / selection.length;
child = CircularPercentIndicator(
percent: percent,

View file

@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
class MultiCrossFader extends StatefulWidget {
final Duration duration;
final Curve fadeCurve, sizeCurve;
final AlignmentGeometry alignment;
final Widget child;
const MultiCrossFader({
@required this.duration,
this.fadeCurve = Curves.linear,
this.sizeCurve = Curves.linear,
this.alignment = Alignment.topCenter,
@required this.child,
});
@override
_MultiCrossFaderState createState() => _MultiCrossFaderState();
}
class _MultiCrossFaderState extends State<MultiCrossFader> {
Widget _first, _second;
CrossFadeState _fadeState = CrossFadeState.showFirst;
@override
void initState() {
super.initState();
_first = widget.child;
_second = SizedBox();
}
@override
void didUpdateWidget(MultiCrossFader oldWidget) {
super.didUpdateWidget(oldWidget);
if (_first == oldWidget.child) {
_second = widget.child;
_fadeState = CrossFadeState.showSecond;
} else {
_first = widget.child;
_fadeState = CrossFadeState.showFirst;
}
}
@override
Widget build(BuildContext context) {
return AnimatedCrossFade(
firstChild: _first,
secondChild: _second,
firstCurve: widget.fadeCurve,
secondCurve: widget.fadeCurve,
sizeCurve: widget.sizeCurve,
alignment: widget.alignment,
crossFadeState: _fadeState,
duration: widget.duration,
);
}
}

View file

@ -2,6 +2,8 @@ import 'dart:ui';
import 'package:flutter/material.dart';
final _filter = ImageFilter.blur(sigmaX: 4, sigmaY: 4);
class BlurredRect extends StatelessWidget {
final Widget child;
@ -11,7 +13,7 @@ class BlurredRect extends StatelessWidget {
Widget build(BuildContext context) {
return ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4),
filter: _filter,
child: child,
),
);
@ -29,7 +31,7 @@ class BlurredRRect extends StatelessWidget {
return ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4),
filter: _filter,
child: child,
),
);
@ -45,7 +47,7 @@ class BlurredOval extends StatelessWidget {
Widget build(BuildContext context) {
return ClipOval(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4),
filter: _filter,
child: child,
),
);

View file

@ -1,11 +1,18 @@
import 'package:flutter/material.dart';
class AvesCircleBorder {
static BoxBorder build(BuildContext context) {
final subPixel = MediaQuery.of(context).devicePixelRatio > 2;
return Border.all(
color: Colors.white30,
width: subPixel ? 0.5 : 1.0,
static const borderColor = Colors.white30;
static double _borderWidth(BuildContext context) => MediaQuery.of(context).devicePixelRatio > 2 ? 0.5 : 1.0;
static Border build(BuildContext context) {
return Border.fromBorderSide(buildSide(context));
}
static BorderSide buildSide(BuildContext context) {
return BorderSide(
color: borderColor,
width: _borderWidth(context),
);
}
}

View file

@ -30,7 +30,6 @@ class AvesExpansionTile extends StatelessWidget {
title: HighlightTitle(
title,
color: color,
fontSize: 18,
enabled: enabled,
),
expandable: enabled,

View file

@ -23,9 +23,10 @@ class VideoIcon extends StatelessWidget {
@override
Widget build(BuildContext context) {
return OverlayIcon(
icon: AIcons.play,
icon: entry.is360 ? AIcons.threesixty : AIcons.play,
size: iconSize,
text: showDuration ? entry.durationText : null,
iconScale: entry.is360 && showDuration ? .9 : 1,
);
}
}
@ -45,6 +46,34 @@ class AnimatedImageIcon extends StatelessWidget {
}
}
class GeotiffIcon extends StatelessWidget {
final double iconSize;
const GeotiffIcon({Key key, this.iconSize}) : super(key: key);
@override
Widget build(BuildContext context) {
return OverlayIcon(
icon: AIcons.geo,
size: iconSize,
);
}
}
class SphericalImageIcon extends StatelessWidget {
final double iconSize;
const SphericalImageIcon({Key key, this.iconSize}) : super(key: key);
@override
Widget build(BuildContext context) {
return OverlayIcon(
icon: AIcons.threesixty,
size: iconSize,
);
}
}
class GpsIcon extends StatelessWidget {
final double iconSize;

View file

@ -3,46 +3,55 @@ import 'package:aves/widgets/common/fx/highlight_decoration.dart';
import 'package:flutter/material.dart';
class HighlightTitle extends StatelessWidget {
final String name;
final String title;
final Color color;
final double fontSize;
final bool enabled;
final bool enabled, selectable;
const HighlightTitle(
this.name, {
this.title, {
this.color,
this.fontSize = 20,
this.fontSize = 18,
this.enabled = true,
}) : assert(name != null);
this.selectable = false,
}) : assert(title != null);
static const disabledColor = Colors.grey;
@override
Widget build(BuildContext context) {
final style = TextStyle(
shadows: [
Shadow(
color: Colors.black,
offset: Offset(1, 1),
blurRadius: 2,
)
],
fontSize: fontSize,
fontFamily: 'Concourse Caps',
);
return Align(
alignment: AlignmentDirectional.centerStart,
child: Container(
decoration: HighlightDecoration(
color: enabled ? color ?? stringToColor(name) : disabledColor,
color: enabled ? color ?? stringToColor(title) : disabledColor,
),
margin: EdgeInsets.symmetric(vertical: 4.0),
child: Text(
name,
style: TextStyle(
shadows: [
Shadow(
color: Colors.black,
offset: Offset(1, 1),
blurRadius: 2,
child: selectable
? SelectableText(
title,
style: style,
maxLines: 1,
)
],
fontSize: fontSize,
fontFamily: 'Concourse Caps',
),
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
),
: Text(
title,
style: style,
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
),
),
);
}

View file

@ -2,7 +2,6 @@ import 'dart:math';
import 'dart:ui' as ui;
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/common/tile_extent_manager.dart';
import 'package:flutter/material.dart';
@ -20,7 +19,7 @@ class GridScaleGestureDetector<T> extends StatefulWidget {
final GlobalKey scrollableKey;
final ValueNotifier<double> appBarHeightNotifier;
final Size viewportSize;
final bool showScaledGrid;
final Widget Function(Offset center, double extent, Widget child) gridBuilder;
final Widget Function(T item, double extent) scaledBuilder;
final Rect Function(BuildContext context, T item) getScaledItemTileRect;
final void Function(T item) onScaled;
@ -31,7 +30,7 @@ class GridScaleGestureDetector<T> extends StatefulWidget {
@required this.scrollableKey,
@required this.appBarHeightNotifier,
@required this.viewportSize,
@required this.showScaledGrid,
this.gridBuilder,
@required this.scaledBuilder,
@required this.getScaledItemTileRect,
@required this.onScaled,
@ -56,10 +55,6 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
@override
Widget build(BuildContext context) {
return GestureDetector(
onHorizontalDragStart: (details) {
// if `onHorizontalDragStart` callback is not defined,
// horizontal drag gestures are interpreted as scaling
},
onScaleStart: (details) {
// the gesture detector wrongly detects a new scaling gesture
// when scaling ends and we apply the new extent, so we prevent this
@ -91,10 +86,9 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
builder: (context) => ScaleOverlay(
builder: (extent) => widget.scaledBuilder(_metadata.item, extent),
center: thumbnailCenter,
gridWidth: gridWidth,
spacing: tileExtentManager.spacing,
viewportWidth: gridWidth,
gridBuilder: widget.gridBuilder,
scaledExtentNotifier: _scaledExtentNotifier,
showScaledGrid: widget.showScaledGrid,
),
);
Overlay.of(scrollableContext).insert(_overlayEntry);
@ -133,7 +127,16 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
});
}
},
child: widget.child,
child: GestureDetector(
// Horizontal/vertical drag gestures are interpreted as scaling
// if they are not handled by `onHorizontalDragStart`/`onVerticalDragStart`
// at the scaling `GestureDetector` level, or handled beforehand down the widget tree.
// Setting `onHorizontalDragStart`, `onVerticalDragStart`, and `onScaleStart`
// all at once is not allowed, so we use another `GestureDetector` for that.
onVerticalDragStart: (details) {},
onHorizontalDragStart: (details) {},
child: widget.child,
),
);
}
@ -157,18 +160,16 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
class ScaleOverlay extends StatefulWidget {
final Widget Function(double extent) builder;
final Offset center;
final double gridWidth;
final double spacing;
final double viewportWidth;
final ValueNotifier<double> scaledExtentNotifier;
final bool showScaledGrid;
final Widget Function(Offset center, double extent, Widget child) gridBuilder;
const ScaleOverlay({
@required this.builder,
@required this.center,
@required this.gridWidth,
@required this.spacing,
@required this.viewportWidth,
@required this.scaledExtentNotifier,
@required this.showScaledGrid,
this.gridBuilder,
});
@override
@ -180,7 +181,7 @@ class _ScaleOverlayState extends State<ScaleOverlay> {
Offset get center => widget.center;
double get gridWidth => widget.gridWidth;
double get gridWidth => widget.viewportWidth;
@override
void initState() {
@ -241,16 +242,7 @@ class _ScaleOverlayState extends State<ScaleOverlay> {
),
],
);
if (widget.showScaledGrid) {
child = CustomPaint(
painter: GridPainter(
center: clampedCenter,
extent: extent,
spacing: widget.spacing,
),
child: child,
);
}
child = widget.gridBuilder?.call(clampedCenter, extent, child) ?? child;
return child;
},
),
@ -263,31 +255,36 @@ class _ScaleOverlayState extends State<ScaleOverlay> {
class GridPainter extends CustomPainter {
final Offset center;
final double extent, spacing;
final double strokeWidth;
final Color color;
const GridPainter({
@required this.center,
@required this.extent,
@required this.spacing,
this.spacing = 0.0,
this.strokeWidth = 1.0,
@required this.color,
});
@override
void paint(Canvas canvas, Size size) {
final radius = extent * 3;
final paint = Paint()
..strokeWidth = DecoratedThumbnail.borderWidth
..strokeWidth = strokeWidth
..shader = ui.Gradient.radial(
center,
size.width * .7,
radius,
[
DecoratedThumbnail.borderColor,
color,
Colors.transparent,
],
[
min(.5, 2 * extent / size.width),
extent / radius,
1,
],
);
void draw(Offset topLeft) {
for (var i = -2; i <= 3; i++) {
for (var i = -1; i <= 2; i++) {
final ref = (extent + spacing) * i;
canvas.drawLine(Offset(0, topLeft.dy + ref), Offset(size.width, topLeft.dy + ref), paint);
canvas.drawLine(Offset(topLeft.dx + ref, 0), Offset(topLeft.dx + ref, size.height), paint);

View file

@ -6,15 +6,16 @@ import 'package:flutter/widgets.dart';
class TileExtentManager {
final String settingsRouteKey;
final int columnCountMin, columnCountDefault;
final double spacing, extentMin;
final double spacing, extentMin, extentMax;
final ValueNotifier<double> extentNotifier;
const TileExtentManager({
@required this.settingsRouteKey,
@required this.columnCountMin,
@required this.extentNotifier,
this.columnCountMin = 2,
@required this.columnCountDefault,
@required this.extentMin,
@required this.extentNotifier,
this.extentMax = 300,
@required this.spacing,
});
@ -46,7 +47,7 @@ class TileExtentManager {
return newExtent;
}
double _extentMax(Size viewportSize) => (viewportSize.shortestSide - spacing * (columnCountMin - 1)) / columnCountMin;
double _extentMax(Size viewportSize) => min(extentMax, (viewportSize.shortestSide - spacing * (columnCountMin - 1)) / columnCountMin);
double _columnCountForExtent(Size viewportSize, double extent) => (viewportSize.width + spacing) / (extent + spacing);

View file

@ -0,0 +1,47 @@
import 'dart:collection';
import 'package:aves/services/android_debug_service.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/fullscreen/info/common.dart';
import 'package:flutter/material.dart';
class DebugAndroidDirSection extends StatefulWidget {
@override
_DebugAndroidDirSectionState createState() => _DebugAndroidDirSectionState();
}
class _DebugAndroidDirSectionState extends State<DebugAndroidDirSection> with AutomaticKeepAliveClientMixin {
Future<Map> _loader;
@override
void initState() {
super.initState();
_loader = AndroidDebugService.getContextDirs();
}
@override
Widget build(BuildContext context) {
super.build(context);
return AvesExpansionTile(
title: 'Android Dir',
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

@ -1,6 +1,6 @@
import 'dart:collection';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/android_debug_service.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/fullscreen/info/common.dart';
import 'package:flutter/material.dart';
@ -16,7 +16,7 @@ class _DebugAndroidEnvironmentSectionState extends State<DebugAndroidEnvironment
@override
void initState() {
super.initState();
_loader = AndroidAppService.getEnv();
_loader = AndroidDebugService.getEnv();
}
@override

View file

@ -2,6 +2,7 @@ import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/debug/android_dirs.dart';
import 'package:aves/widgets/debug/android_env.dart';
import 'package:aves/widgets/debug/cache.dart';
import 'package:aves/widgets/debug/database.dart';
@ -41,6 +42,7 @@ class AppDebugPageState extends State<AppDebugPage> {
padding: EdgeInsets.all(8),
children: [
_buildGeneralTabView(),
DebugAndroidDirSection(),
DebugAndroidEnvironmentSection(),
DebugCacheSection(),
DebugAppDatabaseSection(),

View file

@ -37,9 +37,12 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
final ValueNotifier<double> _tileExtentNotifier = ValueNotifier(0);
final GlobalKey _scrollableKey = GlobalKey();
static const columnCountDefault = 2;
static const extentMin = 60.0;
static const spacing = 8.0;
FilterGridPage({
Key key,
@required this.source,
@required this.appBar,
@required this.filterEntries,
@ -50,7 +53,7 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
double appBarHeight = kToolbarHeight,
@required this.onTap,
this.onLongPress,
}) {
}) : super(key: key) {
_appBarHeightNotifier.value = appBarHeight;
}
@ -71,10 +74,9 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
final tileExtentManager = TileExtentManager(
settingsRouteKey: settingsRouteKey ?? context.currentRouteName,
columnCountMin: 2,
columnCountDefault: 2,
extentMin: 60,
extentNotifier: _tileExtentNotifier,
columnCountDefault: columnCountDefault,
extentMin: extentMin,
spacing: spacing,
)..applyTileExtent(viewportSize: viewportSize);
@ -98,7 +100,15 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
scrollableKey: _scrollableKey,
appBarHeightNotifier: _appBarHeightNotifier,
viewportSize: viewportSize,
showScaledGrid: true,
gridBuilder: (center, extent, child) => CustomPaint(
painter: GridPainter(
center: center,
extent: extent,
spacing: tileExtentManager.spacing,
color: Colors.grey.shade700,
),
child: child,
),
scaledBuilder: (item, extent) {
final filter = item.filter;
return SizedBox(

View file

@ -44,6 +44,7 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FilterGridPage<T>(
key: ValueKey('filter-grid-page'),
source: source,
appBar: SliverAppBar(
title: TappableAppBarTitle(

View file

@ -2,7 +2,8 @@ import 'dart:collection';
import 'dart:typed_data';
import 'package:aves/model/image_entry.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/android_debug_service.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/fullscreen/info/common.dart';
@ -18,7 +19,7 @@ class MetadataTab extends StatefulWidget {
}
class _MetadataTabState extends State<MetadataTab> {
Future<Map> _bitmapFactoryLoader, _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader, _metadataExtractorLoader;
Future<Map> _bitmapFactoryLoader, _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader, _metadataExtractorLoader, _tiffStructureLoader;
// MediaStore timestamp keys
static const secondTimestampKeys = ['date_added', 'date_modified', 'date_expires', 'isPlayed'];
@ -33,20 +34,19 @@ 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);
_bitmapFactoryLoader = AndroidDebugService.getBitmapFactoryInfo(entry);
_contentResolverMetadataLoader = AndroidDebugService.getContentResolverMetadata(entry);
_exifInterfaceMetadataLoader = AndroidDebugService.getExifInterfaceMetadata(entry);
_mediaMetadataLoader = AndroidDebugService.getMediaMetadataRetrieverMetadata(entry);
_metadataExtractorLoader = AndroidDebugService.getMetadataExtractorSummary(entry);
_tiffStructureLoader = AndroidDebugService.getTiffStructure(entry);
setState(() {});
}
@override
Widget build(BuildContext context) {
Widget builder(BuildContext context, AsyncSnapshot<Map> snapshot, String title) {
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) {
Widget builderFromSnapshotData(BuildContext context, Map snapshotData, String title) {
final data = SplayTreeMap.of(snapshotData.map((k, v) {
final key = k.toString();
var value = v?.toString() ?? 'null';
if ([...secondTimestampKeys, ...millisecondTimestampKeys].contains(key) && v is num && v != 0) {
@ -76,29 +76,47 @@ class _MetadataTabState extends State<MetadataTab> {
);
}
Widget builderFromSnapshot(BuildContext context, AsyncSnapshot<Map> snapshot, String title) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return builderFromSnapshotData(context, snapshot.data, title);
}
return ListView(
padding: EdgeInsets.all(8),
children: [
FutureBuilder<Map>(
future: _bitmapFactoryLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Bitmap Factory'),
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Bitmap Factory'),
),
FutureBuilder<Map>(
future: _contentResolverMetadataLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Content Resolver'),
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Content Resolver'),
),
FutureBuilder<Map>(
future: _exifInterfaceMetadataLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Exif Interface'),
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Exif Interface'),
),
FutureBuilder<Map>(
future: _mediaMetadataLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Media Metadata Retriever'),
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Media Metadata Retriever'),
),
FutureBuilder<Map>(
future: _metadataExtractorLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Metadata Extractor'),
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Metadata Extractor'),
),
if (entry.mimeType == MimeTypes.tiff)
FutureBuilder<Map>(
future: _tiffStructureLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: snapshot.data.entries.map((kv) => builderFromSnapshotData(context, kv.value as Map, 'TIFF ${kv.key}')).toList(),
);
},
),
],
);
}

View file

@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:aves/image_providers/uri_image_provider.dart';
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/image_entry.dart';
@ -77,7 +79,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
});
break;
case EntryAction.share:
AndroidAppService.share({entry}).then((success) {
AndroidAppService.shareEntries({entry}).then((success) {
if (!success) showNoMatchingAppDialog(context);
});
break;
@ -202,7 +204,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
context,
MaterialPageRoute(
settings: RouteSettings(name: SourceViewerPage.routeName),
builder: (context) => SourceViewerPage(entry: entry),
builder: (context) => SourceViewerPage(
loader: () => ImageFileService.getImage(entry.uri, entry.mimeType, 0, false).then(utf8.decode),
),
),
);
}

View file

@ -5,8 +5,8 @@ import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings/screen_on.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/utils/change_notifier.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/utils/change_notifier.dart';
import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/fullscreen/entry_action_delegate.dart';
import 'package:aves/widgets/fullscreen/image_page.dart';
@ -14,6 +14,7 @@ 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';
import 'package:aves/widgets/fullscreen/overlay/panorama.dart';
import 'package:aves/widgets/fullscreen/overlay/top.dart';
import 'package:aves/widgets/fullscreen/overlay/video.dart';
import 'package:flutter/foundation.dart';
@ -223,22 +224,33 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
Widget bottomOverlay = ValueListenableBuilder<ImageEntry>(
valueListenable: _entryNotifier,
builder: (context, entry, child) {
Widget videoOverlay;
if (entry != null) {
final videoController = entry.isVideo ? _videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2 : null;
if (entry == null) return SizedBox.shrink();
Widget extraBottomOverlay;
if (entry.isVideo) {
final videoController = _videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
if (videoController != null) {
videoOverlay = VideoControlOverlay(
extraBottomOverlay = VideoControlOverlay(
entry: entry,
controller: videoController,
scale: _bottomOverlayScale,
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
);
}
} else if (entry.is360) {
extraBottomOverlay = PanoramaOverlay(
entry: entry,
scale: _bottomOverlayScale,
);
}
final child = Column(
children: [
if (videoOverlay != null) videoOverlay,
if (extraBottomOverlay != null)
ExtraBottomOverlay(
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
child: extraBottomOverlay,
),
SlideTransition(
position: _bottomOverlayOffset,
child: FullscreenBottomOverlay(
@ -255,7 +267,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
valueListenable: _overlayAnimationController,
builder: (context, animation, child) {
return Visibility(
visible: entry != null && _overlayAnimationController.status != AnimationStatus.dismissed,
visible: _overlayAnimationController.status != AnimationStatus.dismissed,
child: child,
);
},

View file

@ -96,6 +96,8 @@ class FullscreenDebugPage extends StatelessWidget {
'isVideo': '${entry.isVideo}',
'isCatalogued': '${entry.isCatalogued}',
'isAnimated': '${entry.isAnimated}',
'isGeotiff': '${entry.isGeotiff}',
'is360': '${entry.is360}',
'canEdit': '${entry.canEdit}',
'canEditExif': '${entry.canEditExif}',
'canPrint': '${entry.canPrint}',

View file

@ -48,7 +48,7 @@ class SingleFullscreenPage extends StatelessWidget {
body: FullscreenBody(
initialEntry: entry,
),
backgroundColor: Colors.black,
backgroundColor: Navigator.canPop(context) ? Colors.transparent : Colors.black,
resizeToAvoidBottomInset: false,
),
);

View file

@ -205,7 +205,6 @@ class _ImageViewState extends State<ImageView> {
mimeType: entry.mimeType,
colorFilter: colorFilter,
),
placeholderBuilder: (context) => _loadingBuilder(context, fastThumbnailProvider),
),
backgroundDecoration: backgroundDecoration,
controller: _photoViewController,

View file

@ -33,17 +33,23 @@ class BasicSection extends StatelessWidget {
final showMegaPixels = entry.isPhoto && entry.megaPixels != null && entry.megaPixels > 0;
final resolutionText = '${entry.resolutionText}${showMegaPixels ? ' (${entry.megaPixels} MP)' : ''}';
// TODO TLAD line break on all characters for the following fields when this is fixed: https://github.com/flutter/flutter/issues/61081
// inserting ZWSP (\u200B) between characters does help, but it messes with width and height computation (another Flutter issue)
final title = entry.bestTitle ?? Constants.infoUnknown;
final uri = entry.uri ?? Constants.infoUnknown;
final path = entry.path;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InfoRowGroup({
'Title': entry.bestTitle ?? Constants.infoUnknown,
'Title': title,
'Date': dateText,
if (entry.isVideo) ..._buildVideoRows(),
if (!entry.isSvg) 'Resolution': resolutionText,
'Size': entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : Constants.infoUnknown,
'URI': entry.uri ?? Constants.infoUnknown,
if (entry.path != null) 'Path': entry.path,
'URI': uri,
if (path != null) 'Path': path,
}),
_buildChips(),
],
@ -54,8 +60,10 @@ class BasicSection extends StatelessWidget {
final tags = entry.xmpSubjects..sort(compareAsciiUpperCase);
final album = entry.directory;
final filters = [
if (entry.isVideo) MimeFilter(MimeTypes.anyVideo),
if (entry.isAnimated) MimeFilter(MimeFilter.animated),
if (entry.isImage && entry.is360) MimeFilter(MimeFilter.panorama),
if (entry.isVideo) MimeFilter(entry.is360 ? MimeFilter.sphericalVideo : MimeTypes.anyVideo),
if (entry.isGeotiff) MimeFilter(MimeFilter.geotiff),
if (album != null) AlbumFilter(album, collection?.source?.getUniqueAlbumName(album)),
...tags.map((tag) => TagFilter(tag)),
];

View file

@ -40,10 +40,12 @@ class SectionRow extends StatelessWidget {
class InfoRowGroup extends StatefulWidget {
final Map<String, String> keyValues;
final int maxValueLength;
final Map<String, InfoLinkHandler> linkHandlers;
const InfoRowGroup(
this.keyValues, {
this.maxValueLength = 0,
this.linkHandlers,
});
@override
@ -57,9 +59,13 @@ class _InfoRowGroupState extends State<InfoRowGroup> {
int get maxValueLength => widget.maxValueLength;
Map<String, InfoLinkHandler> get linkHandlers => widget.linkHandlers;
static const keyValuePadding = 16;
static const linkColor = Colors.blue;
static final baseStyle = TextStyle(fontFamily: 'Concourse');
static final keyStyle = baseStyle.copyWith(color: Colors.white70, height: 1.7);
static final linkStyle = baseStyle.copyWith(color: linkColor, decoration: TextDecoration.underline);
@override
Widget build(BuildContext context) {
@ -85,11 +91,29 @@ class _InfoRowGroupState extends State<InfoRowGroup> {
children: keyValues.entries.expand(
(kv) {
final key = kv.key;
var value = kv.value;
// long values are clipped, and made expandable by tapping them
final showPreviewOnly = maxValueLength > 0 && value.length > maxValueLength && !_expandedKeys.contains(key);
if (showPreviewOnly) {
value = '${value.substring(0, maxValueLength)}';
String value;
TextStyle style;
GestureRecognizer recognizer;
if (linkHandlers?.containsKey(key) == true) {
final handler = linkHandlers[key];
value = handler.linkText;
// open link on tap
recognizer = TapGestureRecognizer()..onTap = () => handler.onTap(context);
style = linkStyle;
} else {
value = kv.value;
// long values are clipped, and made expandable by tapping them
final showPreviewOnly = maxValueLength > 0 && value.length > maxValueLength && !_expandedKeys.contains(key);
if (showPreviewOnly) {
value = '${value.substring(0, maxValueLength)}';
// show full value on tap
recognizer = TapGestureRecognizer()..onTap = () => setState(() => _expandedKeys.add(key));
}
}
if (key != lastKey) {
value = '$value\n';
}
// as of Flutter v1.22.4, `SelectableText` cannot contain `WidgetSpan`
@ -98,9 +122,9 @@ class _InfoRowGroupState extends State<InfoRowGroup> {
final spaceCount = (100 * thisSpaceSize / baseSpaceWidth).round();
return [
TextSpan(text: '$key', style: keyStyle),
TextSpan(text: key, style: keyStyle),
TextSpan(text: '\u200A' * spaceCount),
TextSpan(text: '$value${key == lastKey ? '' : '\n'}', recognizer: showPreviewOnly ? _buildTapRecognizer(key) : null),
TextSpan(text: value, style: style, recognizer: recognizer),
];
},
).toList(),
@ -121,8 +145,14 @@ class _InfoRowGroupState extends State<InfoRowGroup> {
)..layout(BoxConstraints(), parentUsesSize: true);
return para.getMaxIntrinsicWidth(double.infinity);
}
GestureRecognizer _buildTapRecognizer(String key) {
return TapGestureRecognizer()..onTap = () => setState(() => _expandedKeys.add(key));
}
}
class InfoLinkHandler {
final String linkText;
final void Function(BuildContext context) onTap;
const InfoLinkHandler({
@required this.linkText,
@required this.onTap,
});
}

View file

@ -10,6 +10,7 @@ import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/fullscreen/info/common.dart';
import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart';
import 'package:aves/widgets/fullscreen/info/metadata/svg_tile.dart';
import 'package:aves/widgets/fullscreen/info/metadata/xmp_tile.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
@ -86,42 +87,49 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
// warning: placing the `AnimationLimiter` as a parent to the `ScrollView`
// triggers dispose & reinitialization of other sections, including heavy widgets like maps
return SliverToBoxAdapter(
child: AnimatedBuilder(
animation: _loadedMetadataUri,
builder: (context, child) {
Widget content;
if (_metadata.isEmpty) {
content = SizedBox.shrink();
} else {
content = Column(
children: AnimationConfiguration.toStaggeredList(
duration: Durations.staggeredAnimation,
delay: Durations.staggeredAnimationDelay,
childAnimationBuilder: (child) => SlideAnimation(
verticalOffset: 50.0,
child: FadeInAnimation(
child: child,
child: NotificationListener<ScrollNotification>(
// cancel notification bubbling so that the info page
// does not misinterpret content scrolling for page scrolling
onNotification: (notification) => true,
child: AnimatedBuilder(
animation: _loadedMetadataUri,
builder: (context, child) {
Widget content;
if (_metadata.isEmpty) {
content = SizedBox.shrink();
} else {
content = Column(
children: AnimationConfiguration.toStaggeredList(
duration: Durations.staggeredAnimation,
delay: Durations.staggeredAnimationDelay,
childAnimationBuilder: (child) => SlideAnimation(
verticalOffset: 50.0,
child: FadeInAnimation(
child: child,
),
),
children: [
SectionRow(AIcons.info),
..._metadata.entries.map((kv) => _buildDirTile(kv.key, kv.value)),
],
),
children: [
SectionRow(AIcons.info),
..._metadata.entries.map((kv) => _buildDirTile(kv.key, kv.value)),
],
),
);
}
return AnimationLimiter(
// we update the limiter key after fetching the metadata of a new entry,
// in order to restart the staggered animation of the metadata section
key: Key(_loadedMetadataUri.value),
child: content,
);
}
return AnimationLimiter(
// we update the limiter key after fetching the metadata of a new entry,
// in order to restart the staggered animation of the metadata section
key: Key(_loadedMetadataUri.value),
child: content,
);
},
},
),
),
);
}
Widget _buildDirTile(String title, _MetadataDirectory dir) {
if (dir.tags.isEmpty) return SizedBox.shrink();
final dirName = dir.name;
if (dirName == xmpDirectory) {
return XmpDirTile(
@ -130,6 +138,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
expandedNotifier: _expandedDirectoryNotifier,
);
}
Widget thumbnail;
final prefixChildren = <Widget>[];
switch (dirName) {
@ -163,7 +172,11 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
if (thumbnail != null) thumbnail,
Padding(
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup(dir.tags, maxValueLength: Constants.infoGroupMaxValueLength),
child: InfoRowGroup(
dir.tags,
maxValueLength: Constants.infoGroupMaxValueLength,
linkHandlers: dirName == SvgMetadata.metadataDirectory ? SvgMetadata.getLinkHandlers(dir.tags) : null,
),
),
],
);
@ -179,7 +192,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
if (entry == null) return;
if (_loadedMetadataUri.value == entry.uri) return;
if (isVisible) {
final rawMetadata = await MetadataService.getAllMetadata(entry) ?? {};
final rawMetadata = await (entry.isSvg ? SvgMetadata.getAllMetadata(entry) : MetadataService.getAllMetadata(entry)) ?? {};
final directories = rawMetadata.entries.map((dirKV) {
var directoryName = dirKV.key as String ?? '';

View file

@ -5,7 +5,7 @@ import 'package:aves/model/image_entry.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:flutter/material.dart';
enum MetadataThumbnailSource { embedded, exif, xmp }
enum MetadataThumbnailSource { embedded, exif }
class MetadataThumbnails extends StatefulWidget {
final MetadataThumbnailSource source;
@ -36,10 +36,7 @@ class _MetadataThumbnailsState extends State<MetadataThumbnails> {
_loader = MetadataService.getEmbeddedPictures(uri);
break;
case MetadataThumbnailSource.exif:
_loader = MetadataService.getExifThumbnails(uri);
break;
case MetadataThumbnailSource.xmp:
_loader = MetadataService.getXmpThumbnails(entry);
_loader = MetadataService.getExifThumbnails(entry);
break;
}
}

View file

@ -0,0 +1,76 @@
import 'dart:collection';
import 'dart:convert';
import 'package:aves/model/image_entry.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/utils/string_utils.dart';
import 'package:aves/widgets/fullscreen/info/common.dart';
import 'package:aves/widgets/fullscreen/source_viewer_page.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:xml/xml.dart';
class SvgMetadata {
static const docDirectory = 'Document';
static const metadataDirectory = 'Metadata';
static const _attributes = ['x', 'y', 'width', 'height', 'preserveAspectRatio', 'viewBox'];
static const _textElements = ['title', 'desc'];
static const _metadataElement = 'metadata';
static Future<Map<String, Map<String, String>>> getAllMetadata(ImageEntry entry) async {
try {
final data = await ImageFileService.getImage(entry.uri, entry.mimeType, 0, false);
final document = XmlDocument.parse(utf8.decode(data));
final root = document.rootElement;
final docDir = Map.fromEntries([
...root.attributes.where((a) => _attributes.contains(a.name.qualified)).map((a) => MapEntry(_formatKey(a.name.qualified), a.value)),
..._textElements.map((name) => MapEntry(_formatKey(name), root.getElement(name)?.text)).where((kv) => kv.value != null),
]);
final metadata = root.getElement(_metadataElement);
final metadataDir = Map.fromEntries([
if (metadata != null) MapEntry('Metadata', metadata.toXmlString(pretty: true)),
]);
return {
if (docDir.isNotEmpty) docDirectory: docDir,
if (metadataDir.isNotEmpty) metadataDirectory: metadataDir,
};
} catch (exception, stack) {
debugPrint('failed to parse XML from SVG with exception=$exception\n$stack');
return null;
}
}
static Map<String, InfoLinkHandler> getLinkHandlers(SplayTreeMap<String, String> tags) {
return {
'Metadata': InfoLinkHandler(
linkText: 'View XML',
onTap: (context) {
Navigator.push(
context,
MaterialPageRoute(
settings: RouteSettings(name: SourceViewerPage.routeName),
builder: (context) => SourceViewerPage(
loader: () => SynchronousFuture(tags['Metadata']),
),
),
);
},
),
};
}
static String _formatKey(String key) {
switch (key) {
case 'desc':
return 'Description';
default:
return key.toSentenceCase();
}
}
}

View file

@ -0,0 +1,137 @@
import 'package:aves/ref/brand_colors.dart';
import 'package:aves/ref/xmp.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/utils/string_utils.dart';
import 'package:aves/widgets/common/identity/highlight_title.dart';
import 'package:aves/widgets/fullscreen/info/common.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class XmpNamespace {
final String namespace;
const XmpNamespace(this.namespace);
String get displayTitle => XMP.namespaces[namespace] ?? namespace;
List<Widget> buildNamespaceSection({
@required List<MapEntry<String, String>> rawProps,
}) {
final props = rawProps
.map((kv) {
final prop = XmpProp(kv.key, kv.value);
return extractData(prop) ? null : prop;
})
.where((e) => e != null)
.toList()
..sort((a, b) => compareAsciiUpperCaseNatural(a.displayKey, b.displayKey));
final content = [
if (props.isNotEmpty)
InfoRowGroup(
Map.fromEntries(props.map((prop) => MapEntry(prop.displayKey, formatValue(prop)))),
maxValueLength: Constants.infoGroupMaxValueLength,
linkHandlers: linkifyValues(props),
),
...buildFromExtractedData(),
];
return content.isNotEmpty
? [
if (displayTitle.isNotEmpty)
Padding(
padding: EdgeInsets.only(top: 8),
child: HighlightTitle(
displayTitle,
color: BrandColors.get(displayTitle),
selectable: true,
),
),
...content
]
: [];
}
bool extractStruct(XmpProp prop, RegExp pattern, Map<String, String> store) {
final matches = pattern.allMatches(prop.path);
if (matches.isEmpty) return false;
final match = matches.first;
final field = XmpProp.formatKey(match.group(1));
store[field] = formatValue(prop);
return true;
}
bool extractIndexedStruct(XmpProp prop, RegExp pattern, Map<int, Map<String, String>> store) {
final matches = pattern.allMatches(prop.path);
if (matches.isEmpty) return false;
final match = matches.first;
final index = int.parse(match.group(1));
final field = XmpProp.formatKey(match.group(2));
final fields = store.putIfAbsent(index, () => <String, String>{});
fields[field] = formatValue(prop);
return true;
}
bool extractData(XmpProp prop) => false;
List<Widget> buildFromExtractedData() => [];
String formatValue(XmpProp prop) => prop.value;
Map<String, InfoLinkHandler> linkifyValues(List<XmpProp> props) => null;
// identity
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is XmpNamespace && other.namespace == namespace;
}
@override
int get hashCode => namespace.hashCode;
@override
String toString() {
return '$runtimeType#${shortHash(this)}{namespace=$namespace}';
}
}
class XmpProp {
final String path, value;
final String displayKey;
XmpProp(this.path, this.value) : displayKey = formatKey(path);
static String formatKey(String propPath) {
return propPath.splitMapJoin(XMP.structFieldSeparator,
onMatch: (match) => ' ${match.group(0)} ',
onNonMatch: (s) {
// strip namespace & format
return s.split(XMP.propNamespaceSeparator).last.toSentenceCase();
});
}
@override
String toString() {
return '$runtimeType#${shortHash(this)}{path=$path, value=$value}';
}
}
class OpenEmbeddedDataNotification extends Notification {
final String propPath;
final String mimeType;
const OpenEmbeddedDataNotification({
@required this.propPath,
@required this.mimeType,
});
@override
String toString() {
return '$runtimeType#${shortHash(this)}{propPath=$propPath, mimeType=$mimeType}';
}
}

View file

@ -0,0 +1,78 @@
import 'package:aves/ref/exif.dart';
import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart';
// cf https://github.com/adobe/xmp-docs/blob/master/XMPNamespaces/exif.md
class XmpExifNamespace extends XmpNamespace {
static const ns = 'exif';
XmpExifNamespace() : super(ns);
@override
String get displayTitle => 'Exif';
@override
String formatValue(XmpProp prop) {
final v = prop.value;
switch (prop.path) {
case 'exif:ColorSpace':
return Exif.getColorSpaceDescription(v);
case 'exif:Contrast':
return Exif.getContrastDescription(v);
case 'exif:CustomRendered':
return Exif.getCustomRenderedDescription(v);
case 'exif:ExifVersion':
case 'exif:FlashpixVersion':
return Exif.getExifVersionDescription(v);
case 'exif:ExposureMode':
return Exif.getExposureModeDescription(v);
case 'exif:ExposureProgram':
return Exif.getExposureProgramDescription(v);
case 'exif:FileSource':
return Exif.getFileSourceDescription(v);
case 'exif:Flash/exif:Mode':
return Exif.getFlashModeDescription(v);
case 'exif:Flash/exif:Return':
return Exif.getFlashReturnDescription(v);
case 'exif:FocalPlaneResolutionUnit':
return Exif.getResolutionUnitDescription(v);
case 'exif:GainControl':
return Exif.getGainControlDescription(v);
case 'exif:LightSource':
return Exif.getLightSourceDescription(v);
case 'exif:MeteringMode':
return Exif.getMeteringModeDescription(v);
case 'exif:Saturation':
return Exif.getSaturationDescription(v);
case 'exif:SceneCaptureType':
return Exif.getSceneCaptureTypeDescription(v);
case 'exif:SceneType':
return Exif.getSceneTypeDescription(v);
case 'exif:SensingMethod':
return Exif.getSensingMethodDescription(v);
case 'exif:Sharpness':
return Exif.getSharpnessDescription(v);
case 'exif:SubjectDistanceRange':
return Exif.getSubjectDistanceRangeDescription(v);
case 'exif:WhiteBalance':
return Exif.getWhiteBalanceDescription(v);
case 'exif:GPSAltitudeRef':
return Exif.getGPSAltitudeRefDescription(v);
case 'exif:GPSDestBearingRef':
case 'exif:GPSImgDirectionRef':
case 'exif:GPSTrackRef':
return Exif.getGPSDirectionRefDescription(v);
case 'exif:GPSDestDistanceRef':
return Exif.getGPSDestDistanceRefDescription(v);
case 'exif:GPSDifferential':
return Exif.getGPSDifferentialDescription(v);
case 'exif:GPSMeasureMode':
return Exif.getGPSMeasureModeDescription(v);
case 'exif:GPSSpeedRef':
return Exif.getGPSSpeedRefDescription(v);
case 'exif:GPSStatus':
return Exif.getGPSStatusDescription(v);
default:
return v;
}
}
}

View file

@ -0,0 +1,69 @@
import 'package:aves/widgets/fullscreen/info/common.dart';
import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart';
import 'package:tuple/tuple.dart';
abstract class XmpGoogleNamespace extends XmpNamespace {
XmpGoogleNamespace(String ns) : super(ns);
List<Tuple2<String, String>> get dataProps;
@override
Map<String, InfoLinkHandler> linkifyValues(List<XmpProp> props) {
return Map.fromEntries(dataProps.map((t) {
final dataPropPath = t.item1;
final mimePropPath = t.item2;
final dataProp = props.firstWhere((prop) => prop.path == dataPropPath, orElse: () => null);
final mimeProp = props.firstWhere((prop) => prop.path == mimePropPath, orElse: () => null);
return (dataProp != null && mimeProp != null)
? MapEntry(
dataProp.displayKey,
InfoLinkHandler(
linkText: 'Open',
onTap: (context) => OpenEmbeddedDataNotification(
propPath: dataProp.path,
mimeType: mimeProp.value,
).dispatch(context),
))
: null;
}).where((e) => e != null));
}
}
class XmpGAudioNamespace extends XmpGoogleNamespace {
static const ns = 'GAudio';
XmpGAudioNamespace() : super(ns);
@override
List<Tuple2<String, String>> get dataProps => [Tuple2('$ns:Data', '$ns:Mime')];
@override
String get displayTitle => 'Google Audio';
}
class XmpGDepthNamespace extends XmpGoogleNamespace {
static const ns = 'GDepth';
XmpGDepthNamespace() : super(ns);
@override
List<Tuple2<String, String>> get dataProps => [
Tuple2('$ns:Data', '$ns:Mime'),
Tuple2('$ns:Confidence', '$ns:ConfidenceMime'),
];
@override
String get displayTitle => 'Google Depth';
}
class XmpGImageNamespace extends XmpGoogleNamespace {
static const ns = 'GImage';
XmpGImageNamespace() : super(ns);
@override
List<Tuple2<String, String>> get dataProps => [Tuple2('$ns:Data', '$ns:Mime')];
@override
String get displayTitle => 'Google Image';
}

View file

@ -0,0 +1,28 @@
import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart';
import 'package:aves/widgets/fullscreen/info/metadata/xmp_structs.dart';
import 'package:flutter/material.dart';
class XmpIptcCoreNamespace extends XmpNamespace {
static const ns = 'Iptc4xmpCore';
static final creatorContactInfoPattern = RegExp(r'Iptc4xmpCore:CreatorContactInfo/(.*)');
final creatorContactInfo = <String, String>{};
XmpIptcCoreNamespace() : super(ns);
@override
String get displayTitle => 'IPTC Core';
@override
bool extractData(XmpProp prop) => extractStruct(prop, creatorContactInfoPattern, creatorContactInfo);
@override
List<Widget> buildFromExtractedData() => [
if (creatorContactInfo.isNotEmpty)
XmpStructCard(
title: 'Creator Contact Info',
struct: creatorContactInfo,
),
];
}

View file

@ -0,0 +1,47 @@
// cf photoshop:ColorMode
// cf https://github.com/adobe/xmp-docs/blob/master/XMPNamespaces/photoshop.md
import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart';
class XmpPhotoshopNamespace extends XmpNamespace {
static const ns = 'photoshop';
XmpPhotoshopNamespace() : super(ns);
@override
String get displayTitle => 'Photoshop';
@override
String formatValue(XmpProp prop) {
final value = prop.value;
switch (prop.path) {
case 'photoshop:ColorMode':
return getColorModeDescription(value);
}
return value;
}
static String getColorModeDescription(String valueString) {
final value = int.tryParse(valueString);
if (value == null) return valueString;
switch (value) {
case 0:
return 'Bitmap';
case 1:
return 'Gray scale';
case 2:
return 'Indexed colour';
case 3:
return 'RGB colour';
case 4:
return 'CMYK colour';
case 7:
return 'Multi-channel';
case 8:
return 'Duotone';
case 9:
return 'LAB colour';
default:
return 'Unknown ($value)';
}
}
}

View file

@ -0,0 +1,32 @@
import 'package:aves/ref/exif.dart';
import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart';
// cf https://github.com/adobe/xmp-docs/blob/master/XMPNamespaces/tiff.md
class XmpTiffNamespace extends XmpNamespace {
static const ns = 'tiff';
@override
String get displayTitle => 'TIFF';
XmpTiffNamespace() : super(ns);
@override
String formatValue(XmpProp prop) {
final value = prop.value;
switch (prop.path) {
case 'tiff:Compression':
return Exif.getCompressionDescription(value);
case 'tiff:Orientation':
return Exif.getOrientationDescription(value);
case 'tiff:PhotometricInterpretation':
return Exif.getPhotometricInterpretationDescription(value);
case 'tiff:PlanarConfiguration':
return Exif.getPlanarConfigurationDescription(value);
case 'tiff:ResolutionUnit':
return Exif.getResolutionUnitDescription(value);
case 'tiff:YCbCrPositioning':
return Exif.getYCbCrPositioningDescription(value);
}
return value;
}
}

View file

@ -0,0 +1,105 @@
import 'package:aves/ref/mime_types.dart';
import 'package:aves/widgets/fullscreen/info/common.dart';
import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart';
import 'package:aves/widgets/fullscreen/info/metadata/xmp_structs.dart';
import 'package:flutter/material.dart';
class XmpBasicNamespace extends XmpNamespace {
static const ns = 'xmp';
static final thumbnailsPattern = RegExp(r'xmp:Thumbnails\[(\d+)\]/(.*)');
static const thumbnailDataDisplayKey = 'Image';
final thumbnails = <int, Map<String, String>>{};
XmpBasicNamespace() : super(ns);
@override
String get displayTitle => 'Basic';
@override
bool extractData(XmpProp prop) => extractIndexedStruct(prop, thumbnailsPattern, thumbnails);
@override
List<Widget> buildFromExtractedData() => [
if (thumbnails.isNotEmpty)
XmpStructArrayCard(
title: 'Thumbnail',
structByIndex: thumbnails,
linkifier: (index) {
final struct = thumbnails[index];
return {
if (struct.containsKey(thumbnailDataDisplayKey))
thumbnailDataDisplayKey: InfoLinkHandler(
linkText: 'Open',
onTap: (context) => OpenEmbeddedDataNotification(
propPath: 'xmp:Thumbnails[$index]/xmpGImg:image',
mimeType: MimeTypes.jpeg,
).dispatch(context),
),
};
},
)
];
}
class XmpMMNamespace extends XmpNamespace {
static const ns = 'xmpMM';
static const didPrefix = 'xmp.did:';
static const iidPrefix = 'xmp.iid:';
static final derivedFromPattern = RegExp(r'xmpMM:DerivedFrom/(.*)');
static final historyPattern = RegExp(r'xmpMM:History\[(\d+)\]/(.*)');
final derivedFrom = <String, String>{};
final history = <int, Map<String, String>>{};
XmpMMNamespace() : super(ns);
@override
String get displayTitle => 'Media Management';
@override
bool extractData(XmpProp prop) {
final hasStructs = extractStruct(prop, derivedFromPattern, derivedFrom);
final hasIndexedStructs = extractIndexedStruct(prop, historyPattern, history);
return hasStructs || hasIndexedStructs;
}
@override
List<Widget> buildFromExtractedData() => [
if (derivedFrom.isNotEmpty)
XmpStructCard(
title: 'Derived From',
struct: derivedFrom,
),
if (history.isNotEmpty)
XmpStructArrayCard(
title: 'History',
structByIndex: history,
),
];
@override
String formatValue(XmpProp prop) {
final value = prop.value;
if (value.startsWith(didPrefix)) return value.replaceFirst(didPrefix, '');
if (value.startsWith(iidPrefix)) return value.replaceFirst(iidPrefix, '');
return value;
}
}
class XmpNoteNamespace extends XmpNamespace {
static const ns = 'xmpNote';
// `xmpNote:HasExtendedXMP` is structural and should not be displayed to users
static const hasExtendedXmp = '$ns:HasExtendedXMP';
XmpNoteNamespace() : super(ns);
@override
bool extractData(XmpProp prop) {
return prop.path == hasExtendedXmp;
}
}

View file

@ -0,0 +1,141 @@
import 'dart:math';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/basic/multi_cross_fader.dart';
import 'package:aves/widgets/common/identity/highlight_title.dart';
import 'package:aves/widgets/fullscreen/info/common.dart';
import 'package:flutter/material.dart';
class XmpStructArrayCard extends StatefulWidget {
final String title;
final List<Map<String, String>> structs = [];
final Map<String, InfoLinkHandler> Function(int index) linkifier;
XmpStructArrayCard({
@required this.title,
@required Map<int, Map<String, String>> structByIndex,
this.linkifier,
}) {
structs.length = structByIndex.keys.fold(0, max);
structByIndex.keys.forEach((index) => structs[index - 1] = structByIndex[index]);
}
@override
_XmpStructArrayCardState createState() => _XmpStructArrayCardState();
}
class _XmpStructArrayCardState extends State<XmpStructArrayCard> {
int _index;
List<Map<String, String>> get structs => widget.structs;
@override
void initState() {
super.initState();
_index = structs.length - 1;
}
@override
Widget build(BuildContext context) {
void setIndex(int index) {
index = index.clamp(0, structs.length - 1);
if (_index != index) {
_index = index;
setState(() {});
}
}
return Card(
margin: XmpStructCard.cardMargin,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.only(left: 8, top: 8, right: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: HighlightTitle(
'${widget.title} ${_index + 1}',
color: Colors.transparent,
selectable: true,
),
),
IconButton(
visualDensity: VisualDensity.compact,
icon: Icon(AIcons.previous),
onPressed: _index > 0 ? () => setIndex(_index - 1) : null,
tooltip: 'Previous',
),
IconButton(
visualDensity: VisualDensity.compact,
icon: Icon(AIcons.next),
onPressed: _index < structs.length - 1 ? () => setIndex(_index + 1) : null,
tooltip: 'Next',
),
],
),
),
MultiCrossFader(
duration: Durations.xmpStructArrayCardTransition,
sizeCurve: Curves.easeOutBack,
alignment: AlignmentDirectional.topStart,
child: Padding(
// add padding at this level (instead of the column level)
// so that the crossfader can animate the content size
// without clipping the text
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup(
structs[_index],
maxValueLength: Constants.infoGroupMaxValueLength,
linkHandlers: widget.linkifier?.call(_index + 1),
),
),
),
],
),
);
}
}
class XmpStructCard extends StatelessWidget {
final String title;
final Map<String, String> struct;
final Map<String, InfoLinkHandler> Function() linkifier;
static const cardMargin = EdgeInsets.symmetric(vertical: 8, horizontal: 0);
const XmpStructCard({
@required this.title,
@required this.struct,
this.linkifier,
});
@override
Widget build(BuildContext context) {
return Card(
margin: cardMargin,
child: Padding(
padding: EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HighlightTitle(
title,
color: Colors.transparent,
selectable: true,
),
InfoRowGroup(
struct,
maxValueLength: Constants.infoGroupMaxValueLength,
linkHandlers: linkifier?.call(),
),
],
),
),
);
}
}

View file

@ -1,18 +1,27 @@
import 'dart:collection';
import 'package:aves/model/image_entry.dart';
import 'package:aves/ref/brand_colors.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/ref/xmp.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/behaviour/routes.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/common/identity/highlight_title.dart';
import 'package:aves/widgets/fullscreen/info/common.dart';
import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/fullscreen/fullscreen_page.dart';
import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart';
import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/exif.dart';
import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/google.dart';
import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/iptc.dart';
import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/photoshop.dart';
import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/tiff.dart';
import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/xmp.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:pedantic/pedantic.dart';
class XmpDirTile extends StatelessWidget {
class XmpDirTile extends StatefulWidget {
final ImageEntry entry;
final SplayTreeMap<String, String> tags;
final ValueNotifier<String> expandedNotifier;
@ -23,56 +32,101 @@ class XmpDirTile extends StatelessWidget {
@required this.expandedNotifier,
});
@override
_XmpDirTileState createState() => _XmpDirTileState();
}
class _XmpDirTileState extends State<XmpDirTile> with FeedbackMixin {
ImageEntry get entry => widget.entry;
@override
Widget build(BuildContext context) {
final thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry);
final sections = SplayTreeMap.of(
groupBy<MapEntry<String, String>, String>(tags.entries, (kv) {
final sections = SplayTreeMap<XmpNamespace, List<MapEntry<String, String>>>.of(
groupBy(widget.tags.entries, (kv) {
final fullKey = kv.key;
final i = fullKey.indexOf(XMP.namespaceSeparator);
if (i == -1) return '';
final namespace = fullKey.substring(0, i);
return XMP.namespaces[namespace] ?? namespace;
final i = fullKey.indexOf(XMP.propNamespaceSeparator);
final namespace = i == -1 ? '' : fullKey.substring(0, i);
switch (namespace) {
case XmpBasicNamespace.ns:
return XmpBasicNamespace();
case XmpExifNamespace.ns:
return XmpExifNamespace();
case XmpGAudioNamespace.ns:
return XmpGAudioNamespace();
case XmpGDepthNamespace.ns:
return XmpGDepthNamespace();
case XmpGImageNamespace.ns:
return XmpGImageNamespace();
case XmpIptcCoreNamespace.ns:
return XmpIptcCoreNamespace();
case XmpMMNamespace.ns:
return XmpMMNamespace();
case XmpNoteNamespace.ns:
return XmpNoteNamespace();
case XmpPhotoshopNamespace.ns:
return XmpPhotoshopNamespace();
case XmpTiffNamespace.ns:
return XmpTiffNamespace();
default:
return XmpNamespace(namespace);
}
}),
compareAsciiUpperCase,
(a, b) => compareAsciiUpperCase(a.displayTitle, b.displayTitle),
);
return AvesExpansionTile(
title: 'XMP',
expandedNotifier: expandedNotifier,
expandedNotifier: widget.expandedNotifier,
children: [
if (thumbnail != null) thumbnail,
Padding(
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: sections.entries.expand((sectionEntry) {
final title = sectionEntry.key;
final entries = sectionEntry.value.map((kv) {
final key = kv.key.splitMapJoin(XMP.structFieldSeparator, onNonMatch: (s) {
// strip namespace
final key = s.split(XMP.namespaceSeparator).last;
// uppercase first letter
return key.replaceFirstMapped(RegExp('.'), (m) => m.group(0).toUpperCase());
});
return MapEntry(key, kv.value);
}).toList()
..sort((a, b) => compareAsciiUpperCaseNatural(a.key, b.key));
return [
if (title.isNotEmpty)
Padding(
padding: EdgeInsets.only(top: 8),
child: HighlightTitle(
title,
color: BrandColors.get(title),
),
),
InfoRowGroup(Map.fromEntries(entries), maxValueLength: Constants.infoGroupMaxValueLength),
];
}).toList(),
NotificationListener<OpenEmbeddedDataNotification>(
onNotification: (notification) {
_openEmbeddedData(notification.propPath, notification.mimeType);
return true;
},
child: Padding(
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: sections.entries
.expand((kv) => kv.key.buildNamespaceSection(
rawProps: kv.value,
))
.toList(),
),
),
),
],
);
}
Future<void> _openEmbeddedData(String propPath, String propMimeType) async {
final fields = await MetadataService.extractXmpDataProp(entry, propPath, propMimeType);
if (fields == null || !fields.containsKey('mimeType') || !fields.containsKey('uri')) {
showFeedback(context, 'Failed');
return;
}
final mimeType = fields['mimeType'];
final uri = fields['uri'];
if (!MimeTypes.isImage(mimeType) && !MimeTypes.isVideo(mimeType)) {
// open with another app
unawaited(AndroidAppService.open(uri, mimeType).then((success) {
if (!success) {
// fallback to sharing, so that the file can be saved somewhere
AndroidAppService.shareSingle(uri, mimeType).then((success) {
if (!success) showNoMatchingAppDialog(context);
});
}
}));
return;
}
final embedEntry = ImageEntry.fromMap(fields);
unawaited(Navigator.push(
context,
TransparentMaterialPageRoute(
settings: RouteSettings(name: SingleFullscreenPage.routeName),
pageBuilder: (c, a, sa) => SingleFullscreenPage(entry: embedEntry),
),
));
}
}

View file

@ -303,3 +303,35 @@ class _ShootingRow extends StatelessWidget {
);
}
}
class ExtraBottomOverlay extends StatelessWidget {
final EdgeInsets viewInsets, viewPadding;
final Widget child;
const ExtraBottomOverlay({
Key key,
this.viewInsets,
this.viewPadding,
@required this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final mq = context.select<MediaQueryData, Tuple3<double, EdgeInsets, EdgeInsets>>((mq) => Tuple3(mq.size.width, mq.viewInsets, mq.viewPadding));
final mqWidth = mq.item1;
final mqViewInsets = mq.item2;
final mqViewPadding = mq.item3;
final viewInsets = this.viewInsets ?? mqViewInsets;
final viewPadding = this.viewPadding ?? mqViewPadding;
final safePadding = (viewInsets + viewPadding).copyWith(bottom: 8) + EdgeInsets.symmetric(horizontal: 8.0);
return Padding(
padding: safePadding,
child: SizedBox(
width: mqWidth - safePadding.horizontal,
child: child,
),
);
}
}

View file

@ -8,7 +8,12 @@ class OverlayButton extends StatelessWidget {
final Animation<double> scale;
final Widget child;
const OverlayButton({Key key, this.scale, this.child}) : super(key: key);
const OverlayButton({
Key key,
@required this.scale,
@required this.child,
}) : assert(scale != null),
super(key: key);
@override
Widget build(BuildContext context) {
@ -30,3 +35,45 @@ class OverlayButton extends StatelessWidget {
);
}
}
class OverlayTextButton extends StatelessWidget {
final Animation<double> scale;
final String text;
final VoidCallback onPressed;
const OverlayTextButton({
Key key,
@required this.scale,
@required this.text,
this.onPressed,
}) : assert(scale != null),
super(key: key);
static const _borderRadius = 123.0;
static final _minSize = MaterialStateProperty.all<Size>(Size(kMinInteractiveDimension, kMinInteractiveDimension));
@override
Widget build(BuildContext context) {
return SizeTransition(
sizeFactor: scale,
child: BlurredRRect(
borderRadius: _borderRadius,
child: OutlinedButton(
onPressed: onPressed,
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all<Color>(kOverlayBackgroundColor),
foregroundColor: MaterialStateProperty.all<Color>(Colors.white),
overlayColor: MaterialStateProperty.all<Color>(Colors.white.withOpacity(0.12)),
minimumSize: _minSize,
side: MaterialStateProperty.all<BorderSide>(AvesCircleBorder.buildSide(context)),
shape: MaterialStateProperty.all<OutlinedBorder>(RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(_borderRadius)),
)),
// shape: MaterialStateProperty.all<OutlinedBorder>(CircleBorder()),
),
child: Text(text.toUpperCase()),
),
),
);
}
}

View file

@ -0,0 +1,37 @@
import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/fullscreen/overlay/common.dart';
import 'package:aves/widgets/fullscreen/panorama_page.dart';
import 'package:flutter/material.dart';
class PanoramaOverlay extends StatelessWidget {
final ImageEntry entry;
final Animation<double> scale;
const PanoramaOverlay({
Key key,
@required this.entry,
@required this.scale,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Row(
children: [
Spacer(),
OverlayTextButton(
scale: scale,
text: 'Open Panorama',
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
settings: RouteSettings(name: PanoramaPage.routeName),
builder: (context) => PanoramaPage(entry: entry),
),
);
},
)
],
);
}
}

View file

@ -10,22 +10,17 @@ import 'package:aves/widgets/common/fx/borders.dart';
import 'package:aves/widgets/fullscreen/overlay/common.dart';
import 'package:flutter/material.dart';
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class VideoControlOverlay extends StatefulWidget {
final ImageEntry entry;
final Animation<double> scale;
final IjkMediaController controller;
final EdgeInsets viewInsets, viewPadding;
final Animation<double> scale;
const VideoControlOverlay({
Key key,
@required this.entry,
@required this.controller,
@required this.scale,
this.viewInsets,
this.viewPadding,
}) : super(key: key);
@override
@ -99,63 +94,48 @@ class VideoControlOverlayState extends State<VideoControlOverlay> with SingleTic
@override
Widget build(BuildContext context) {
final mq = context.select<MediaQueryData, Tuple3<double, EdgeInsets, EdgeInsets>>((mq) => Tuple3(mq.size.width, mq.viewInsets, mq.viewPadding));
final mqWidth = mq.item1;
final mqViewInsets = mq.item2;
final mqViewPadding = mq.item3;
final viewInsets = widget.viewInsets ?? mqViewInsets;
final viewPadding = widget.viewPadding ?? mqViewPadding;
final safePadding = (viewInsets + viewPadding).copyWith(bottom: 8) + EdgeInsets.symmetric(horizontal: 8.0);
return Padding(
padding: safePadding,
child: SizedBox(
width: mqWidth - safePadding.horizontal,
child: StreamBuilder<IjkStatus>(
stream: controller.ijkStatusStream,
builder: (context, snapshot) {
// do not use stream snapshot because it is obsolete when switching between videos
final status = controller.ijkStatus;
return TooltipTheme(
data: TooltipTheme.of(context).copyWith(
preferBelow: false,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: status == IjkStatus.error
? [
OverlayButton(
scale: scale,
child: IconButton(
icon: Icon(AIcons.openInNew),
onPressed: () => AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype),
tooltip: 'Open',
),
return StreamBuilder<IjkStatus>(
stream: controller.ijkStatusStream,
builder: (context, snapshot) {
// do not use stream snapshot because it is obsolete when switching between videos
final status = controller.ijkStatus;
return TooltipTheme(
data: TooltipTheme.of(context).copyWith(
preferBelow: false,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: status == IjkStatus.error
? [
OverlayButton(
scale: scale,
child: IconButton(
icon: Icon(AIcons.openInNew),
onPressed: () => AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype),
tooltip: 'Open',
),
),
]
: [
Expanded(
child: _buildProgressBar(),
),
SizedBox(width: 8),
OverlayButton(
scale: scale,
child: IconButton(
icon: AnimatedIcon(
icon: AnimatedIcons.play_pause,
progress: _playPauseAnimation,
),
]
: [
Expanded(
child: _buildProgressBar(),
),
SizedBox(width: 8),
OverlayButton(
scale: scale,
child: IconButton(
icon: AnimatedIcon(
icon: AnimatedIcons.play_pause,
progress: _playPauseAnimation,
),
onPressed: _playPause,
tooltip: isPlaying ? 'Pause' : 'Play',
),
),
],
),
);
}),
),
);
onPressed: _playPause,
tooltip: isPlaying ? 'Pause' : 'Play',
),
),
],
),
);
});
}
Widget _buildProgressBar() {

View file

@ -0,0 +1,32 @@
import 'package:aves/image_providers/uri_image_provider.dart';
import 'package:aves/model/image_entry.dart';
import 'package:flutter/material.dart';
import 'package:panorama/panorama.dart';
class PanoramaPage extends StatelessWidget {
static const routeName = '/fullscreen/panorama';
final ImageEntry entry;
const PanoramaPage({@required this.entry});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Panorama(
child: Image(
image: UriImage(
uri: entry.uri,
mimeType: entry.mimeType,
rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
expectedContentLength: entry.sizeBytes,
),
),
// TODO TLAD toggle sensor control
sensorControl: SensorControl.None,
),
resizeToAvoidBottomInset: false,
);
}
}

View file

@ -1,7 +1,3 @@
import 'dart:convert';
import 'package:aves/model/image_entry.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/widgets/common/aves_highlight.dart';
import 'package:flutter/material.dart';
import 'package:flutter_highlight/themes/darcula.dart';
@ -9,10 +5,10 @@ import 'package:flutter_highlight/themes/darcula.dart';
class SourceViewerPage extends StatefulWidget {
static const routeName = '/fullscreen/source';
final ImageEntry entry;
final Future<String> Function() loader;
const SourceViewerPage({
@required this.entry,
@required this.loader,
});
@override
@ -22,12 +18,10 @@ class SourceViewerPage extends StatefulWidget {
class _SourceViewerPageState extends State<SourceViewerPage> {
Future<String> _loader;
ImageEntry get entry => widget.entry;
@override
void initState() {
super.initState();
_loader = ImageFileService.getImage(entry.uri, entry.mimeType, 0, false).then(utf8.decode);
_loader = widget.loader();
}
@override
@ -40,12 +34,8 @@ class _SourceViewerPageState extends State<SourceViewerPage> {
child: FutureBuilder<String>(
future: _loader,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Text(snapshot.error.toString());
}
if (snapshot.connectionState != ConnectionState.done) {
return SizedBox.shrink();
}
if (snapshot.hasError) return Text(snapshot.error.toString());
if (!snapshot.hasData) return SizedBox.shrink();
final source = snapshot.data;
final highlightView = AvesHighlightView(

View file

@ -82,6 +82,9 @@ class ImageSearchDelegate {
MimeFilter(MimeTypes.anyImage),
MimeFilter(MimeTypes.anyVideo),
MimeFilter(MimeFilter.animated),
MimeFilter(MimeFilter.panorama),
MimeFilter(MimeFilter.sphericalVideo),
MimeFilter(MimeFilter.geotiff),
MimeFilter(MimeTypes.svg),
].where((f) => f != null && containQuery(f.label)),
// usually perform hero animation only on tapped chips,

View file

@ -5,7 +5,6 @@ import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/common/identity/highlight_title.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/settings/access_grants.dart';
@ -225,17 +224,3 @@ class _SettingsPageState extends State<SettingsPage> {
);
}
}
class SectionTitle extends StatelessWidget {
final String text;
const SectionTitle(this.text);
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(left: 16, top: 6, right: 16, bottom: 12),
child: HighlightTitle(text),
);
}
}

View file

@ -63,7 +63,7 @@ packages:
name: cached_network_image
url: "https://pub.dartlang.org"
source: hosted
version: "2.3.3"
version: "2.4.1"
characters:
dependency: transitive
description:
@ -200,14 +200,14 @@ packages:
name: firebase
url: "https://pub.dartlang.org"
source: hosted
version: "7.3.2"
version: "7.3.3"
firebase_analytics:
dependency: "direct main"
description:
name: firebase_analytics
url: "https://pub.dartlang.org"
source: hosted
version: "6.2.0"
version: "6.3.0"
firebase_analytics_platform_interface:
dependency: transitive
description:
@ -228,35 +228,35 @@ packages:
name: firebase_core
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.2"
version: "0.5.3"
firebase_core_platform_interface:
dependency: transitive
description:
name: firebase_core_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
version: "2.1.0"
firebase_core_web:
dependency: transitive
description:
name: firebase_core_web
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.1"
version: "0.2.1+1"
firebase_crashlytics:
dependency: "direct main"
description:
name: firebase_crashlytics
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.3"
version: "0.2.4"
firebase_crashlytics_platform_interface:
dependency: transitive
description:
name: firebase_crashlytics_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.3"
version: "1.1.4"
flushbar:
dependency: "direct main"
description:
@ -283,6 +283,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
flutter_cube:
dependency: transitive
description:
name: flutter_cube
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.6"
flutter_driver:
dependency: "direct dev"
description: flutter
@ -522,6 +529,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.9.7"
motion_sensors:
dependency: transitive
description:
name: motion_sensors
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.4"
nested:
dependency: transitive
description:
@ -585,6 +599,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.3"
panorama:
dependency: "direct main"
description:
name: panorama
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.0"
path:
dependency: transitive
description:
@ -747,7 +768,7 @@ packages:
name: provider
url: "https://pub.dartlang.org"
source: hosted
version: "4.3.2+2"
version: "4.3.2+3"
pub_semver:
dependency: transitive
description:
@ -1132,7 +1153,7 @@ packages:
source: hosted
version: "0.1.2"
xml:
dependency: transitive
dependency: "direct main"
description:
name: xml
url: "https://pub.dartlang.org"

View file

@ -1,21 +1,9 @@
name: aves
description: Aves is a gallery and metadata explorer app, built for Android.
# The following line prevents the package from being accidentally published to
# pub.dev using `pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# 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.8+34
version: 1.2.9+35
# brendan-duncan/image (as of v2.1.19):
# - does not support TIFF with JPEG compression (issue #184)
@ -77,6 +65,7 @@ dependencies:
overlay_support:
package_info:
palette_generator:
panorama:
pdf:
pedantic:
percent_indicator:
@ -93,6 +82,7 @@ dependencies:
streams_channel:
tuple:
url_launcher:
xml:
dev_dependencies:
flutter_test:

File diff suppressed because one or more lines are too long

1
shaders_1.22.5.sksl.json Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,17 @@
import 'package:test/test.dart';
import 'package:aves/utils/string_utils.dart';
void main() {
test('Sentence case', () {
expect('XResolution'.toSentenceCase(), 'X Resolution');
expect('PixelXDimension'.toSentenceCase(), 'Pixel X Dimension');
expect('FocalPointX'.toSentenceCase(), 'Focal Point X');
expect('ISOSpeedRatings[1]'.toSentenceCase(), 'ISO Speed Ratings [1]');
expect('LegacyIPTCDigest'.toSentenceCase(), 'Legacy IPTC Digest');
expect('DocumentID'.toSentenceCase(), 'Document ID');
expect('H'.toSentenceCase(), 'H');
expect('LW[1]'.toSentenceCase(), 'LW [1]');
});
}

View file

@ -30,6 +30,8 @@ void main() {
});
agreeToTerms();
visitAbout();
visitSettings();
sortCollection();
groupCollection();
selectFirstAlbum();
@ -43,7 +45,7 @@ void main() {
test('contemplation', () async {
await Future.delayed(Duration(seconds: 5));
});
}, timeout: Timeout(Duration(seconds: 10)));
}, timeout: Timeout(Duration(seconds: 30)));
}
void agreeToTerms() {
@ -60,6 +62,30 @@ void agreeToTerms() {
});
}
void visitAbout() {
test('[collection] visit about page', () async {
await driver.tap(find.byValueKey('appbar-leading-button'));
await driver.waitUntilNoTransientCallbacks();
await driver.tap(find.byValueKey('About-tile'));
await driver.waitUntilNoTransientCallbacks();
await pressDeviceBackButton();
});
}
void visitSettings() {
test('[collection] visit about page', () async {
await driver.tap(find.byValueKey('appbar-leading-button'));
await driver.waitUntilNoTransientCallbacks();
await driver.tap(find.byValueKey('Settings-tile'));
await driver.waitUntilNoTransientCallbacks();
await pressDeviceBackButton();
});
}
void sortCollection() {
test('[collection] sort', () async {
await driver.tap(find.byValueKey('appbar-menu-button'));
@ -92,8 +118,11 @@ void selectFirstAlbum() {
await driver.tap(find.byValueKey('Albums-tile'));
await driver.waitUntilNoTransientCallbacks();
// wait for collection loading
await driver.waitForCondition(NoPendingPlatformMessages());
await driver.tap(find.descendant(
of: find.byType('FilterGridPage'),
of: find.byValueKey('filter-grid-page'),
matching: find.byType('DecoratedFilterChip'),
firstMatchOnly: true,
));

View file

@ -42,3 +42,5 @@ Future<void> copyContent(String sourceDir, String targetDir) async {
Future<void> grantPermissions(String packageName, Iterable<String> permissions) async {
await Future.forEach(permissions, (permission) => runAdb(['shell', 'pm', 'grant', packageName, permission]));
}
Future<void> pressDeviceBackButton() => runAdb(['shell', 'input', 'keyevent', 'KEYCODE_BACK']);

View file

@ -1,6 +1,7 @@
Thanks for using Aves!
v1.2.8:
- pinch to scale albums, countries & tags
- SVG source viewer
- improved detailed metadata layout
v1.2.9:
- identify 360 photos/videos, GeoTIFF
- open panoramas (360 photos)
- open GImage/GAudio/GDepth media and thumbnails embedded in XMP
- improved large TIFF handling
Full changelog available on Github