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

View file

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

View file

@ -3,6 +3,21 @@ All notable changes to this project will be documented in this file.
## [Unreleased] ## [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 ## [v1.2.8] - 2020-11-27
### Added ### Added
- Albums / Countries / Tags: pinch to change tile size - 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 ## 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 animated images: GIF, WEBP
- support raw images: ARW, CR2, DNG, NEF, NRW, ORF, PEF, RAF, RW2, SRW
- support vector images: SVG - support vector images: SVG
- support videos: MP4, AVI, AVCHD & probably others - support videos: MP4, AVI, MKV, AVCHD & probably others
- search and filter by country, place, XMP tag, type (animated, raster, vector, video) - identify panoramas (aka photo spheres), 360° videos, GeoTIFF files
- search and filter by country, place, XMP tag, type (animated, raster, vector…)
- favorites - favorites
- statistics - statistics
- support Android API 24 ~ 30 (Nougat ~ R) - 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)) - 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 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 ## Test Devices

View file

@ -109,9 +109,6 @@ dependencies {
implementation 'com.github.deckerst:Android-TiffBitmapFactory:7efb450636' implementation 'com.github.deckerst:Android-TiffBitmapFactory:7efb450636'
implementation 'com.github.bumptech.glide:glide:4.11.0' 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 'androidx.annotation:annotation:1.1.0'
kapt 'com.github.bumptech.glide:compiler:4.11.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, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this))
MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(MetadataHandler(this)) MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(MetadataHandler(this))
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(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, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(this, args) }
StreamsChannel(messenger, ImageOpStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageOpStreamHandler(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) { when (call.method) {
"getAppIcon" -> GlobalScope.launch { getAppIcon(call, Coresult(result)) } "getAppIcon" -> GlobalScope.launch { getAppIcon(call, Coresult(result)) }
"getAppNames" -> GlobalScope.launch { getAppNames(Coresult(result)) } "getAppNames" -> GlobalScope.launch { getAppNames(Coresult(result)) }
"getEnv" -> result.success(System.getenv())
"edit" -> { "edit" -> {
val title = call.argument<String>("title") val title = call.argument<String>("title")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
@ -154,7 +153,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val intent = Intent(Intent.ACTION_EDIT) val intent = Intent(Intent.ACTION_EDIT)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) .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) return safeStartActivityChooser(title, intent)
} }
@ -163,7 +162,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val intent = Intent(Intent.ACTION_VIEW) val intent = Intent(Intent.ACTION_VIEW)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.setDataAndType(uri, mimeType) .setDataAndType(getShareableUri(uri), mimeType)
return safeStartActivityChooser(title, intent) return safeStartActivityChooser(title, intent)
} }
@ -179,7 +178,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val intent = Intent(Intent.ACTION_ATTACH_DATA) val intent = Intent(Intent.ACTION_ATTACH_DATA)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.setDataAndType(uri, mimeType) .setDataAndType(getShareableUri(uri), mimeType)
return safeStartActivityChooser(title, intent) return safeStartActivityChooser(title, intent)
} }
@ -187,15 +186,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val intent = Intent(Intent.ACTION_SEND) val intent = Intent(Intent.ACTION_SEND)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.setType(mimeType) .setType(mimeType)
when (uri.scheme?.toLowerCase(Locale.ROOT)) { .putExtra(Intent.EXTRA_STREAM, getShareableUri(uri))
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)
}
return safeStartActivityChooser(title, intent) return safeStartActivityChooser(title, intent)
} }
@ -252,6 +243,18 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
return false 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 { companion object {
private val LOG_TAG = createTag(AppAdapterHandler::class.java) private val LOG_TAG = createTag(AppAdapterHandler::class.java)
const val CHANNEL = "deckers.thibault/aves/app" 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.ImageProvider.ImageOpCallback
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
import deckers.thibault.aves.model.provider.MediaStoreImageProvider import deckers.thibault.aves.model.provider.MediaStoreImageProvider
import deckers.thibault.aves.utils.MimeTypes
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.MethodCallHandler
@ -95,15 +96,25 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
return return
} }
regionFetcher.fetch( 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, uri,
mimeType, mimeType,
sampleSize, sampleSize,
Rect(x, y, x + width, y + height), regionRect,
Size(imageWidth, imageHeight), Size(imageWidth, imageHeight),
result, result,
) )
} }
}
private suspend fun getImageEntry(call: MethodCall, result: MethodChannel.Result) { private suspend fun getImageEntry(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") // MIME type is optional val mimeType = call.argument<String>("mimeType") // MIME type is optional

View file

@ -1,14 +1,8 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context import android.content.Context
import android.database.Cursor
import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.Log import android.util.Log
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import com.adobe.internal.xmp.XMPException import com.adobe.internal.xmp.XMPException
@ -23,14 +17,16 @@ import com.drew.metadata.exif.ExifSubIFDDirectory
import com.drew.metadata.exif.GpsDirectory import com.drew.metadata.exif.GpsDirectory
import com.drew.metadata.file.FileTypeDirectory import com.drew.metadata.file.FileTypeDirectory
import com.drew.metadata.gif.GifAnimationDirectory 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.webp.WebpDirectory
import com.drew.metadata.xmp.XmpDirectory 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.describeAll
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDouble import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDouble
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeRational 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
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDateMillis import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDescription 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.getSafeInt
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeRational import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeRational
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString 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
import deckers.thibault.aves.metadata.XMP.getSafeDateMillis
import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText
import deckers.thibault.aves.metadata.XMP.isPanorama
import deckers.thibault.aves.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
import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isMultimedia import deckers.thibault.aves.utils.MimeTypes.isMultimedia
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.tiffExtensionPattern 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 io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.IOException import java.io.File
import java.util.* import java.util.*
import kotlin.math.roundToLong import kotlin.math.roundToLong
@ -70,14 +73,9 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
"getAllMetadata" -> GlobalScope.launch { getAllMetadata(call, Coresult(result)) } "getAllMetadata" -> GlobalScope.launch { getAllMetadata(call, Coresult(result)) }
"getCatalogMetadata" -> GlobalScope.launch { getCatalogMetadata(call, Coresult(result)) } "getCatalogMetadata" -> GlobalScope.launch { getCatalogMetadata(call, Coresult(result)) }
"getOverlayMetadata" -> GlobalScope.launch { getOverlayMetadata(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)) } "getEmbeddedPictures" -> GlobalScope.launch { getEmbeddedPictures(call, Coresult(result)) }
"getExifThumbnails" -> GlobalScope.launch { getExifThumbnails(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() else -> result.notImplemented()
} }
} }
@ -85,6 +83,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private fun getAllMetadata(call: MethodCall, result: MethodChannel.Result) { private fun getAllMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) { if (mimeType == null || uri == null) {
result.error("getAllMetadata-args", "failed because of missing arguments", null) result.error("getAllMetadata-args", "failed because of missing arguments", null)
return return
@ -96,7 +95,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
if (isSupportedByMetadataExtractor(mimeType)) { if (isSupportedByMetadataExtractor(mimeType)) {
try { try {
StorageUtils.openInputStream(context, uri)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input) val metadata = ImageMetadataReader.readMetadata(input)
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java) foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java) foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java)
@ -111,22 +110,36 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
metadataMap[dirName] = dirMap metadataMap[dirName] = dirMap
// tags // tags
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) }) dirMap.putAll(dir.tags.map { Pair(it.tagName, it.description) })
}
if (dir is XmpDirectory) { if (dir is XmpDirectory) {
try { try {
val xmpMeta = dir.xmpMeta.apply { sort() } for (prop in dir.xmpMeta) {
for (prop in xmpMeta) {
if (prop is XMPPropertyInfo) { if (prop is XMPPropertyInfo) {
val path = prop.path val path = prop.path
val value = prop.value if (path?.isNotEmpty() == true) {
if (path?.isNotEmpty() == true && value?.isNotEmpty() == true) { val value = if (XMP.isDataPath(path)) "[skipped]" else prop.value
if (value?.isNotEmpty() == true) {
dirMap[path] = value dirMap[path] = value
} }
} }
} }
}
} catch (e: XMPException) { } catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e) 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 // fallback to read EXIF via ExifInterface
try { try {
StorageUtils.openInputStream(context, uri)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val exif = ExifInterface(input) val exif = ExifInterface(input)
val allTags = describeAll(exif).toMutableMap() val allTags = describeAll(exif).toMutableMap()
if (foundXmp) { if (foundXmp) {
@ -187,32 +200,48 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
return dirMap 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) { private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val path = call.argument<String>("path") val path = call.argument<String>("path")
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) { if (mimeType == null || uri == null) {
result.error("getCatalogMetadata-args", "failed because of missing arguments", null) result.error("getCatalogMetadata-args", "failed because of missing arguments", null)
return return
} }
val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType, path)) val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType, path, sizeBytes))
if (isVideo(mimeType)) { if (isMultimedia(mimeType)) {
metadataMap.putAll(getVideoCatalogMetadataByMediaMetadataRetriever(uri)) metadataMap.putAll(getMultimediaCatalogMetadataByMediaMetadataRetriever(uri))
} }
// report success even when empty // report success even when empty
result.success(metadataMap) 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>() val metadataMap = HashMap<String, Any>()
var flags = 0
var foundExif = false var foundExif = false
if (isSupportedByMetadataExtractor(mimeType)) { if (isSupportedByMetadataExtractor(mimeType)) {
try { try {
StorageUtils.openInputStream(context, uri)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input) val metadata = ImageMetadataReader.readMetadata(input)
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java) foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
@ -247,7 +276,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
} }
dir.getSafeInt(ExifIFD0Directory.TAG_ORIENTATION) { dir.getSafeInt(ExifIFD0Directory.TAG_ORIENTATION) {
val orientation = it val orientation = it
metadataMap[KEY_IS_FLIPPED] = isFlippedForExifCode(orientation) if (isFlippedForExifCode(orientation)) flags = flags or MASK_IS_FLIPPED
metadataMap[KEY_ROTATION_DEGREES] = getRotationDegreesForExifCode(orientation) metadataMap[KEY_ROTATION_DEGREES] = getRotationDegreesForExifCode(orientation)
} }
} }
@ -268,29 +297,59 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
if (xmpMeta.doesPropertyExist(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME)) { if (xmpMeta.doesPropertyExist(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME)) {
val count = xmpMeta.countArrayItems(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 } 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)) { 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) { } catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e) 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) { when (mimeType) {
MimeTypes.GIF -> { 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 -> { MimeTypes.WEBP -> {
for (dir in metadata.getDirectoriesOfType(WebpDirectory::class.java)) { 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) { } catch (e: Exception) {
@ -300,17 +359,17 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
} }
} }
if (!foundExif) { if (!foundExif && isSupportedByExifInterface(mimeType)) {
// fallback to read EXIF via ExifInterface // fallback to read EXIF via ExifInterface
try { try {
StorageUtils.openInputStream(context, uri)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val exif = ExifInterface(input) val exif = ExifInterface(input)
exif.getSafeDateMillis(ExifInterface.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it } exif.getSafeDateMillis(ExifInterface.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it }
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
exif.getSafeDateMillis(ExifInterface.TAG_DATETIME) { metadataMap[KEY_DATE_MILLIS] = it } exif.getSafeDateMillis(ExifInterface.TAG_DATETIME) { metadataMap[KEY_DATE_MILLIS] = it }
} }
exif.getSafeInt(ExifInterface.TAG_ORIENTATION, acceptZero = false) { 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 metadataMap[KEY_ROTATION_DEGREES] = exif.rotationDegrees
} }
val latLong = exif.latLong val latLong = exif.latLong
@ -325,29 +384,33 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
Log.w(LOG_TAG, "failed to get metadata by ExifInterface for uri=$uri", e) Log.w(LOG_TAG, "failed to get metadata by ExifInterface for uri=$uri", e)
} }
} }
metadataMap[KEY_FLAGS] = flags
return metadataMap return metadataMap
} }
private fun getVideoCatalogMetadataByMediaMetadataRetriever(uri: Uri): Map<String, Any> { private fun getMultimediaCatalogMetadataByMediaMetadataRetriever(uri: Uri): Map<String, Any> {
val metadataMap = HashMap<String, Any>() val metadataMap = HashMap<String, Any>()
val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return metadataMap val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return metadataMap
try { try {
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { metadataMap[KEY_ROTATION_DEGREES] = it } retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { metadataMap[KEY_ROTATION_DEGREES] = it }
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { metadataMap[KEY_DATE_MILLIS] = it } retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { metadataMap[KEY_DATE_MILLIS] = it }
}
if (!metadataMap.containsKey(KEY_LATITUDE)) {
val locationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION) val locationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION)
if (locationString != null) { if (locationString != null) {
val matcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString) val matcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString)
if (matcher.find() && matcher.groupCount() >= 2) { if (matcher.find() && matcher.groupCount() >= 2) {
// keep `0.0` as `0.0`, not `0` val latitude = matcher.group(1)?.toDoubleOrNull()
val latitude = matcher.group(1)?.toDoubleOrNull() ?: 0.0 val longitude = matcher.group(2)?.toDoubleOrNull()
val longitude = matcher.group(2)?.toDoubleOrNull() ?: 0.0 if (latitude != null && longitude != null) {
if (latitude != 0.0 || longitude != 0.0) {
metadataMap[KEY_LATITUDE] = latitude metadataMap[KEY_LATITUDE] = latitude
metadataMap[KEY_LONGITUDE] = longitude metadataMap[KEY_LONGITUDE] = longitude
} }
} }
} }
}
} catch (e: Exception) { } catch (e: Exception) {
Log.w(LOG_TAG, "failed to get catalog metadata by MediaMetadataRetriever for uri=$uri", e) Log.w(LOG_TAG, "failed to get catalog metadata by MediaMetadataRetriever for uri=$uri", e)
} finally { } finally {
@ -360,6 +423,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) { private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) { if (mimeType == null || uri == null) {
result.error("getOverlayMetadata-args", "failed because of missing arguments", null) result.error("getOverlayMetadata-args", "failed because of missing arguments", null)
return return
@ -387,7 +451,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
var foundExif = false var foundExif = false
if (isSupportedByMetadataExtractor(mimeType)) { if (isSupportedByMetadataExtractor(mimeType)) {
try { try {
StorageUtils.openInputStream(context, uri)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input) val metadata = ImageMetadataReader.readMetadata(input)
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) { for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
foundExif = true 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 // fallback to read EXIF via ExifInterface
try { try {
StorageUtils.openInputStream(context, uri)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val exif = ExifInterface(input) val exif = ExifInterface(input)
exif.getSafeDouble(ExifInterface.TAG_F_NUMBER) { metadataMap[KEY_APERTURE] = it } exif.getSafeDouble(ExifInterface.TAG_F_NUMBER) { metadataMap[KEY_APERTURE] = it }
exif.getSafeRational(ExifInterface.TAG_EXPOSURE_TIME, saveExposureTime) exif.getSafeRational(ExifInterface.TAG_EXPOSURE_TIME, saveExposureTime)
@ -424,176 +488,6 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
result.success(metadataMap) 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) { private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) { if (uri == null) {
@ -617,15 +511,18 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
} }
private fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) { private fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } 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) result.error("getExifThumbnails-args", "failed because of missing arguments", null)
return return
} }
val thumbnails = ArrayList<ByteArray>() val thumbnails = ArrayList<ByteArray>()
if (isSupportedByExifInterface(mimeType)) {
try { try {
StorageUtils.openInputStream(context, uri)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val exif = ExifInterface(input) val exif = ExifInterface(input)
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
exif.thumbnailBitmap?.let { bitmap -> exif.thumbnailBitmap?.let { bitmap ->
@ -638,47 +535,85 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
// ExifInterface initialization can fail with a RuntimeException // ExifInterface initialization can fail with a RuntimeException
// caused by an internal MediaMetadataRetriever failure // caused by an internal MediaMetadataRetriever failure
} }
}
result.success(thumbnails) result.success(thumbnails)
} }
private fun getXmpThumbnails(call: MethodCall, result: MethodChannel.Result) { private fun extractXmpDataProp(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (mimeType == null || uri == null) { val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
result.error("getXmpThumbnails-args", "failed because of missing arguments", null) 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 return
} }
val thumbnails = ArrayList<ByteArray>()
if (isSupportedByMetadataExtractor(mimeType)) { if (isSupportedByMetadataExtractor(mimeType)) {
try { try {
StorageUtils.openInputStream(context, uri)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input) val metadata = ImageMetadataReader.readMetadata(input)
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) { // data can be large and stored in "Extended XMP",
val xmpMeta = dir.xmpMeta // which is returned as a second XMP directory
val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java)
try { try {
if (xmpMeta.doesPropertyExist(XMP.XMP_SCHEMA_NS, XMP.THUMBNAIL_PROP_NAME)) { val pathParts = dataPropPath.split('/')
val count = xmpMeta.countArrayItems(XMP.XMP_SCHEMA_NS, XMP.THUMBNAIL_PROP_NAME)
for (i in 1 until count + 1) { val embedBytes: ByteArray = if (pathParts.size == 1) {
val structName = "${XMP.THUMBNAIL_PROP_NAME}[$i]" val propName = pathParts[0]
val image = xmpMeta.getStructField(XMP.XMP_SCHEMA_NS, structName, XMP.IMG_SCHEMA_NS, XMP.THUMBNAIL_IMAGE_PROP_NAME) val propNs = XMP.namespaceForPropPath(propName)
if (image != null) { xmpDirs.map { it.xmpMeta.getPropertyBase64(propNs, propName) }.first { it != null }
thumbnails.add(XMPUtils.decodeBase64(image.value)) } 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) { } catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e) result.error("extractXmpDataProp-args", "failed to read XMP directory for uri=$uri prop=$dataPropPath", e.message)
} return
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(LOG_TAG, "failed to extract xmp thumbnail", e) Log.w(LOG_TAG, "failed to extract file from XMP", e)
} catch (e: NoClassDefFoundError) { } catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to extract xmp thumbnail", e) Log.w(LOG_TAG, "failed to extract file from XMP", e)
} }
} }
result.success(thumbnails) result.error("extractXmpDataProp-empty", "failed to extract file from XMP uri=$uri prop=$dataPropPath", null)
} }
companion object { companion object {
@ -688,14 +623,19 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
// catalog metadata // catalog metadata
private const val KEY_MIME_TYPE = "mimeType" private const val KEY_MIME_TYPE = "mimeType"
private const val KEY_DATE_MILLIS = "dateMillis" private const val KEY_DATE_MILLIS = "dateMillis"
private const val KEY_IS_ANIMATED = "isAnimated" private const val KEY_FLAGS = "flags"
private const val KEY_IS_FLIPPED = "isFlipped"
private const val KEY_ROTATION_DEGREES = "rotationDegrees" private const val KEY_ROTATION_DEGREES = "rotationDegrees"
private const val KEY_LATITUDE = "latitude" private const val KEY_LATITUDE = "latitude"
private const val KEY_LONGITUDE = "longitude" private const val KEY_LONGITUDE = "longitude"
private const val KEY_XMP_SUBJECTS = "xmpSubjects" private const val KEY_XMP_SUBJECTS = "xmpSubjects"
private const val KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription" private const val KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription"
private const val 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 // overlay metadata
private const val KEY_APERTURE = "aperture" private const val KEY_APERTURE = "aperture"
private const val KEY_EXPOSURE_TIME = "exposureTime" 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.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.signature.ObjectKey import com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.decoder.TiffThumbnail
import deckers.thibault.aves.decoder.VideoThumbnail import deckers.thibault.aves.decoder.VideoThumbnail
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.BitmapUtils.getBytes
@ -21,7 +22,6 @@ import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
class ThumbnailFetcher internal constructor( class ThumbnailFetcher internal constructor(
private val context: Context, private val context: Context,
@ -45,9 +45,7 @@ class ThumbnailFetcher internal constructor(
var exception: Exception? = null var exception: Exception? = null
try { try {
if (mimeType == MimeTypes.TIFF) { if (mimeType != MimeTypes.TIFF && (width == defaultSize || height == defaultSize) && !isFlipped) {
bitmap = getTiff()
} else if ((width == defaultSize || height == defaultSize) && !isFlipped) {
// Fetch low quality thumbnails when size is not specified. // Fetch low quality thumbnails when size is not specified.
// As of Android R, the Media Store content resolver may return a thumbnail // As of Android R, the Media Store content resolver may return a thumbnail
// that is automatically rotated according to EXIF orientation, but not flipped, // that is automatically rotated according to EXIF orientation, but not flipped,
@ -121,10 +119,11 @@ class ThumbnailFetcher internal constructor(
.load(VideoThumbnail(context, uri)) .load(VideoThumbnail(context, uri))
.submit(width, height) .submit(width, height)
} else { } else {
val model: Any = if (mimeType == MimeTypes.TIFF) TiffThumbnail(context, uri) else uri
Glide.with(context) Glide.with(context)
.asBitmap() .asBitmap()
.apply(options) .apply(options)
.load(uri) .load(model)
.submit(width, height) .submit(width, height)
} }
@ -138,31 +137,4 @@ class ThumbnailFetcher internal constructor(
Glide.with(context).clear(target) Glide.with(context).clear(target)
} }
} }
private fun getTiff(): Bitmap? {
// determine sample size
var sampleSize = 1
context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true
}
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
val imageWidth = options.outWidth
val imageHeight = options.outHeight
if (imageHeight > height || imageWidth > width) {
while (imageHeight / (sampleSize * 2) > height && imageWidth / (sampleSize * 2) > width) {
sampleSize *= 2
}
}
}
// decode
return context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = false
inSampleSize = sampleSize
}
return TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
}
}
} }

View file

@ -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) { private fun streamImageByGlide(uri: Uri, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) {
val target = Glide.with(activity) val target = Glide.with(activity)
.asBitmap() .asBitmap()
.apply(options) .apply(glideOptions)
.load(uri) .load(uri)
.submit() .submit()
try { try {
@ -118,7 +118,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
private fun streamVideoByGlide(uri: Uri) { private fun streamVideoByGlide(uri: Uri) {
val target = Glide.with(activity) val target = Glide.with(activity)
.asBitmap() .asBitmap()
.apply(options) .apply(glideOptions)
.load(VideoThumbnail(activity, uri)) .load(VideoThumbnail(activity, uri))
.submit() .submit()
try { 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 val resolver = activity.contentResolver
try { try {
var dirCount = 0 var dirCount = 0
@ -148,18 +148,17 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
} }
// TODO TLAD handle multipage TIFF // TODO TLAD handle multipage TIFF
if (dirCount > 0) { if (dirCount > page) {
val i = 0
resolver.openFileDescriptor(uri, "r")?.use { descriptor -> resolver.openFileDescriptor(uri, "r")?.use { descriptor ->
val options = TiffBitmapFactory.Options().apply { val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = false inJustDecodeBounds = false
inDirectoryNumber = i inDirectoryNumber = page
} }
val bitmap = TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options) val bitmap = TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
if (bitmap != null) { if (bitmap != null) {
success(bitmap.getBytes(canHaveAlpha = true, recycle = true)) success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
} else { } 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 const val bufferSize = 2 shl 17 // 256kB
// request a fresh image with the highest quality format // request a fresh image with the highest quality format
val options = RequestOptions() val glideOptions = RequestOptions()
.format(DecodeFormat.PREFER_ARGB_8888) .format(DecodeFormat.PREFER_ARGB_8888)
.diskCacheStrategy(DiskCacheStrategy.NONE) .diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true) .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) class VideoThumbnail(val context: Context, val uri: Uri)
internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, InputStream> { 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)) 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 package deckers.thibault.aves.metadata
import android.content.Context
import android.net.Uri
import androidx.exifinterface.media.ExifInterface 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.ParseException
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@ -13,6 +19,9 @@ object Metadata {
// "+51.3328-000.7053+113.474/" (Apple) // "+51.3328-000.7053+113.474/" (Apple)
val VIDEO_LOCATION_PATTERN: Pattern = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+).*") 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 // directory names, as shown when listing all metadata
const val DIR_GPS = "GPS" // from metadata-extractor const val DIR_GPS = "GPS" // from metadata-extractor
const val DIR_XMP = "XMP" // 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)? // yyyyMMddTHHmmss(.sss)?(Z|+/-hhmm)?
fun parseVideoMetadataDate(metadataDate: String?): Long { fun parseVideoMetadataDate(metadataDate: String?): Long {
var dateString = metadataDate ?: return 0 var dateString = metadataDate ?: return 0
@ -83,4 +94,42 @@ object Metadata {
} }
return dateMillis 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.lang.Rational
import com.drew.metadata.Directory import com.drew.metadata.Directory
import com.drew.metadata.exif.ExifIFD0Directory
import java.util.* import java.util.*
object MetadataExtractorHelper { object MetadataExtractorHelper {
@ -34,4 +35,25 @@ object MetadataExtractorHelper {
fun Directory.getSafeDateMillis(tag: Int, save: (value: Long) -> Unit) { fun Directory.getSafeDateMillis(tag: Int, save: (value: Long) -> Unit) {
if (this.containsTag(tag)) save(this.getDate(tag, null, TimeZone.getDefault()).time) 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.XMPException
import com.adobe.internal.xmp.XMPMeta import com.adobe.internal.xmp.XMPMeta
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import java.util.*
object XMP { object XMP {
private val LOG_TAG = LogUtils.createTag(XMP::class.java) private val LOG_TAG = LogUtils.createTag(XMP::class.java)
const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/" const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/"
const val PHOTOSHOP_SCHEMA_NS = "http://ns.adobe.com/photoshop/1.0/"
const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/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 SUBJECT_PROP_NAME = "dc:subject"
const val TITLE_PROP_NAME = "dc:title" const val TITLE_PROP_NAME = "dc:title"
const val DESCRIPTION_PROP_NAME = "dc:description" const val DESCRIPTION_PROP_NAME = "dc:description"
const val THUMBNAIL_PROP_NAME = "xmp:Thumbnails" const val PS_DATE_CREATED_PROP_NAME = "photoshop:DateCreated"
const val THUMBNAIL_IMAGE_PROP_NAME = "xmpGImg:image" const val CREATE_DATE_PROP_NAME = "xmp:CreateDate"
private const val GENERIC_LANG = "" private const val GENERIC_LANG = ""
private const val SPECIFIC_LANG = "en-US" 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 { try {
if (this.doesPropertyExist(DC_SCHEMA_NS, propName)) { if (doesPropertyExist(schema, propName)) {
val item = this.getLocalizedText(DC_SCHEMA_NS, propName, GENERIC_LANG, SPECIFIC_LANG) 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 // 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) { } 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.getSafeInt
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeLong import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeLong
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeString 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.Metadata.getRotationDegreesForExifCode
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
@ -27,6 +28,7 @@ import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeLong
import deckers.thibault.aves.model.provider.FieldMap import deckers.thibault.aves.model.provider.FieldMap
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import java.io.IOException import java.io.IOException
class SourceImageEntry { class SourceImageEntry {
@ -129,7 +131,10 @@ class SourceImageEntry {
fillByExifInterface(context) fillByExifInterface(context)
} }
if (!isSized) { if (!isSized) {
fillByBitmapDecode(context) when (sourceMimeType) {
MimeTypes.TIFF -> fillByTiffDecode(context)
else -> fillByBitmapDecode(context)
}
} }
return this return this
} }
@ -155,10 +160,12 @@ class SourceImageEntry {
// finds: width, height, orientation, date, duration // finds: width, height, orientation, date, duration
private fun fillByMetadataExtractor(context: Context) { private fun fillByMetadataExtractor(context: Context) {
// skip raw images because `metadata-extractor` reports the decoded dimensions instead of the raw dimensions // 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 { try {
StorageUtils.openInputStream(context, uri)?.use { input -> Metadata.openSafeInputStream(context, uri, sourceMimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input) val metadata = ImageMetadataReader.readMetadata(input)
// do not switch on specific mime types, as the reported mime type could be wrong // 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 // finds: width, height, orientation, date
private fun fillByExifInterface(context: Context) { private fun fillByExifInterface(context: Context) {
if (!ExifInterface.isSupportedMimeType(sourceMimeType)) return; if (!MimeTypes.isSupportedByExifInterface(sourceMimeType)) return
try { try {
StorageUtils.openInputStream(context, uri)?.use { input -> Metadata.openSafeInputStream(context, uri, sourceMimeType, sizeBytes)?.use { input ->
val exif = ExifInterface(input) val exif = ExifInterface(input)
foundExif = true foundExif = true
exif.getSafeInt(ExifInterface.TAG_IMAGE_WIDTH, acceptZero = false) { width = it } 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 { companion object {
// convenience method // convenience method
private fun toLong(o: Any?): Long? = when (o) { private fun toLong(o: Any?): Long? = when (o) {

View file

@ -1,5 +1,7 @@
package deckers.thibault.aves.utils package deckers.thibault.aves.utils
import androidx.exifinterface.media.ExifInterface
object MimeTypes { object MimeTypes {
private const val IMAGE = "image" private const val IMAGE = "image"
@ -65,12 +67,16 @@ object MimeTypes {
else -> false else -> false
} }
// as of metadata-extractor v2.14.0 // as of `metadata-extractor` v2.14.0
fun isSupportedByMetadataExtractor(mimeType: String) = when (mimeType) { fun isSupportedByMetadataExtractor(mimeType: String) = when (mimeType) {
WBMP, MP2T, WEBM -> false WBMP, MP2T, WEBM -> false
else -> true 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 // Glide automatically applies EXIF orientation when decoding images of known formats
// but we need to rotate the decoded bitmap for the other formats // but we need to rotate the decoded bitmap for the other formats
// maybe related to ExifInterface version used by Glide: // maybe related to ExifInterface version used by Glide:

View file

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

View file

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

View file

@ -10,6 +10,9 @@ class MimeFilter extends CollectionFilter {
// fake mime type // fake mime type
static const animated = 'aves/animated'; // subset of `image/gif` and `image/webp` 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; final String mime;
bool Function(ImageEntry) _filter; bool Function(ImageEntry) _filter;
@ -22,6 +25,18 @@ class MimeFilter extends CollectionFilter {
_filter = (entry) => entry.isAnimated; _filter = (entry) => entry.isAnimated;
_label = 'Animated'; _label = 'Animated';
_icon = AIcons.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('/*')) { } else if (lowMime.endsWith('/*')) {
lowMime = lowMime.substring(0, lowMime.length - 2); lowMime = lowMime.substring(0, lowMime.length - 2);
_filter = (entry) => entry.mimeType.startsWith(lowMime); _filter = (entry) => entry.mimeType.startsWith(lowMime);

View file

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

View file

@ -173,12 +173,12 @@ class ImageEntry {
bool get isSvg => mimeType == MimeTypes.svg; 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) // 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" // 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, // 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. // 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.heic,
MimeTypes.heif, MimeTypes.heif,
@ -196,14 +196,22 @@ class ImageEntry {
].contains(mimeType) && ].contains(mimeType) &&
!isAnimated; !isAnimated;
bool get canTile => _supportedByBitmapRegionDecoder || mimeType == MimeTypes.tiff;
bool get isRaw => MimeTypes.rawImages.contains(mimeType); 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 isCatalogued => _catalogMetadata != null;
bool get isAnimated => _catalogMetadata?.isAnimated ?? false; 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 canEdit => path != null;
bool get canPrint => !isVideo; bool get canPrint => !isVideo;
@ -299,7 +307,7 @@ class ImageEntry {
bool get isLocated => _addressDetails != null; 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 { String get geoUri {
if (!hasGps) return null; if (!hasGps) return null;
@ -373,12 +381,17 @@ class ImageEntry {
: call()); : call());
if (addresses != null && addresses.isNotEmpty) { if (addresses != null && addresses.isNotEmpty) {
final address = addresses.first; final address = addresses.first;
final cc = address.countryCode;
final cn = address.countryName;
final aa = address.adminArea;
addressDetails = AddressDetails( addressDetails = AddressDetails(
contentId: contentId, contentId: contentId,
countryCode: address.countryCode, countryCode: cc,
countryName: address.countryName, countryName: cn,
adminArea: address.adminArea, adminArea: aa,
locality: address.locality, // 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) { } catch (error, stackTrace) {

View file

@ -30,28 +30,41 @@ class DateMetadata {
class CatalogMetadata { class CatalogMetadata {
final int contentId, dateMillis; final int contentId, dateMillis;
final bool isAnimated; final bool isAnimated, isGeotiff, is360;
bool isFlipped; bool isFlipped;
int rotationDegrees; int rotationDegrees;
final String mimeType, xmpSubjects, xmpTitleDescription; final String mimeType, xmpSubjects, xmpTitleDescription;
final double latitude, longitude; double latitude, longitude;
Address address; 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({ CatalogMetadata({
this.contentId, this.contentId,
this.mimeType, this.mimeType,
this.dateMillis, this.dateMillis,
this.isAnimated, this.isAnimated,
this.isFlipped, this.isFlipped,
this.isGeotiff,
this.is360,
this.rotationDegrees, this.rotationDegrees,
this.xmpSubjects, this.xmpSubjects,
this.xmpTitleDescription, this.xmpTitleDescription,
double latitude, double latitude,
double longitude, double longitude,
}) }) {
// Geocoder throws an IllegalArgumentException when a coordinate has a funky values like 1.7056881853375E7 // 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, // We also exclude zero coordinates, taking into account precision errors (e.g. {5.952380952380953e-11,-2.7777777777777777e-10}),
longitude = longitude == null || longitude < -180.0 || longitude > 180.0 ? null : longitude; // 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({ CatalogMetadata copyWith({
@required int contentId, @required int contentId,
@ -62,6 +75,8 @@ class CatalogMetadata {
dateMillis: dateMillis, dateMillis: dateMillis,
isAnimated: isAnimated, isAnimated: isAnimated,
isFlipped: isFlipped, isFlipped: isFlipped,
isGeotiff: isGeotiff,
is360: is360,
rotationDegrees: rotationDegrees, rotationDegrees: rotationDegrees,
xmpSubjects: xmpSubjects, xmpSubjects: xmpSubjects,
xmpTitleDescription: xmpTitleDescription, xmpTitleDescription: xmpTitleDescription,
@ -70,15 +85,16 @@ class CatalogMetadata {
); );
} }
factory CatalogMetadata.fromMap(Map map, {bool boolAsInteger = false}) { factory CatalogMetadata.fromMap(Map map) {
final isAnimated = map['isAnimated'] ?? (boolAsInteger ? 0 : false); final flags = map['flags'] ?? 0;
final isFlipped = map['isFlipped'] ?? (boolAsInteger ? 0 : false);
return CatalogMetadata( return CatalogMetadata(
contentId: map['contentId'], contentId: map['contentId'],
mimeType: map['mimeType'], mimeType: map['mimeType'],
dateMillis: map['dateMillis'] ?? 0, dateMillis: map['dateMillis'] ?? 0,
isAnimated: boolAsInteger ? isAnimated != 0 : isAnimated, isAnimated: flags & _isAnimatedMask != 0,
isFlipped: boolAsInteger ? isFlipped != 0 : isFlipped, isFlipped: flags & _isFlippedMask != 0,
isGeotiff: flags & _isGeotiffMask != 0,
is360: flags & _is360Mask != 0,
// `rotationDegrees` should default to `sourceRotationDegrees`, not 0 // `rotationDegrees` should default to `sourceRotationDegrees`, not 0
rotationDegrees: map['rotationDegrees'], rotationDegrees: map['rotationDegrees'],
xmpSubjects: map['xmpSubjects'] ?? '', xmpSubjects: map['xmpSubjects'] ?? '',
@ -88,12 +104,11 @@ class CatalogMetadata {
); );
} }
Map<String, dynamic> toMap({bool boolAsInteger = false}) => { Map<String, dynamic> toMap() => {
'contentId': contentId, 'contentId': contentId,
'mimeType': mimeType, 'mimeType': mimeType,
'dateMillis': dateMillis, 'dateMillis': dateMillis,
'isAnimated': boolAsInteger ? (isAnimated ? 1 : 0) : isAnimated, 'flags': (isAnimated ? _isAnimatedMask : 0) | (isFlipped ? _isFlippedMask : 0) | (isGeotiff ? _isGeotiffMask : 0) | (is360 ? _is360Mask : 0),
'isFlipped': boolAsInteger ? (isFlipped ? 1 : 0) : isFlipped,
'rotationDegrees': rotationDegrees, 'rotationDegrees': rotationDegrees,
'xmpSubjects': xmpSubjects, 'xmpSubjects': xmpSubjects,
'xmpTitleDescription': xmpTitleDescription, 'xmpTitleDescription': xmpTitleDescription,
@ -103,7 +118,7 @@ class CatalogMetadata {
@override @override
String toString() { 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_entry.dart';
import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/metadata_db_upgrade.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
@ -48,8 +49,7 @@ class MetadataDb {
'contentId INTEGER PRIMARY KEY' 'contentId INTEGER PRIMARY KEY'
', mimeType TEXT' ', mimeType TEXT'
', dateMillis INTEGER' ', dateMillis INTEGER'
', isAnimated INTEGER' ', flags INTEGER'
', isFlipped INTEGER'
', rotationDegrees INTEGER' ', rotationDegrees INTEGER'
', xmpSubjects TEXT' ', xmpSubjects TEXT'
', xmpTitleDescription TEXT' ', xmpTitleDescription TEXT'
@ -69,65 +69,8 @@ class MetadataDb {
', path TEXT' ', path TEXT'
')'); ')');
}, },
onUpgrade: (db, oldVersion, newVersion) async { onUpgrade: MetadataDbUpgrader.upgradeDb,
// warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported version: 3,
// 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,
); );
} }
@ -238,7 +181,7 @@ class MetadataDb {
// final stopwatch = Stopwatch()..start(); // final stopwatch = Stopwatch()..start();
final db = await _database; final db = await _database;
final maps = await db.query(metadataTable); 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'); // debugPrint('$runtimeType loadMetadataEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries');
return metadataEntries; return metadataEntries;
} }
@ -246,11 +189,15 @@ class MetadataDb {
Future<void> saveMetadata(Iterable<CatalogMetadata> metadataEntries) async { Future<void> saveMetadata(Iterable<CatalogMetadata> metadataEntries) async {
if (metadataEntries == null || metadataEntries.isEmpty) return; if (metadataEntries == null || metadataEntries.isEmpty) return;
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
try {
final db = await _database; final db = await _database;
final batch = db.batch(); final batch = db.batch();
metadataEntries.where((metadata) => metadata != null).forEach((metadata) => _batchInsertMetadata(batch, metadata)); metadataEntries.where((metadata) => metadata != null).forEach((metadata) => _batchInsertMetadata(batch, metadata));
await batch.commit(noResult: true); await batch.commit(noResult: true);
debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries'); 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 { Future<void> updateMetadataId(int oldId, CatalogMetadata metadata) async {
@ -273,7 +220,7 @@ class MetadataDb {
} }
batch.insert( batch.insert(
metadataTable, metadataTable,
metadata.toMap(boolAsInteger: true), metadata.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace, 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'; import 'package:flutter/painting.dart';
class BrandColors { class BrandColors {
static const Color adobeAfterEffects = Color(0xFF9A9AFF);
static const Color adobeIllustrator = Color(0xFFFF9B00); static const Color adobeIllustrator = Color(0xFFFF9B00);
static const Color adobePhotoshop = Color(0xFF2DAAFF); static const Color adobePhotoshop = Color(0xFF2DAAFF);
static const Color android = Color(0xFF3DDC84); static const Color android = Color(0xFF3DDC84);
@ -9,6 +10,8 @@ class BrandColors {
static Color get(String text) { static Color get(String text) {
if (text != null) { if (text != null) {
switch (text.toLowerCase()) { switch (text.toLowerCase()) {
case 'after effects':
return adobeAfterEffects;
case 'illustrator': case 'illustrator':
return adobeIllustrator; return adobeIllustrator;
case 'photoshop': 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 { class MimeTypes {
static const String anyImage = 'image/*'; static const anyImage = 'image/*';
static const String gif = 'image/gif'; static const gif = 'image/gif';
static const String heic = 'image/heic'; static const heic = 'image/heic';
static const String heif = 'image/heif'; static const heif = 'image/heif';
static const String jpeg = 'image/jpeg'; static const jpeg = 'image/jpeg';
static const String png = 'image/png'; static const png = 'image/png';
static const String svg = 'image/svg+xml'; static const svg = 'image/svg+xml';
static const String webp = 'image/webp'; static const webp = 'image/webp';
static const String tiff = 'image/tiff'; static const tiff = 'image/tiff';
static const String psd = 'image/vnd.adobe.photoshop'; static const psd = 'image/vnd.adobe.photoshop';
static const String arw = 'image/x-sony-arw'; static const arw = 'image/x-sony-arw';
static const String cr2 = 'image/x-canon-cr2'; static const cr2 = 'image/x-canon-cr2';
static const String crw = 'image/x-canon-crw'; static const crw = 'image/x-canon-crw';
static const String dcr = 'image/x-kodak-dcr'; static const dcr = 'image/x-kodak-dcr';
static const String dng = 'image/x-adobe-dng'; static const dng = 'image/x-adobe-dng';
static const String erf = 'image/x-epson-erf'; static const erf = 'image/x-epson-erf';
static const String k25 = 'image/x-kodak-k25'; static const k25 = 'image/x-kodak-k25';
static const String kdc = 'image/x-kodak-kdc'; static const kdc = 'image/x-kodak-kdc';
static const String mrw = 'image/x-minolta-mrw'; static const mrw = 'image/x-minolta-mrw';
static const String nef = 'image/x-nikon-nef'; static const nef = 'image/x-nikon-nef';
static const String nrw = 'image/x-nikon-nrw'; static const nrw = 'image/x-nikon-nrw';
static const String orf = 'image/x-olympus-orf'; static const orf = 'image/x-olympus-orf';
static const String pef = 'image/x-pentax-pef'; static const pef = 'image/x-pentax-pef';
static const String raf = 'image/x-fuji-raf'; static const raf = 'image/x-fuji-raf';
static const String raw = 'image/x-panasonic-raw'; static const raw = 'image/x-panasonic-raw';
static const String rw2 = 'image/x-panasonic-rw2'; static const rw2 = 'image/x-panasonic-rw2';
static const String sr2 = 'image/x-sony-sr2'; static const sr2 = 'image/x-sony-sr2';
static const String srf = 'image/x-sony-srf'; static const srf = 'image/x-sony-srf';
static const String srw = 'image/x-samsung-srw'; static const srw = 'image/x-samsung-srw';
static const String x3f = 'image/x-sigma-x3f'; static const x3f = 'image/x-sigma-x3f';
static const String anyVideo = 'video/*'; static const anyVideo = 'video/*';
static const String avi = 'video/avi'; static const avi = 'video/avi';
static const String mp2t = 'video/mp2t'; // .m2ts static const mp2t = 'video/mp2t'; // .m2ts
static const String mp4 = 'video/mp4'; static const mp4 = 'video/mp4';
// groups // groups
static const List<String> rawImages = [arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f]; static const List<String> 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 { class XMP {
static const namespaceSeparator = ':'; static const propNamespaceSeparator = ':';
static const structFieldSeparator = '/'; static const structFieldSeparator = '/';
// cf https://exiftool.org/TagNames/XMP.html // cf https://exiftool.org/TagNames/XMP.html
static const Map<String, String> namespaces = { static const Map<String, String> namespaces = {
'aux': 'Auxiliary Exif', 'adsml-at': 'AdsML',
'aux': 'Exif Aux',
'avm': 'Astronomy Visualization',
'Camera': 'Camera', 'Camera': 'Camera',
'creatorAtom': 'After Effects',
'crs': 'Camera Raw Settings', 'crs': 'Camera Raw Settings',
'dc': 'Dublin Core', 'dc': 'Dublin Core',
'exif': 'Exif', 'drone-dji': 'DJI Drone',
'exifEX': 'Exif Ex',
'GettyImagesGIFT': 'Getty Images',
'GIMP': 'GIMP', 'GIMP': 'GIMP',
'GFocus': 'Google Focus',
'GPano': 'Google Panorama',
'illustrator': 'Illustrator', 'illustrator': 'Illustrator',
'Iptc4xmpCore': 'IPTC Core',
'lr': 'Lightroom', 'lr': 'Lightroom',
'MicrosoftPhoto': 'Microsoft Photo', 'MicrosoftPhoto': 'Microsoft Photo',
'panorama': 'Panorama', 'panorama': 'Panorama',
'pdf': 'PDF', 'pdf': 'PDF',
'pdfx': 'PDF/X', 'pdfx': 'PDF/X',
'PanoStudioXMP': 'PanoramaStudio',
'photomechanic': 'Photo Mechanic', 'photomechanic': 'Photo Mechanic',
'photoshop': 'Photoshop', 'plus': 'PLUS',
'tiff': 'TIFF', 'pmtm': 'Photomatix',
'xmp': 'Basic',
'xmpBJ': 'Basic Job Ticket', 'xmpBJ': 'Basic Job Ticket',
'xmpDM': 'Dynamic Media', 'xmpDM': 'Dynamic Media',
'xmpMM': 'Media Management',
'xmpRights': 'Rights Management', 'xmpRights': 'Rights Management',
'xmpTPg': 'Paged-Text', 'xmpTPg': 'Paged-Text',
}; };

View file

@ -31,16 +31,6 @@ class AndroidAppService {
return null; 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 { static Future<bool> edit(String uri, String mimeType) async {
try { try {
return await platform.invokeMethod('edit', <String, dynamic>{ return await platform.invokeMethod('edit', <String, dynamic>{
@ -91,7 +81,7 @@ class AndroidAppService {
return false; 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 // loosen mime type to a generic one, so we can share with badly defined apps
// e.g. Google Lens declares receiving "image/jpeg" only, but it can actually handle more formats // e.g. Google Lens declares receiving "image/jpeg" only, but it can actually handle more formats
final urisByMimeType = groupBy<ImageEntry, String>(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList())); final urisByMimeType = groupBy<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, 'urisByMimeType': urisByMimeType,
}); });
} on PlatformException catch (e) { } 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; 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>{ final result = await platform.invokeMethod('getAllMetadata', <String, dynamic>{
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,
'uri': entry.uri, 'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
}); });
return result as Map; return result as Map;
} on PlatformException catch (e) { } on PlatformException catch (e) {
@ -44,6 +45,7 @@ class MetadataService {
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,
'uri': entry.uri, 'uri': entry.uri,
'path': entry.path, 'path': entry.path,
'sizeBytes': entry.sizeBytes,
}) as Map; }) as Map;
result['contentId'] = entry.contentId; result['contentId'] = entry.contentId;
return CatalogMetadata.fromMap(result); return CatalogMetadata.fromMap(result);
@ -69,6 +71,7 @@ class MetadataService {
final result = await platform.invokeMethod('getOverlayMetadata', <String, dynamic>{ final result = await platform.invokeMethod('getOverlayMetadata', <String, dynamic>{
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,
'uri': entry.uri, 'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
}) as Map; }) as Map;
return OverlayMetadata.fromMap(result); return OverlayMetadata.fromMap(result);
} on PlatformException catch (e) { } on PlatformException catch (e) {
@ -77,72 +80,6 @@ class MetadataService {
return null; 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 { static Future<List<Uint8List>> getEmbeddedPictures(String uri) async {
try { try {
final result = await platform.invokeMethod('getEmbeddedPictures', <String, dynamic>{ final result = await platform.invokeMethod('getEmbeddedPictures', <String, dynamic>{
@ -155,10 +92,12 @@ class MetadataService {
return []; return [];
} }
static Future<List<Uint8List>> getExifThumbnails(String uri) async { static Future<List<Uint8List>> getExifThumbnails(ImageEntry entry) async {
try { try {
final result = await platform.invokeMethod('getExifThumbnails', <String, dynamic>{ 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>(); return (result as List).cast<Uint8List>();
} on PlatformException catch (e) { } on PlatformException catch (e) {
@ -167,16 +106,19 @@ class MetadataService {
return []; return [];
} }
static Future<List<Uint8List>> getXmpThumbnails(ImageEntry entry) async { static Future<Map> extractXmpDataProp(ImageEntry entry, String propPath, String propMimeType) async {
try { try {
final result = await platform.invokeMethod('getXmpThumbnails', <String, dynamic>{ final result = await platform.invokeMethod('extractXmpDataProp', <String, dynamic>{
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,
'uri': entry.uri, 'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
'propPath': propPath,
'propMimeType': propMimeType,
}); });
return (result as List).cast<Uint8List>(); return result;
} on PlatformException catch (e) { } 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 // info
static const mapStyleSwitchAnimation = Duration(milliseconds: 300); static const mapStyleSwitchAnimation = Duration(milliseconds: 300);
static const xmpStructArrayCardTransition = Duration(milliseconds: 300);
// delays & refresh intervals // delays & refresh intervals
static const opToastDisplay = Duration(seconds: 2); static const opToastDisplay = Duration(seconds: 2);

View file

@ -26,11 +26,9 @@ class AIcons {
// actions // actions
static const IconData addShortcut = Icons.add_to_home_screen_outlined; static const IconData addShortcut = Icons.add_to_home_screen_outlined;
static const IconData clear = Icons.clear_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 createAlbum = Icons.add_circle_outline;
static const IconData debug = Icons.whatshot_outlined; static const IconData debug = Icons.whatshot_outlined;
static const IconData delete = Icons.delete_outlined; static const IconData delete = Icons.delete_outlined;
static const IconData expand = Icons.expand_more_outlined;
static const IconData flip = Icons.flip_outlined; static const IconData flip = Icons.flip_outlined;
static const IconData favourite = Icons.favorite_border; static const IconData favourite = Icons.favorite_border;
static const IconData favouriteActive = Icons.favorite; static const IconData favouriteActive = Icons.favorite;
@ -52,6 +50,10 @@ class AIcons {
static const IconData stats = Icons.pie_chart_outlined; static const IconData stats = Icons.pie_chart_outlined;
static const IconData zoomIn = Icons.add_outlined; static const IconData zoomIn = Icons.add_outlined;
static const IconData zoomOut = Icons.remove_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 // albums
static const IconData album = Icons.photo_album_outlined; static const IconData album = Icons.photo_album_outlined;
@ -61,7 +63,9 @@ class AIcons {
// thumbnail overlay // thumbnail overlay
static const IconData animated = Icons.slideshow; static const IconData animated = Icons.slideshow;
static const IconData geo = Icons.language_outlined;
static const IconData play = Icons.play_circle_outline; 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 selected = Icons.check_circle_outline;
static const IconData unselected = Icons.radio_button_unchecked; static const IconData unselected = Icons.radio_button_unchecked;
} }

View file

@ -18,8 +18,8 @@ class Constants {
offset: Offset(0.5, 1.0), offset: Offset(0.5, 1.0),
); );
static const String overlayUnknown = ''; // em dash static const overlayUnknown = ''; // em dash
static const String infoUnknown = 'unknown'; static const infoUnknown = 'unknown';
static final pointNemo = LatLng(-48.876667, -123.393333); 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', licenseUrl: 'https://github.com/flutter/packages/blob/master/packages/palette_generator/LICENSE',
sourceUrl: 'https://github.com/flutter/packages/tree/master/packages/palette_generator', 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( Dependency(
name: 'PDF for Dart and Flutter', name: 'PDF for Dart and Flutter',
license: 'Apache 2.0', license: 'Apache 2.0',
@ -287,6 +293,12 @@ class Constants {
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher/LICENSE', 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', 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() { Widget _buildHeader() {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Padding( Padding(
padding: EdgeInsetsDirectional.only(start: 8), padding: EdgeInsetsDirectional.only(start: 8),

View file

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

View file

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

View file

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

View file

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

View file

@ -31,9 +31,9 @@ class ThumbnailCollection extends StatelessWidget {
final ValueNotifier<bool> _isScrollingNotifier = ValueNotifier(false); final ValueNotifier<bool> _isScrollingNotifier = ValueNotifier(false);
final GlobalKey _scrollableKey = GlobalKey(); final GlobalKey _scrollableKey = GlobalKey();
static const columnCountMin = 2;
static const columnCountDefault = 4; static const columnCountDefault = 4;
static const extentMin = 46.0; static const extentMin = 46.0;
static const spacing = 0.0;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -47,11 +47,10 @@ class ThumbnailCollection extends StatelessWidget {
final tileExtentManager = TileExtentManager( final tileExtentManager = TileExtentManager(
settingsRouteKey: context.currentRouteName, settingsRouteKey: context.currentRouteName,
columnCountMin: columnCountMin, extentNotifier: _tileExtentNotifier,
columnCountDefault: columnCountDefault, columnCountDefault: columnCountDefault,
extentMin: extentMin, extentMin: extentMin,
extentNotifier: _tileExtentNotifier, spacing: spacing,
spacing: 0,
)..applyTileExtent(viewportSize: viewportSize); )..applyTileExtent(viewportSize: viewportSize);
final cacheExtent = tileExtentManager.getEffectiveExtentMax(viewportSize) * 2; final cacheExtent = tileExtentManager.getEffectiveExtentMax(viewportSize) * 2;
@ -77,7 +76,18 @@ class ThumbnailCollection extends StatelessWidget {
scrollableKey: _scrollableKey, scrollableKey: _scrollableKey,
appBarHeightNotifier: _appBarHeightNotifier, appBarHeightNotifier: _appBarHeightNotifier,
viewportSize: viewportSize, 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( scaledBuilder: (entry, extent) => DecoratedThumbnail(
entry: entry, entry: entry,
extent: extent, extent: extent,
@ -98,6 +108,7 @@ class ThumbnailCollection extends StatelessWidget {
collection: collection, collection: collection,
scrollableWidth: viewportSize.width, scrollableWidth: viewportSize.width,
tileExtent: tileExtent, tileExtent: tileExtent,
columnCount: tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent),
thumbnailBuilder: (entry) => GridThumbnail( thumbnailBuilder: (entry) => GridThumbnail(
key: ValueKey(entry.contentId), key: ValueKey(entry.contentId),
collection: collection, collection: collection,

View file

@ -56,7 +56,7 @@ mixin FeedbackMixin {
stream: opStream, stream: opStream,
builder: (context, snapshot) { builder: (context, snapshot) {
Widget child = SizedBox.shrink(); Widget child = SizedBox.shrink();
if (!snapshot.hasError && snapshot.connectionState == ConnectionState.active) { if (!snapshot.hasError) {
final percent = processed.length.toDouble() / selection.length; final percent = processed.length.toDouble() / selection.length;
child = CircularPercentIndicator( child = CircularPercentIndicator(
percent: percent, 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'; import 'package:flutter/material.dart';
final _filter = ImageFilter.blur(sigmaX: 4, sigmaY: 4);
class BlurredRect extends StatelessWidget { class BlurredRect extends StatelessWidget {
final Widget child; final Widget child;
@ -11,7 +13,7 @@ class BlurredRect extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ClipRect( return ClipRect(
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4), filter: _filter,
child: child, child: child,
), ),
); );
@ -29,7 +31,7 @@ class BlurredRRect extends StatelessWidget {
return ClipRRect( return ClipRRect(
borderRadius: BorderRadius.circular(borderRadius), borderRadius: BorderRadius.circular(borderRadius),
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4), filter: _filter,
child: child, child: child,
), ),
); );
@ -45,7 +47,7 @@ class BlurredOval extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ClipOval( return ClipOval(
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4), filter: _filter,
child: child, child: child,
), ),
); );

View file

@ -1,11 +1,18 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class AvesCircleBorder { class AvesCircleBorder {
static BoxBorder build(BuildContext context) { static const borderColor = Colors.white30;
final subPixel = MediaQuery.of(context).devicePixelRatio > 2;
return Border.all( static double _borderWidth(BuildContext context) => MediaQuery.of(context).devicePixelRatio > 2 ? 0.5 : 1.0;
color: Colors.white30,
width: subPixel ? 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: HighlightTitle(
title, title,
color: color, color: color,
fontSize: 18,
enabled: enabled, enabled: enabled,
), ),
expandable: enabled, expandable: enabled,

View file

@ -23,9 +23,10 @@ class VideoIcon extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return OverlayIcon( return OverlayIcon(
icon: AIcons.play, icon: entry.is360 ? AIcons.threesixty : AIcons.play,
size: iconSize, size: iconSize,
text: showDuration ? entry.durationText : null, 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 { class GpsIcon extends StatelessWidget {
final double iconSize; final double iconSize;

View file

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

View file

@ -2,7 +2,6 @@ import 'dart:math';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:aves/theme/durations.dart'; 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/providers/media_query_data_provider.dart';
import 'package:aves/widgets/common/tile_extent_manager.dart'; import 'package:aves/widgets/common/tile_extent_manager.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -20,7 +19,7 @@ class GridScaleGestureDetector<T> extends StatefulWidget {
final GlobalKey scrollableKey; final GlobalKey scrollableKey;
final ValueNotifier<double> appBarHeightNotifier; final ValueNotifier<double> appBarHeightNotifier;
final Size viewportSize; 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 Widget Function(T item, double extent) scaledBuilder;
final Rect Function(BuildContext context, T item) getScaledItemTileRect; final Rect Function(BuildContext context, T item) getScaledItemTileRect;
final void Function(T item) onScaled; final void Function(T item) onScaled;
@ -31,7 +30,7 @@ class GridScaleGestureDetector<T> extends StatefulWidget {
@required this.scrollableKey, @required this.scrollableKey,
@required this.appBarHeightNotifier, @required this.appBarHeightNotifier,
@required this.viewportSize, @required this.viewportSize,
@required this.showScaledGrid, this.gridBuilder,
@required this.scaledBuilder, @required this.scaledBuilder,
@required this.getScaledItemTileRect, @required this.getScaledItemTileRect,
@required this.onScaled, @required this.onScaled,
@ -56,10 +55,6 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
onHorizontalDragStart: (details) {
// if `onHorizontalDragStart` callback is not defined,
// horizontal drag gestures are interpreted as scaling
},
onScaleStart: (details) { onScaleStart: (details) {
// the gesture detector wrongly detects a new scaling gesture // the gesture detector wrongly detects a new scaling gesture
// when scaling ends and we apply the new extent, so we prevent this // 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: (context) => ScaleOverlay(
builder: (extent) => widget.scaledBuilder(_metadata.item, extent), builder: (extent) => widget.scaledBuilder(_metadata.item, extent),
center: thumbnailCenter, center: thumbnailCenter,
gridWidth: gridWidth, viewportWidth: gridWidth,
spacing: tileExtentManager.spacing, gridBuilder: widget.gridBuilder,
scaledExtentNotifier: _scaledExtentNotifier, scaledExtentNotifier: _scaledExtentNotifier,
showScaledGrid: widget.showScaledGrid,
), ),
); );
Overlay.of(scrollableContext).insert(_overlayEntry); Overlay.of(scrollableContext).insert(_overlayEntry);
@ -133,7 +127,16 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
}); });
} }
}, },
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, child: widget.child,
),
); );
} }
@ -157,18 +160,16 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
class ScaleOverlay extends StatefulWidget { class ScaleOverlay extends StatefulWidget {
final Widget Function(double extent) builder; final Widget Function(double extent) builder;
final Offset center; final Offset center;
final double gridWidth; final double viewportWidth;
final double spacing;
final ValueNotifier<double> scaledExtentNotifier; final ValueNotifier<double> scaledExtentNotifier;
final bool showScaledGrid; final Widget Function(Offset center, double extent, Widget child) gridBuilder;
const ScaleOverlay({ const ScaleOverlay({
@required this.builder, @required this.builder,
@required this.center, @required this.center,
@required this.gridWidth, @required this.viewportWidth,
@required this.spacing,
@required this.scaledExtentNotifier, @required this.scaledExtentNotifier,
@required this.showScaledGrid, this.gridBuilder,
}); });
@override @override
@ -180,7 +181,7 @@ class _ScaleOverlayState extends State<ScaleOverlay> {
Offset get center => widget.center; Offset get center => widget.center;
double get gridWidth => widget.gridWidth; double get gridWidth => widget.viewportWidth;
@override @override
void initState() { void initState() {
@ -241,16 +242,7 @@ class _ScaleOverlayState extends State<ScaleOverlay> {
), ),
], ],
); );
if (widget.showScaledGrid) { child = widget.gridBuilder?.call(clampedCenter, extent, child) ?? child;
child = CustomPaint(
painter: GridPainter(
center: clampedCenter,
extent: extent,
spacing: widget.spacing,
),
child: child,
);
}
return child; return child;
}, },
), ),
@ -263,31 +255,36 @@ class _ScaleOverlayState extends State<ScaleOverlay> {
class GridPainter extends CustomPainter { class GridPainter extends CustomPainter {
final Offset center; final Offset center;
final double extent, spacing; final double extent, spacing;
final double strokeWidth;
final Color color;
const GridPainter({ const GridPainter({
@required this.center, @required this.center,
@required this.extent, @required this.extent,
@required this.spacing, this.spacing = 0.0,
this.strokeWidth = 1.0,
@required this.color,
}); });
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
final radius = extent * 3;
final paint = Paint() final paint = Paint()
..strokeWidth = DecoratedThumbnail.borderWidth ..strokeWidth = strokeWidth
..shader = ui.Gradient.radial( ..shader = ui.Gradient.radial(
center, center,
size.width * .7, radius,
[ [
DecoratedThumbnail.borderColor, color,
Colors.transparent, Colors.transparent,
], ],
[ [
min(.5, 2 * extent / size.width), extent / radius,
1, 1,
], ],
); );
void draw(Offset topLeft) { void draw(Offset topLeft) {
for (var i = -2; i <= 3; i++) { for (var i = -1; i <= 2; i++) {
final ref = (extent + spacing) * i; final ref = (extent + spacing) * i;
canvas.drawLine(Offset(0, topLeft.dy + ref), Offset(size.width, topLeft.dy + ref), paint); 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); 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 { class TileExtentManager {
final String settingsRouteKey; final String settingsRouteKey;
final int columnCountMin, columnCountDefault; final int columnCountMin, columnCountDefault;
final double spacing, extentMin; final double spacing, extentMin, extentMax;
final ValueNotifier<double> extentNotifier; final ValueNotifier<double> extentNotifier;
const TileExtentManager({ const TileExtentManager({
@required this.settingsRouteKey, @required this.settingsRouteKey,
@required this.columnCountMin, @required this.extentNotifier,
this.columnCountMin = 2,
@required this.columnCountDefault, @required this.columnCountDefault,
@required this.extentMin, @required this.extentMin,
@required this.extentNotifier, this.extentMax = 300,
@required this.spacing, @required this.spacing,
}); });
@ -46,7 +47,7 @@ class TileExtentManager {
return newExtent; 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); 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 '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/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/fullscreen/info/common.dart'; import 'package:aves/widgets/fullscreen/info/common.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -16,7 +16,7 @@ class _DebugAndroidEnvironmentSectionState extends State<DebugAndroidEnvironment
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loader = AndroidAppService.getEnv(); _loader = AndroidDebugService.getEnv();
} }
@override @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/model/source/collection_source.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.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/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/android_env.dart';
import 'package:aves/widgets/debug/cache.dart'; import 'package:aves/widgets/debug/cache.dart';
import 'package:aves/widgets/debug/database.dart'; import 'package:aves/widgets/debug/database.dart';
@ -41,6 +42,7 @@ class AppDebugPageState extends State<AppDebugPage> {
padding: EdgeInsets.all(8), padding: EdgeInsets.all(8),
children: [ children: [
_buildGeneralTabView(), _buildGeneralTabView(),
DebugAndroidDirSection(),
DebugAndroidEnvironmentSection(), DebugAndroidEnvironmentSection(),
DebugCacheSection(), DebugCacheSection(),
DebugAppDatabaseSection(), DebugAppDatabaseSection(),

View file

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

View file

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

View file

@ -2,7 +2,8 @@ import 'dart:collection';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:aves/model/image_entry.dart'; 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/utils/constants.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.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/common.dart';
@ -18,7 +19,7 @@ class MetadataTab extends StatefulWidget {
} }
class _MetadataTabState extends State<MetadataTab> { class _MetadataTabState extends State<MetadataTab> {
Future<Map> _bitmapFactoryLoader, _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader, _metadataExtractorLoader; Future<Map> _bitmapFactoryLoader, _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader, _metadataExtractorLoader, _tiffStructureLoader;
// MediaStore timestamp keys // MediaStore timestamp keys
static const secondTimestampKeys = ['date_added', 'date_modified', 'date_expires', 'isPlayed']; static const secondTimestampKeys = ['date_added', 'date_modified', 'date_expires', 'isPlayed'];
@ -33,20 +34,19 @@ class _MetadataTabState extends State<MetadataTab> {
} }
void _loadMetadata() { void _loadMetadata() {
_bitmapFactoryLoader = MetadataService.getBitmapFactoryInfo(entry); _bitmapFactoryLoader = AndroidDebugService.getBitmapFactoryInfo(entry);
_contentResolverMetadataLoader = MetadataService.getContentResolverMetadata(entry); _contentResolverMetadataLoader = AndroidDebugService.getContentResolverMetadata(entry);
_exifInterfaceMetadataLoader = MetadataService.getExifInterfaceMetadata(entry); _exifInterfaceMetadataLoader = AndroidDebugService.getExifInterfaceMetadata(entry);
_mediaMetadataLoader = MetadataService.getMediaMetadataRetrieverMetadata(entry); _mediaMetadataLoader = AndroidDebugService.getMediaMetadataRetrieverMetadata(entry);
_metadataExtractorLoader = MetadataService.getMetadataExtractorSummary(entry); _metadataExtractorLoader = AndroidDebugService.getMetadataExtractorSummary(entry);
_tiffStructureLoader = AndroidDebugService.getTiffStructure(entry);
setState(() {}); setState(() {});
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget builder(BuildContext context, AsyncSnapshot<Map> snapshot, String title) { Widget builderFromSnapshotData(BuildContext context, Map snapshotData, String title) {
if (snapshot.hasError) return Text(snapshot.error.toString()); final data = SplayTreeMap.of(snapshotData.map((k, v) {
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
final data = SplayTreeMap.of(snapshot.data.map((k, v) {
final key = k.toString(); final key = k.toString();
var value = v?.toString() ?? 'null'; var value = v?.toString() ?? 'null';
if ([...secondTimestampKeys, ...millisecondTimestampKeys].contains(key) && v is num && v != 0) { if ([...secondTimestampKeys, ...millisecondTimestampKeys].contains(key) && v is num && v != 0) {
@ -76,28 +76,46 @@ 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( return ListView(
padding: EdgeInsets.all(8), padding: EdgeInsets.all(8),
children: [ children: [
FutureBuilder<Map>( FutureBuilder<Map>(
future: _bitmapFactoryLoader, future: _bitmapFactoryLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Bitmap Factory'), builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Bitmap Factory'),
), ),
FutureBuilder<Map>( FutureBuilder<Map>(
future: _contentResolverMetadataLoader, future: _contentResolverMetadataLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Content Resolver'), builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Content Resolver'),
), ),
FutureBuilder<Map>( FutureBuilder<Map>(
future: _exifInterfaceMetadataLoader, future: _exifInterfaceMetadataLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Exif Interface'), builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Exif Interface'),
), ),
FutureBuilder<Map>( FutureBuilder<Map>(
future: _mediaMetadataLoader, future: _mediaMetadataLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Media Metadata Retriever'), builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Media Metadata Retriever'),
), ),
FutureBuilder<Map>( FutureBuilder<Map>(
future: _metadataExtractorLoader, 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/image_providers/uri_image_provider.dart';
import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
@ -77,7 +79,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
}); });
break; break;
case EntryAction.share: case EntryAction.share:
AndroidAppService.share({entry}).then((success) { AndroidAppService.shareEntries({entry}).then((success) {
if (!success) showNoMatchingAppDialog(context); if (!success) showNoMatchingAppDialog(context);
}); });
break; break;
@ -202,7 +204,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
context, context,
MaterialPageRoute( MaterialPageRoute(
settings: RouteSettings(name: SourceViewerPage.routeName), 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/screen_on.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/utils/change_notifier.dart';
import 'package:aves/theme/durations.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/collection/collection_page.dart';
import 'package:aves/widgets/fullscreen/entry_action_delegate.dart'; import 'package:aves/widgets/fullscreen/entry_action_delegate.dart';
import 'package:aves/widgets/fullscreen/image_page.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/info_page.dart';
import 'package:aves/widgets/fullscreen/info/notifications.dart'; import 'package:aves/widgets/fullscreen/info/notifications.dart';
import 'package:aves/widgets/fullscreen/overlay/bottom.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/top.dart';
import 'package:aves/widgets/fullscreen/overlay/video.dart'; import 'package:aves/widgets/fullscreen/overlay/video.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -223,22 +224,33 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
Widget bottomOverlay = ValueListenableBuilder<ImageEntry>( Widget bottomOverlay = ValueListenableBuilder<ImageEntry>(
valueListenable: _entryNotifier, valueListenable: _entryNotifier,
builder: (context, entry, child) { builder: (context, entry, child) {
Widget videoOverlay; if (entry == null) return SizedBox.shrink();
if (entry != null) {
final videoController = entry.isVideo ? _videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2 : null; Widget extraBottomOverlay;
if (entry.isVideo) {
final videoController = _videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
if (videoController != null) { if (videoController != null) {
videoOverlay = VideoControlOverlay( extraBottomOverlay = VideoControlOverlay(
entry: entry, entry: entry,
controller: videoController, controller: videoController,
scale: _bottomOverlayScale, scale: _bottomOverlayScale,
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
); );
} }
} else if (entry.is360) {
extraBottomOverlay = PanoramaOverlay(
entry: entry,
scale: _bottomOverlayScale,
);
} }
final child = Column( final child = Column(
children: [ children: [
if (videoOverlay != null) videoOverlay, if (extraBottomOverlay != null)
ExtraBottomOverlay(
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
child: extraBottomOverlay,
),
SlideTransition( SlideTransition(
position: _bottomOverlayOffset, position: _bottomOverlayOffset,
child: FullscreenBottomOverlay( child: FullscreenBottomOverlay(
@ -255,7 +267,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
valueListenable: _overlayAnimationController, valueListenable: _overlayAnimationController,
builder: (context, animation, child) { builder: (context, animation, child) {
return Visibility( return Visibility(
visible: entry != null && _overlayAnimationController.status != AnimationStatus.dismissed, visible: _overlayAnimationController.status != AnimationStatus.dismissed,
child: child, child: child,
); );
}, },

View file

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

View file

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

View file

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

View file

@ -33,17 +33,23 @@ class BasicSection extends StatelessWidget {
final showMegaPixels = entry.isPhoto && entry.megaPixels != null && entry.megaPixels > 0; final showMegaPixels = entry.isPhoto && entry.megaPixels != null && entry.megaPixels > 0;
final resolutionText = '${entry.resolutionText}${showMegaPixels ? ' (${entry.megaPixels} MP)' : ''}'; 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( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
InfoRowGroup({ InfoRowGroup({
'Title': entry.bestTitle ?? Constants.infoUnknown, 'Title': title,
'Date': dateText, 'Date': dateText,
if (entry.isVideo) ..._buildVideoRows(), if (entry.isVideo) ..._buildVideoRows(),
if (!entry.isSvg) 'Resolution': resolutionText, if (!entry.isSvg) 'Resolution': resolutionText,
'Size': entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : Constants.infoUnknown, 'Size': entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : Constants.infoUnknown,
'URI': entry.uri ?? Constants.infoUnknown, 'URI': uri,
if (entry.path != null) 'Path': entry.path, if (path != null) 'Path': path,
}), }),
_buildChips(), _buildChips(),
], ],
@ -54,8 +60,10 @@ class BasicSection extends StatelessWidget {
final tags = entry.xmpSubjects..sort(compareAsciiUpperCase); final tags = entry.xmpSubjects..sort(compareAsciiUpperCase);
final album = entry.directory; final album = entry.directory;
final filters = [ final filters = [
if (entry.isVideo) MimeFilter(MimeTypes.anyVideo),
if (entry.isAnimated) MimeFilter(MimeFilter.animated), 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)), if (album != null) AlbumFilter(album, collection?.source?.getUniqueAlbumName(album)),
...tags.map((tag) => TagFilter(tag)), ...tags.map((tag) => TagFilter(tag)),
]; ];

View file

@ -40,10 +40,12 @@ class SectionRow extends StatelessWidget {
class InfoRowGroup extends StatefulWidget { class InfoRowGroup extends StatefulWidget {
final Map<String, String> keyValues; final Map<String, String> keyValues;
final int maxValueLength; final int maxValueLength;
final Map<String, InfoLinkHandler> linkHandlers;
const InfoRowGroup( const InfoRowGroup(
this.keyValues, { this.keyValues, {
this.maxValueLength = 0, this.maxValueLength = 0,
this.linkHandlers,
}); });
@override @override
@ -57,9 +59,13 @@ class _InfoRowGroupState extends State<InfoRowGroup> {
int get maxValueLength => widget.maxValueLength; int get maxValueLength => widget.maxValueLength;
Map<String, InfoLinkHandler> get linkHandlers => widget.linkHandlers;
static const keyValuePadding = 16; static const keyValuePadding = 16;
static const linkColor = Colors.blue;
static final baseStyle = TextStyle(fontFamily: 'Concourse'); static final baseStyle = TextStyle(fontFamily: 'Concourse');
static final keyStyle = baseStyle.copyWith(color: Colors.white70, height: 1.7); static final keyStyle = baseStyle.copyWith(color: Colors.white70, height: 1.7);
static final linkStyle = baseStyle.copyWith(color: linkColor, decoration: TextDecoration.underline);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -85,11 +91,29 @@ class _InfoRowGroupState extends State<InfoRowGroup> {
children: keyValues.entries.expand( children: keyValues.entries.expand(
(kv) { (kv) {
final key = kv.key; final key = kv.key;
var value = kv.value; 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 // long values are clipped, and made expandable by tapping them
final showPreviewOnly = maxValueLength > 0 && value.length > maxValueLength && !_expandedKeys.contains(key); final showPreviewOnly = maxValueLength > 0 && value.length > maxValueLength && !_expandedKeys.contains(key);
if (showPreviewOnly) { if (showPreviewOnly) {
value = '${value.substring(0, maxValueLength)}'; 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` // 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(); final spaceCount = (100 * thisSpaceSize / baseSpaceWidth).round();
return [ return [
TextSpan(text: '$key', style: keyStyle), TextSpan(text: key, style: keyStyle),
TextSpan(text: '\u200A' * spaceCount), TextSpan(text: '\u200A' * spaceCount),
TextSpan(text: '$value${key == lastKey ? '' : '\n'}', recognizer: showPreviewOnly ? _buildTapRecognizer(key) : null), TextSpan(text: value, style: style, recognizer: recognizer),
]; ];
}, },
).toList(), ).toList(),
@ -121,8 +145,14 @@ class _InfoRowGroupState extends State<InfoRowGroup> {
)..layout(BoxConstraints(), parentUsesSize: true); )..layout(BoxConstraints(), parentUsesSize: true);
return para.getMaxIntrinsicWidth(double.infinity); return para.getMaxIntrinsicWidth(double.infinity);
} }
}
GestureRecognizer _buildTapRecognizer(String key) { class InfoLinkHandler {
return TapGestureRecognizer()..onTap = () => setState(() => _expandedKeys.add(key)); 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/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/fullscreen/info/common.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/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:aves/widgets/fullscreen/info/metadata/xmp_tile.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -86,6 +87,10 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
// warning: placing the `AnimationLimiter` as a parent to the `ScrollView` // warning: placing the `AnimationLimiter` as a parent to the `ScrollView`
// triggers dispose & reinitialization of other sections, including heavy widgets like maps // triggers dispose & reinitialization of other sections, including heavy widgets like maps
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: NotificationListener<ScrollNotification>(
// cancel notification bubbling so that the info page
// does not misinterpret content scrolling for page scrolling
onNotification: (notification) => true,
child: AnimatedBuilder( child: AnimatedBuilder(
animation: _loadedMetadataUri, animation: _loadedMetadataUri,
builder: (context, child) { builder: (context, child) {
@ -118,10 +123,13 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
); );
}, },
), ),
),
); );
} }
Widget _buildDirTile(String title, _MetadataDirectory dir) { Widget _buildDirTile(String title, _MetadataDirectory dir) {
if (dir.tags.isEmpty) return SizedBox.shrink();
final dirName = dir.name; final dirName = dir.name;
if (dirName == xmpDirectory) { if (dirName == xmpDirectory) {
return XmpDirTile( return XmpDirTile(
@ -130,6 +138,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
expandedNotifier: _expandedDirectoryNotifier, expandedNotifier: _expandedDirectoryNotifier,
); );
} }
Widget thumbnail; Widget thumbnail;
final prefixChildren = <Widget>[]; final prefixChildren = <Widget>[];
switch (dirName) { switch (dirName) {
@ -163,7 +172,11 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
if (thumbnail != null) thumbnail, if (thumbnail != null) thumbnail,
Padding( Padding(
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), 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 (entry == null) return;
if (_loadedMetadataUri.value == entry.uri) return; if (_loadedMetadataUri.value == entry.uri) return;
if (isVisible) { 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) { final directories = rawMetadata.entries.map((dirKV) {
var directoryName = dirKV.key as String ?? ''; 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:aves/services/metadata_service.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
enum MetadataThumbnailSource { embedded, exif, xmp } enum MetadataThumbnailSource { embedded, exif }
class MetadataThumbnails extends StatefulWidget { class MetadataThumbnails extends StatefulWidget {
final MetadataThumbnailSource source; final MetadataThumbnailSource source;
@ -36,10 +36,7 @@ class _MetadataThumbnailsState extends State<MetadataThumbnails> {
_loader = MetadataService.getEmbeddedPictures(uri); _loader = MetadataService.getEmbeddedPictures(uri);
break; break;
case MetadataThumbnailSource.exif: case MetadataThumbnailSource.exif:
_loader = MetadataService.getExifThumbnails(uri); _loader = MetadataService.getExifThumbnails(entry);
break;
case MetadataThumbnailSource.xmp:
_loader = MetadataService.getXmpThumbnails(entry);
break; 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 'dart:collection';
import 'package:aves/model/image_entry.dart'; 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/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/aves_expansion_tile.dart';
import 'package:aves/widgets/common/identity/highlight_title.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/fullscreen/info/common.dart'; import 'package:aves/widgets/fullscreen/fullscreen_page.dart';
import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.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:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pedantic/pedantic.dart';
class XmpDirTile extends StatelessWidget { class XmpDirTile extends StatefulWidget {
final ImageEntry entry; final ImageEntry entry;
final SplayTreeMap<String, String> tags; final SplayTreeMap<String, String> tags;
final ValueNotifier<String> expandedNotifier; final ValueNotifier<String> expandedNotifier;
@ -23,56 +32,101 @@ class XmpDirTile extends StatelessWidget {
@required this.expandedNotifier, @required this.expandedNotifier,
}); });
@override
_XmpDirTileState createState() => _XmpDirTileState();
}
class _XmpDirTileState extends State<XmpDirTile> with FeedbackMixin {
ImageEntry get entry => widget.entry;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry); final sections = SplayTreeMap<XmpNamespace, List<MapEntry<String, String>>>.of(
final sections = SplayTreeMap.of( groupBy(widget.tags.entries, (kv) {
groupBy<MapEntry<String, String>, String>(tags.entries, (kv) {
final fullKey = kv.key; final fullKey = kv.key;
final i = fullKey.indexOf(XMP.namespaceSeparator); final i = fullKey.indexOf(XMP.propNamespaceSeparator);
if (i == -1) return ''; final namespace = i == -1 ? '' : fullKey.substring(0, i);
final namespace = fullKey.substring(0, i); switch (namespace) {
return XMP.namespaces[namespace] ?? 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( return AvesExpansionTile(
title: 'XMP', title: 'XMP',
expandedNotifier: expandedNotifier, expandedNotifier: widget.expandedNotifier,
children: [ children: [
if (thumbnail != null) thumbnail, NotificationListener<OpenEmbeddedDataNotification>(
Padding( onNotification: (notification) {
_openEmbeddedData(notification.propPath, notification.mimeType);
return true;
},
child: Padding(
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: sections.entries.expand((sectionEntry) { children: sections.entries
final title = sectionEntry.key; .expand((kv) => kv.key.buildNamespaceSection(
rawProps: kv.value,
final entries = sectionEntry.value.map((kv) { ))
final key = kv.key.splitMapJoin(XMP.structFieldSeparator, onNonMatch: (s) { .toList(),
// 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(),
),
), ),
], ],
); );
} }
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 Animation<double> scale;
final Widget child; 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 @override
Widget build(BuildContext context) { 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:aves/widgets/fullscreen/overlay/common.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart'; import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class VideoControlOverlay extends StatefulWidget { class VideoControlOverlay extends StatefulWidget {
final ImageEntry entry; final ImageEntry entry;
final Animation<double> scale;
final IjkMediaController controller; final IjkMediaController controller;
final EdgeInsets viewInsets, viewPadding; final Animation<double> scale;
const VideoControlOverlay({ const VideoControlOverlay({
Key key, Key key,
@required this.entry, @required this.entry,
@required this.controller, @required this.controller,
@required this.scale, @required this.scale,
this.viewInsets,
this.viewPadding,
}) : super(key: key); }) : super(key: key);
@override @override
@ -99,20 +94,7 @@ class VideoControlOverlayState extends State<VideoControlOverlay> with SingleTic
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final mq = context.select<MediaQueryData, Tuple3<double, EdgeInsets, EdgeInsets>>((mq) => Tuple3(mq.size.width, mq.viewInsets, mq.viewPadding)); return StreamBuilder<IjkStatus>(
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, stream: controller.ijkStatusStream,
builder: (context, snapshot) { builder: (context, snapshot) {
// do not use stream snapshot because it is obsolete when switching between videos // do not use stream snapshot because it is obsolete when switching between videos
@ -153,9 +135,7 @@ class VideoControlOverlayState extends State<VideoControlOverlay> with SingleTic
], ],
), ),
); );
}), });
),
);
} }
Widget _buildProgressBar() { 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:aves/widgets/common/aves_highlight.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_highlight/themes/darcula.dart'; import 'package:flutter_highlight/themes/darcula.dart';
@ -9,10 +5,10 @@ import 'package:flutter_highlight/themes/darcula.dart';
class SourceViewerPage extends StatefulWidget { class SourceViewerPage extends StatefulWidget {
static const routeName = '/fullscreen/source'; static const routeName = '/fullscreen/source';
final ImageEntry entry; final Future<String> Function() loader;
const SourceViewerPage({ const SourceViewerPage({
@required this.entry, @required this.loader,
}); });
@override @override
@ -22,12 +18,10 @@ class SourceViewerPage extends StatefulWidget {
class _SourceViewerPageState extends State<SourceViewerPage> { class _SourceViewerPageState extends State<SourceViewerPage> {
Future<String> _loader; Future<String> _loader;
ImageEntry get entry => widget.entry;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loader = ImageFileService.getImage(entry.uri, entry.mimeType, 0, false).then(utf8.decode); _loader = widget.loader();
} }
@override @override
@ -40,12 +34,8 @@ class _SourceViewerPageState extends State<SourceViewerPage> {
child: FutureBuilder<String>( child: FutureBuilder<String>(
future: _loader, future: _loader,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) { if (snapshot.hasError) return Text(snapshot.error.toString());
return Text(snapshot.error.toString()); if (!snapshot.hasData) return SizedBox.shrink();
}
if (snapshot.connectionState != ConnectionState.done) {
return SizedBox.shrink();
}
final source = snapshot.data; final source = snapshot.data;
final highlightView = AvesHighlightView( final highlightView = AvesHighlightView(

View file

@ -82,6 +82,9 @@ class ImageSearchDelegate {
MimeFilter(MimeTypes.anyImage), MimeFilter(MimeTypes.anyImage),
MimeFilter(MimeTypes.anyVideo), MimeFilter(MimeTypes.anyVideo),
MimeFilter(MimeFilter.animated), MimeFilter(MimeFilter.animated),
MimeFilter(MimeFilter.panorama),
MimeFilter(MimeFilter.sphericalVideo),
MimeFilter(MimeFilter.geotiff),
MimeFilter(MimeTypes.svg), MimeFilter(MimeTypes.svg),
].where((f) => f != null && containQuery(f.label)), ].where((f) => f != null && containQuery(f.label)),
// usually perform hero animation only on tapped chips, // 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/theme/durations.dart';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.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/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/settings/access_grants.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 name: cached_network_image
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.3.3" version: "2.4.1"
characters: characters:
dependency: transitive dependency: transitive
description: description:
@ -200,14 +200,14 @@ packages:
name: firebase name: firebase
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "7.3.2" version: "7.3.3"
firebase_analytics: firebase_analytics:
dependency: "direct main" dependency: "direct main"
description: description:
name: firebase_analytics name: firebase_analytics
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.2.0" version: "6.3.0"
firebase_analytics_platform_interface: firebase_analytics_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -228,35 +228,35 @@ packages:
name: firebase_core name: firebase_core
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.5.2" version: "0.5.3"
firebase_core_platform_interface: firebase_core_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: firebase_core_platform_interface name: firebase_core_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.0" version: "2.1.0"
firebase_core_web: firebase_core_web:
dependency: transitive dependency: transitive
description: description:
name: firebase_core_web name: firebase_core_web
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.2.1" version: "0.2.1+1"
firebase_crashlytics: firebase_crashlytics:
dependency: "direct main" dependency: "direct main"
description: description:
name: firebase_crashlytics name: firebase_crashlytics
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.2.3" version: "0.2.4"
firebase_crashlytics_platform_interface: firebase_crashlytics_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: firebase_crashlytics_platform_interface name: firebase_crashlytics_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.1.3" version: "1.1.4"
flushbar: flushbar:
dependency: "direct main" dependency: "direct main"
description: description:
@ -283,6 +283,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.0" 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: flutter_driver:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@ -522,6 +529,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.9.7" version: "0.9.7"
motion_sensors:
dependency: transitive
description:
name: motion_sensors
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.4"
nested: nested:
dependency: transitive dependency: transitive
description: description:
@ -585,6 +599,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.2.3" version: "0.2.3"
panorama:
dependency: "direct main"
description:
name: panorama
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@ -747,7 +768,7 @@ packages:
name: provider name: provider
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.3.2+2" version: "4.3.2+3"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
description: description:
@ -1132,7 +1153,7 @@ packages:
source: hosted source: hosted
version: "0.1.2" version: "0.1.2"
xml: xml:
dependency: transitive dependency: "direct main"
description: description:
name: xml name: xml
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"

View file

@ -1,21 +1,9 @@
name: aves name: aves
description: Aves is a gallery and metadata explorer app, built for Android. 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 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. version: 1.2.9+35
# 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
# brendan-duncan/image (as of v2.1.19): # brendan-duncan/image (as of v2.1.19):
# - does not support TIFF with JPEG compression (issue #184) # - does not support TIFF with JPEG compression (issue #184)
@ -77,6 +65,7 @@ dependencies:
overlay_support: overlay_support:
package_info: package_info:
palette_generator: palette_generator:
panorama:
pdf: pdf:
pedantic: pedantic:
percent_indicator: percent_indicator:
@ -93,6 +82,7 @@ dependencies:
streams_channel: streams_channel:
tuple: tuple:
url_launcher: url_launcher:
xml:
dev_dependencies: dev_dependencies:
flutter_test: 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(); agreeToTerms();
visitAbout();
visitSettings();
sortCollection(); sortCollection();
groupCollection(); groupCollection();
selectFirstAlbum(); selectFirstAlbum();
@ -43,7 +45,7 @@ void main() {
test('contemplation', () async { test('contemplation', () async {
await Future.delayed(Duration(seconds: 5)); await Future.delayed(Duration(seconds: 5));
}); });
}, timeout: Timeout(Duration(seconds: 10))); }, timeout: Timeout(Duration(seconds: 30)));
} }
void agreeToTerms() { 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() { void sortCollection() {
test('[collection] sort', () async { test('[collection] sort', () async {
await driver.tap(find.byValueKey('appbar-menu-button')); await driver.tap(find.byValueKey('appbar-menu-button'));
@ -92,8 +118,11 @@ void selectFirstAlbum() {
await driver.tap(find.byValueKey('Albums-tile')); await driver.tap(find.byValueKey('Albums-tile'));
await driver.waitUntilNoTransientCallbacks(); await driver.waitUntilNoTransientCallbacks();
// wait for collection loading
await driver.waitForCondition(NoPendingPlatformMessages());
await driver.tap(find.descendant( await driver.tap(find.descendant(
of: find.byType('FilterGridPage'), of: find.byValueKey('filter-grid-page'),
matching: find.byType('DecoratedFilterChip'), matching: find.byType('DecoratedFilterChip'),
firstMatchOnly: true, 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 { Future<void> grantPermissions(String packageName, Iterable<String> permissions) async {
await Future.forEach(permissions, (permission) => runAdb(['shell', 'pm', 'grant', packageName, permission])); 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! Thanks for using Aves!
v1.2.8: v1.2.9:
- pinch to scale albums, countries & tags - identify 360 photos/videos, GeoTIFF
- SVG source viewer - open panoramas (360 photos)
- improved detailed metadata layout - open GImage/GAudio/GDepth media and thumbnails embedded in XMP
- improved large TIFF handling
Full changelog available on Github Full changelog available on Github