Merge branch 'develop'
This commit is contained in:
commit
918409346e
96 changed files with 3547 additions and 923 deletions
2
.github/workflows/check.yml
vendored
2
.github/workflows/check.yml
vendored
|
@ -15,7 +15,7 @@ jobs:
|
|||
- uses: subosito/flutter-action@v1
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: '1.22.4'
|
||||
flutter-version: '1.22.5'
|
||||
|
||||
- name: Clone the repository.
|
||||
uses: actions/checkout@v2
|
||||
|
|
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
@ -17,7 +17,7 @@ jobs:
|
|||
- uses: subosito/flutter-action@v1
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: '1.22.4'
|
||||
flutter-version: '1.22.5'
|
||||
|
||||
# Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1):
|
||||
# https://issuetracker.google.com/issues/144111441
|
||||
|
@ -50,8 +50,8 @@ jobs:
|
|||
echo "${{ secrets.KEY_JKS }}" > release.keystore.asc
|
||||
gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE
|
||||
rm release.keystore.asc
|
||||
flutter build apk --bundle-sksl-path shaders_1.22.4.sksl.json
|
||||
flutter build appbundle --bundle-sksl-path shaders_1.22.4.sksl.json
|
||||
flutter build apk --bundle-sksl-path shaders_1.22.5.sksl.json
|
||||
flutter build appbundle --bundle-sksl-path shaders_1.22.5.sksl.json
|
||||
rm $AVES_STORE_FILE
|
||||
env:
|
||||
AVES_STORE_FILE: ${{ github.workspace }}/key.jks
|
||||
|
|
15
CHANGELOG.md
15
CHANGELOG.md
|
@ -3,6 +3,21 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [v1.2.9] - 2020-12-12
|
||||
### Added
|
||||
- Collection: identify 360 photos/videos, GeoTIFF
|
||||
- Viewer: open panoramas (360 photos)
|
||||
- Info: open GImage/GAudio/GDepth media and thumbnails embedded in XMP
|
||||
- Info: SVG metadata
|
||||
|
||||
### Changed
|
||||
- Upgraded Flutter to stable v1.22.5
|
||||
- Viewer: TIFF subsampling & tiling
|
||||
- Info: improved XMP layout
|
||||
|
||||
### Fixed
|
||||
- Fixed large TIFF handling
|
||||
|
||||
## [v1.2.8] - 2020-11-27
|
||||
### Added
|
||||
- Albums / Countries / Tags: pinch to change tile size
|
||||
|
|
13
README.md
13
README.md
|
@ -12,11 +12,13 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
|
|||
|
||||
## Features
|
||||
|
||||
- support raster images: BMP, DNG, GIF, HEIC (from Android Pie), ICO, JPEG, PNG, WBMP, WEBP
|
||||
- support raster images: JPEG, GIF, PNG, HEIC (from Android Pie), WEBP, TIFF, BMP, WBMP, ICO
|
||||
- support animated images: GIF, WEBP
|
||||
- support raw images: ARW, CR2, DNG, NEF, NRW, ORF, PEF, RAF, RW2, SRW
|
||||
- support vector images: SVG
|
||||
- support videos: MP4, AVI, AVCHD & probably others
|
||||
- search and filter by country, place, XMP tag, type (animated, raster, vector, video)
|
||||
- support videos: MP4, AVI, MKV, AVCHD & probably others
|
||||
- identify panoramas (aka photo spheres), 360° videos, GeoTIFF files
|
||||
- search and filter by country, place, XMP tag, type (animated, raster, vector…)
|
||||
- favorites
|
||||
- statistics
|
||||
- support Android API 24 ~ 30 (Nougat ~ R)
|
||||
|
@ -26,7 +28,10 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
|
|||
|
||||
- gesture: double tap on image does not zoom on tapped area (cf [photo_view issue #82](https://github.com/renancaraujo/photo_view/issues/82))
|
||||
- performance: image info page stutters the first time it loads a Google Maps view (cf [flutter issue #28493](https://github.com/flutter/flutter/issues/28493))
|
||||
- performance: image decoding is slow
|
||||
- SVG: unsupported `currentColor` (cf [flutter_svg issue #31](https://github.com/dnfield/flutter_svg/issues/31))
|
||||
- SVG: unsupported out of order defs/references (cf [flutter_svg issue #102](https://github.com/dnfield/flutter_svg/issues/102))
|
||||
- SVG: unsupported `<style>` (cf [flutter_svg issue #105](https://github.com/dnfield/flutter_svg/issues/105))
|
||||
- SVG: limited support for `%`, `mm` or `pt` unit (cf [flutter_svg issue #110](https://github.com/dnfield/flutter_svg/issues/110))
|
||||
|
||||
## Test Devices
|
||||
|
||||
|
|
|
@ -109,9 +109,6 @@ dependencies {
|
|||
implementation 'com.github.deckerst:Android-TiffBitmapFactory:7efb450636'
|
||||
implementation 'com.github.bumptech.glide:glide:4.11.0'
|
||||
|
||||
// TODO TLAD remove when this is fixed: https://github.com/firebase/firebase-android-sdk/issues/1662 https://github.com/FirebaseExtended/flutterfire/issues/3990
|
||||
implementation 'com.google.firebase:firebase-analytics:18.0.0'
|
||||
|
||||
kapt 'androidx.annotation:annotation:1.1.0'
|
||||
kapt 'com.github.bumptech.glide:compiler:4.11.0'
|
||||
|
||||
|
|
|
@ -41,6 +41,7 @@ class MainActivity : FlutterActivity() {
|
|||
MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this))
|
||||
MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(MetadataHandler(this))
|
||||
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
|
||||
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))
|
||||
|
||||
StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(this, args) }
|
||||
StreamsChannel(messenger, ImageOpStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageOpStreamHandler(this, args) }
|
||||
|
|
|
@ -29,7 +29,6 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
when (call.method) {
|
||||
"getAppIcon" -> GlobalScope.launch { getAppIcon(call, Coresult(result)) }
|
||||
"getAppNames" -> GlobalScope.launch { getAppNames(Coresult(result)) }
|
||||
"getEnv" -> result.success(System.getenv())
|
||||
"edit" -> {
|
||||
val title = call.argument<String>("title")
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
|
@ -154,7 +153,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
val intent = Intent(Intent.ACTION_EDIT)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||
.setDataAndType(uri, mimeType)
|
||||
.setDataAndType(getShareableUri(uri), mimeType)
|
||||
return safeStartActivityChooser(title, intent)
|
||||
}
|
||||
|
||||
|
@ -163,7 +162,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
.setDataAndType(uri, mimeType)
|
||||
.setDataAndType(getShareableUri(uri), mimeType)
|
||||
return safeStartActivityChooser(title, intent)
|
||||
}
|
||||
|
||||
|
@ -179,7 +178,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
val intent = Intent(Intent.ACTION_ATTACH_DATA)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
.setDataAndType(uri, mimeType)
|
||||
.setDataAndType(getShareableUri(uri), mimeType)
|
||||
return safeStartActivityChooser(title, intent)
|
||||
}
|
||||
|
||||
|
@ -187,15 +186,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
val intent = Intent(Intent.ACTION_SEND)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
.setType(mimeType)
|
||||
when (uri.scheme?.toLowerCase(Locale.ROOT)) {
|
||||
ContentResolver.SCHEME_FILE -> {
|
||||
val path = uri.path ?: return false
|
||||
val applicationId = context.applicationContext.packageName
|
||||
val apkUri = FileProvider.getUriForFile(context, "$applicationId.fileprovider", File(path))
|
||||
intent.putExtra(Intent.EXTRA_STREAM, apkUri)
|
||||
}
|
||||
else -> intent.putExtra(Intent.EXTRA_STREAM, uri)
|
||||
}
|
||||
.putExtra(Intent.EXTRA_STREAM, getShareableUri(uri))
|
||||
return safeStartActivityChooser(title, intent)
|
||||
}
|
||||
|
||||
|
@ -252,6 +243,18 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
return false
|
||||
}
|
||||
|
||||
private fun getShareableUri(uri: Uri): Uri? {
|
||||
return when (uri.scheme?.toLowerCase(Locale.ROOT)) {
|
||||
ContentResolver.SCHEME_FILE -> {
|
||||
uri.path?.let { path ->
|
||||
val applicationId = context.applicationContext.packageName
|
||||
FileProvider.getUriForFile(context, "$applicationId.fileprovider", File(path))
|
||||
}
|
||||
}
|
||||
else -> uri
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = createTag(AppAdapterHandler::class.java)
|
||||
const val CHANNEL = "deckers.thibault/aves/app"
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -10,6 +10,7 @@ import deckers.thibault.aves.model.provider.FieldMap
|
|||
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
|
||||
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
|
||||
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
|
@ -95,14 +96,24 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
|||
return
|
||||
}
|
||||
|
||||
regionFetcher.fetch(
|
||||
uri,
|
||||
mimeType,
|
||||
sampleSize,
|
||||
Rect(x, y, x + width, y + height),
|
||||
Size(imageWidth, imageHeight),
|
||||
result,
|
||||
)
|
||||
val regionRect = Rect(x, y, x + width, y + height)
|
||||
when (mimeType) {
|
||||
MimeTypes.TIFF -> TiffRegionFetcher(activity).fetch(
|
||||
uri,
|
||||
sampleSize,
|
||||
regionRect,
|
||||
page = 0,
|
||||
result,
|
||||
)
|
||||
else -> regionFetcher.fetch(
|
||||
uri,
|
||||
mimeType,
|
||||
sampleSize,
|
||||
regionRect,
|
||||
Size(imageWidth, imageHeight),
|
||||
result,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getImageEntry(call: MethodCall, result: MethodChannel.Result) {
|
||||
|
|
|
@ -1,14 +1,8 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.graphics.BitmapFactory
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import com.adobe.internal.xmp.XMPException
|
||||
|
@ -23,14 +17,16 @@ import com.drew.metadata.exif.ExifSubIFDDirectory
|
|||
import com.drew.metadata.exif.GpsDirectory
|
||||
import com.drew.metadata.file.FileTypeDirectory
|
||||
import com.drew.metadata.gif.GifAnimationDirectory
|
||||
import com.drew.metadata.iptc.IptcDirectory
|
||||
import com.drew.metadata.mp4.media.Mp4UuidBoxDirectory
|
||||
import com.drew.metadata.webp.WebpDirectory
|
||||
import com.drew.metadata.xmp.XmpDirectory
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDouble
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeRational
|
||||
import deckers.thibault.aves.metadata.Geotiff
|
||||
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
|
||||
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDateMillis
|
||||
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDescription
|
||||
|
@ -43,14 +39,21 @@ import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
|
|||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
|
||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeRational
|
||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
|
||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.isGeoTiff
|
||||
import deckers.thibault.aves.metadata.XMP
|
||||
import deckers.thibault.aves.metadata.XMP.getSafeDateMillis
|
||||
import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText
|
||||
import deckers.thibault.aves.metadata.XMP.isPanorama
|
||||
import deckers.thibault.aves.model.provider.FieldMap
|
||||
import deckers.thibault.aves.model.provider.FileImageProvider
|
||||
import deckers.thibault.aves.model.provider.ImageProvider
|
||||
import deckers.thibault.aves.utils.BitmapUtils
|
||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
||||
import deckers.thibault.aves.utils.MimeTypes.isMultimedia
|
||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
|
||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
|
||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||
import deckers.thibault.aves.utils.MimeTypes.tiffExtensionPattern
|
||||
|
@ -60,7 +63,7 @@ import io.flutter.plugin.common.MethodChannel
|
|||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
|
@ -70,14 +73,9 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
"getAllMetadata" -> GlobalScope.launch { getAllMetadata(call, Coresult(result)) }
|
||||
"getCatalogMetadata" -> GlobalScope.launch { getCatalogMetadata(call, Coresult(result)) }
|
||||
"getOverlayMetadata" -> GlobalScope.launch { getOverlayMetadata(call, Coresult(result)) }
|
||||
"getContentResolverMetadata" -> GlobalScope.launch { getContentResolverMetadata(call, Coresult(result)) }
|
||||
"getExifInterfaceMetadata" -> GlobalScope.launch { getExifInterfaceMetadata(call, Coresult(result)) }
|
||||
"getMediaMetadataRetrieverMetadata" -> GlobalScope.launch { getMediaMetadataRetrieverMetadata(call, Coresult(result)) }
|
||||
"getBitmapFactoryInfo" -> GlobalScope.launch { getBitmapFactoryInfo(call, Coresult(result)) }
|
||||
"getMetadataExtractorSummary" -> GlobalScope.launch { getMetadataExtractorSummary(call, Coresult(result)) }
|
||||
"getEmbeddedPictures" -> GlobalScope.launch { getEmbeddedPictures(call, Coresult(result)) }
|
||||
"getExifThumbnails" -> GlobalScope.launch { getExifThumbnails(call, Coresult(result)) }
|
||||
"getXmpThumbnails" -> GlobalScope.launch { getXmpThumbnails(call, Coresult(result)) }
|
||||
"extractXmpDataProp" -> GlobalScope.launch { extractXmpDataProp(call, Coresult(result)) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
@ -85,6 +83,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
private fun getAllMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||
if (mimeType == null || uri == null) {
|
||||
result.error("getAllMetadata-args", "failed because of missing arguments", null)
|
||||
return
|
||||
|
@ -96,7 +95,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
if (isSupportedByMetadataExtractor(mimeType)) {
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
|
||||
foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java)
|
||||
|
@ -111,22 +110,36 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
metadataMap[dirName] = dirMap
|
||||
|
||||
// tags
|
||||
dirMap.putAll(dir.tags.map { Pair(it.tagName, it.description) })
|
||||
if (mimeType == MimeTypes.TIFF && dir is ExifIFD0Directory) {
|
||||
dirMap.putAll(dir.tags.map {
|
||||
val name = if (it.hasTagName()) {
|
||||
it.tagName
|
||||
} else {
|
||||
Geotiff.getTagName(it.tagType) ?: it.tagName
|
||||
}
|
||||
Pair(name, it.description)
|
||||
})
|
||||
} else {
|
||||
dirMap.putAll(dir.tags.map { Pair(it.tagName, it.description) })
|
||||
}
|
||||
if (dir is XmpDirectory) {
|
||||
try {
|
||||
val xmpMeta = dir.xmpMeta.apply { sort() }
|
||||
for (prop in xmpMeta) {
|
||||
for (prop in dir.xmpMeta) {
|
||||
if (prop is XMPPropertyInfo) {
|
||||
val path = prop.path
|
||||
val value = prop.value
|
||||
if (path?.isNotEmpty() == true && value?.isNotEmpty() == true) {
|
||||
dirMap[path] = value
|
||||
if (path?.isNotEmpty() == true) {
|
||||
val value = if (XMP.isDataPath(path)) "[skipped]" else prop.value
|
||||
if (value?.isNotEmpty() == true) {
|
||||
dirMap[path] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: XMPException) {
|
||||
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
|
||||
}
|
||||
// remove this stat as it is not actual XMP data
|
||||
dirMap.remove(dir.getTagName(XmpDirectory.TAG_XMP_VALUE_COUNT))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -137,10 +150,10 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
if (!foundExif) {
|
||||
if (!foundExif && isSupportedByExifInterface(mimeType)) {
|
||||
// fallback to read EXIF via ExifInterface
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val exif = ExifInterface(input)
|
||||
val allTags = describeAll(exif).toMutableMap()
|
||||
if (foundXmp) {
|
||||
|
@ -187,32 +200,48 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
return dirMap
|
||||
}
|
||||
|
||||
// set `KEY_DATE_MILLIS` from these fields (by precedence):
|
||||
// - ME / Exif / DATETIME_ORIGINAL
|
||||
// - ME / Exif / DATETIME
|
||||
// - EI / Exif / DATETIME_ORIGINAL
|
||||
// - EI / Exif / DATETIME
|
||||
// - ME / XMP / xmp:CreateDate
|
||||
// - ME / XMP / photoshop:DateCreated
|
||||
// - MMR / METADATA_KEY_DATE
|
||||
// set `KEY_XMP_TITLE_DESCRIPTION` from these fields (by precedence):
|
||||
// - ME / XMP / dc:title
|
||||
// - ME / XMP / dc:description
|
||||
// set `KEY_XMP_SUBJECTS` from these fields (by precedence):
|
||||
// - ME / XMP / dc:subject
|
||||
// - ME / IPTC / keywords
|
||||
private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val path = call.argument<String>("path")
|
||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||
if (mimeType == null || uri == null) {
|
||||
result.error("getCatalogMetadata-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType, path))
|
||||
if (isVideo(mimeType)) {
|
||||
metadataMap.putAll(getVideoCatalogMetadataByMediaMetadataRetriever(uri))
|
||||
val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType, path, sizeBytes))
|
||||
if (isMultimedia(mimeType)) {
|
||||
metadataMap.putAll(getMultimediaCatalogMetadataByMediaMetadataRetriever(uri))
|
||||
}
|
||||
|
||||
// report success even when empty
|
||||
result.success(metadataMap)
|
||||
}
|
||||
|
||||
private fun getCatalogMetadataByMetadataExtractor(uri: Uri, mimeType: String, path: String?): Map<String, Any> {
|
||||
private fun getCatalogMetadataByMetadataExtractor(uri: Uri, mimeType: String, path: String?, sizeBytes: Long?): Map<String, Any> {
|
||||
val metadataMap = HashMap<String, Any>()
|
||||
|
||||
var flags = 0
|
||||
var foundExif = false
|
||||
|
||||
if (isSupportedByMetadataExtractor(mimeType)) {
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
|
||||
|
||||
|
@ -247,7 +276,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
dir.getSafeInt(ExifIFD0Directory.TAG_ORIENTATION) {
|
||||
val orientation = it
|
||||
metadataMap[KEY_IS_FLIPPED] = isFlippedForExifCode(orientation)
|
||||
if (isFlippedForExifCode(orientation)) flags = flags or MASK_IS_FLIPPED
|
||||
metadataMap[KEY_ROTATION_DEGREES] = getRotationDegreesForExifCode(orientation)
|
||||
}
|
||||
}
|
||||
|
@ -268,30 +297,60 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
if (xmpMeta.doesPropertyExist(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME)) {
|
||||
val count = xmpMeta.countArrayItems(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME)
|
||||
val values = (1 until count + 1).map { xmpMeta.getArrayItem(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME, it).value }
|
||||
metadataMap[KEY_XMP_SUBJECTS] = values.joinToString(separator = ";")
|
||||
metadataMap[KEY_XMP_SUBJECTS] = values.joinToString(separator = XMP_SUBJECTS_SEPARATOR)
|
||||
}
|
||||
xmpMeta.getSafeLocalizedText(XMP.TITLE_PROP_NAME) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it }
|
||||
xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.TITLE_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it }
|
||||
if (!metadataMap.containsKey(KEY_XMP_TITLE_DESCRIPTION)) {
|
||||
xmpMeta.getSafeLocalizedText(XMP.DESCRIPTION_PROP_NAME) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it }
|
||||
xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.DESCRIPTION_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it }
|
||||
}
|
||||
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
|
||||
xmpMeta.getSafeDateMillis(XMP.XMP_SCHEMA_NS, XMP.CREATE_DATE_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it }
|
||||
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
|
||||
xmpMeta.getSafeDateMillis(XMP.PHOTOSHOP_SCHEMA_NS, XMP.PS_DATE_CREATED_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it }
|
||||
}
|
||||
}
|
||||
|
||||
// identification of panorama (aka photo sphere)
|
||||
if (xmpMeta.isPanorama()) {
|
||||
flags = flags or MASK_IS_360
|
||||
}
|
||||
} catch (e: XMPException) {
|
||||
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Animated GIF & WEBP
|
||||
// XMP fallback to IPTC
|
||||
if (!metadataMap.containsKey(KEY_XMP_SUBJECTS)) {
|
||||
for (dir in metadata.getDirectoriesOfType(IptcDirectory::class.java)) {
|
||||
dir.keywords?.let { metadataMap[KEY_XMP_SUBJECTS] = it.joinToString(separator = XMP_SUBJECTS_SEPARATOR) }
|
||||
}
|
||||
}
|
||||
|
||||
// identification of animated GIF & WEBP, GeoTIFF
|
||||
when (mimeType) {
|
||||
MimeTypes.GIF -> {
|
||||
metadataMap[KEY_IS_ANIMATED] = metadata.containsDirectoryOfType(GifAnimationDirectory::class.java)
|
||||
if (metadata.containsDirectoryOfType(GifAnimationDirectory::class.java)) flags = flags or MASK_IS_ANIMATED
|
||||
}
|
||||
MimeTypes.WEBP -> {
|
||||
for (dir in metadata.getDirectoriesOfType(WebpDirectory::class.java)) {
|
||||
dir.getSafeBoolean(WebpDirectory.TAG_IS_ANIMATION) { metadataMap[KEY_IS_ANIMATED] = it }
|
||||
dir.getSafeBoolean(WebpDirectory.TAG_IS_ANIMATION) {
|
||||
if (it) flags = flags or MASK_IS_ANIMATED
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
MimeTypes.TIFF -> {
|
||||
for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
|
||||
if (dir.isGeoTiff()) flags = flags or MASK_IS_GEOTIFF
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// identification of spherical video (aka 360° video)
|
||||
if (metadata.getDirectoriesOfType(Mp4UuidBoxDirectory::class.java).any {
|
||||
it.getString(Mp4UuidBoxDirectory.TAG_UUID) == Metadata.SPHERICAL_VIDEO_V1_UUID
|
||||
}) {
|
||||
flags = flags or MASK_IS_360
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get catalog metadata by metadata-extractor for uri=$uri, mimeType=$mimeType", e)
|
||||
|
@ -300,17 +359,17 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
if (!foundExif) {
|
||||
if (!foundExif && isSupportedByExifInterface(mimeType)) {
|
||||
// fallback to read EXIF via ExifInterface
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val exif = ExifInterface(input)
|
||||
exif.getSafeDateMillis(ExifInterface.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it }
|
||||
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
|
||||
exif.getSafeDateMillis(ExifInterface.TAG_DATETIME) { metadataMap[KEY_DATE_MILLIS] = it }
|
||||
}
|
||||
exif.getSafeInt(ExifInterface.TAG_ORIENTATION, acceptZero = false) {
|
||||
metadataMap[KEY_IS_FLIPPED] = exif.isFlipped
|
||||
if (exif.isFlipped) flags = flags or MASK_IS_FLIPPED
|
||||
metadataMap[KEY_ROTATION_DEGREES] = exif.rotationDegrees
|
||||
}
|
||||
val latLong = exif.latLong
|
||||
|
@ -325,26 +384,30 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
Log.w(LOG_TAG, "failed to get metadata by ExifInterface for uri=$uri", e)
|
||||
}
|
||||
}
|
||||
metadataMap[KEY_FLAGS] = flags
|
||||
return metadataMap
|
||||
}
|
||||
|
||||
private fun getVideoCatalogMetadataByMediaMetadataRetriever(uri: Uri): Map<String, Any> {
|
||||
private fun getMultimediaCatalogMetadataByMediaMetadataRetriever(uri: Uri): Map<String, Any> {
|
||||
val metadataMap = HashMap<String, Any>()
|
||||
val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return metadataMap
|
||||
try {
|
||||
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { metadataMap[KEY_ROTATION_DEGREES] = it }
|
||||
retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { metadataMap[KEY_DATE_MILLIS] = it }
|
||||
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
|
||||
retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { metadataMap[KEY_DATE_MILLIS] = it }
|
||||
}
|
||||
|
||||
val locationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION)
|
||||
if (locationString != null) {
|
||||
val matcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString)
|
||||
if (matcher.find() && matcher.groupCount() >= 2) {
|
||||
// keep `0.0` as `0.0`, not `0`
|
||||
val latitude = matcher.group(1)?.toDoubleOrNull() ?: 0.0
|
||||
val longitude = matcher.group(2)?.toDoubleOrNull() ?: 0.0
|
||||
if (latitude != 0.0 || longitude != 0.0) {
|
||||
metadataMap[KEY_LATITUDE] = latitude
|
||||
metadataMap[KEY_LONGITUDE] = longitude
|
||||
if (!metadataMap.containsKey(KEY_LATITUDE)) {
|
||||
val locationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION)
|
||||
if (locationString != null) {
|
||||
val matcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString)
|
||||
if (matcher.find() && matcher.groupCount() >= 2) {
|
||||
val latitude = matcher.group(1)?.toDoubleOrNull()
|
||||
val longitude = matcher.group(2)?.toDoubleOrNull()
|
||||
if (latitude != null && longitude != null) {
|
||||
metadataMap[KEY_LATITUDE] = latitude
|
||||
metadataMap[KEY_LONGITUDE] = longitude
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -360,6 +423,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||
if (mimeType == null || uri == null) {
|
||||
result.error("getOverlayMetadata-args", "failed because of missing arguments", null)
|
||||
return
|
||||
|
@ -387,7 +451,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
var foundExif = false
|
||||
if (isSupportedByMetadataExtractor(mimeType)) {
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
|
||||
foundExif = true
|
||||
|
@ -404,10 +468,10 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
if (!foundExif) {
|
||||
if (!foundExif && isSupportedByExifInterface(mimeType)) {
|
||||
// fallback to read EXIF via ExifInterface
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val exif = ExifInterface(input)
|
||||
exif.getSafeDouble(ExifInterface.TAG_F_NUMBER) { metadataMap[KEY_APERTURE] = it }
|
||||
exif.getSafeRational(ExifInterface.TAG_EXPOSURE_TIME, saveExposureTime)
|
||||
|
@ -424,176 +488,6 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
result.success(metadataMap)
|
||||
}
|
||||
|
||||
private fun getContentResolverMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (mimeType == null || uri == null) {
|
||||
result.error("getContentResolverMetadata-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
var contentUri: Uri = uri
|
||||
if (uri.scheme == ContentResolver.SCHEME_CONTENT && MediaStore.AUTHORITY.equals(uri.host, ignoreCase = true)) {
|
||||
try {
|
||||
val id = ContentUris.parseId(uri)
|
||||
contentUri = when {
|
||||
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
|
||||
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
|
||||
else -> uri
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
contentUri = MediaStore.setRequireOriginal(contentUri)
|
||||
}
|
||||
} catch (e: NumberFormatException) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
val cursor = context.contentResolver.query(contentUri, null, null, null, null)
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
val metadataMap = HashMap<String, Any?>()
|
||||
val columnCount = cursor.columnCount
|
||||
val columnNames = cursor.columnNames
|
||||
for (i in 0 until columnCount) {
|
||||
val key = columnNames[i]
|
||||
try {
|
||||
metadataMap[key] = when (cursor.getType(i)) {
|
||||
Cursor.FIELD_TYPE_NULL -> null
|
||||
Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(i)
|
||||
Cursor.FIELD_TYPE_FLOAT -> cursor.getFloat(i)
|
||||
Cursor.FIELD_TYPE_STRING -> cursor.getString(i)
|
||||
Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(i)
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get value for key=$key", e)
|
||||
}
|
||||
}
|
||||
cursor.close()
|
||||
result.success(metadataMap)
|
||||
} else {
|
||||
result.error("getContentResolverMetadata-null", "failed to get cursor for contentUri=$contentUri", null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getExifInterfaceMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (uri == null) {
|
||||
result.error("getExifInterfaceMetadata-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val exif = ExifInterface(input)
|
||||
val metadataMap = HashMap<String, String?>()
|
||||
for (tag in ExifInterfaceHelper.allTags.keys.filter { exif.hasAttribute(it) }) {
|
||||
metadataMap[tag] = exif.getAttribute(tag)
|
||||
}
|
||||
result.success(metadataMap)
|
||||
} ?: result.error("getExifInterfaceMetadata-noinput", "failed to get exif for uri=$uri", null)
|
||||
} catch (e: Exception) {
|
||||
// ExifInterface initialization can fail with a RuntimeException
|
||||
// caused by an internal MediaMetadataRetriever failure
|
||||
result.error("getExifInterfaceMetadata-failure", "failed to get exif for uri=$uri", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getMediaMetadataRetrieverMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (uri == null) {
|
||||
result.error("getMediaMetadataRetrieverMetadata-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val metadataMap = HashMap<String, String>()
|
||||
val retriever = StorageUtils.openMetadataRetriever(context, uri)
|
||||
if (retriever != null) {
|
||||
try {
|
||||
for ((code, name) in MediaMetadataRetrieverHelper.allKeys) {
|
||||
retriever.extractMetadata(code)?.let { metadataMap[name] = it }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// ignore
|
||||
} finally {
|
||||
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
|
||||
retriever.release()
|
||||
}
|
||||
}
|
||||
result.success(metadataMap)
|
||||
}
|
||||
|
||||
private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (uri == null) {
|
||||
result.error("getBitmapDecoderInfo-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val metadataMap = HashMap<String, String>()
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = true
|
||||
}
|
||||
BitmapFactory.decodeStream(input, null, options)
|
||||
options.outMimeType?.let { metadataMap["MimeType"] = it }
|
||||
options.outWidth.takeIf { it >= 0 }?.let { metadataMap["Width"] = it.toString() }
|
||||
options.outHeight.takeIf { it >= 0 }?.let { metadataMap["Height"] = it.toString() }
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
options.outColorSpace?.let { metadataMap["ColorSpace"] = it.toString() }
|
||||
options.outConfig?.let { metadataMap["Config"] = it.toString() }
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
// ignore
|
||||
}
|
||||
result.success(metadataMap)
|
||||
}
|
||||
|
||||
private fun getMetadataExtractorSummary(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (uri == null) {
|
||||
result.error("getMetadataExtractorSummary-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val metadataMap = HashMap<String, String>()
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
metadataMap["mimeType"] = metadata.getDirectoriesOfType(FileTypeDirectory::class.java).joinToString { dir ->
|
||||
if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) {
|
||||
dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)
|
||||
} else ""
|
||||
}
|
||||
metadataMap["typeName"] = metadata.getDirectoriesOfType(FileTypeDirectory::class.java).joinToString { dir ->
|
||||
if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_TYPE_NAME)) {
|
||||
dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_TYPE_NAME)
|
||||
} else ""
|
||||
}
|
||||
for (dir in metadata.directories) {
|
||||
val dirName = dir.name ?: ""
|
||||
var index = 0
|
||||
while (metadataMap.containsKey("$dirName ($index)")) index++
|
||||
var value = "${dir.tagCount} tags"
|
||||
dir.parent?.let { value += ", parent: ${it.name}" }
|
||||
metadataMap["$dirName ($index)"] = value
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e)
|
||||
} catch (e: NoClassDefFoundError) {
|
||||
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e)
|
||||
}
|
||||
|
||||
if (metadataMap.isNotEmpty()) {
|
||||
result.success(metadataMap)
|
||||
} else {
|
||||
result.error("getMetadataExtractorSummary-failure", "failed to get metadata for uri=$uri", null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (uri == null) {
|
||||
|
@ -617,70 +511,111 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
private fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (uri == null) {
|
||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||
if (mimeType == null || uri == null) {
|
||||
result.error("getExifThumbnails-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val thumbnails = ArrayList<ByteArray>()
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val exif = ExifInterface(input)
|
||||
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
|
||||
exif.thumbnailBitmap?.let { bitmap ->
|
||||
TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let {
|
||||
thumbnails.add(it.getBytes(canHaveAlpha = false, recycle = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// ExifInterface initialization can fail with a RuntimeException
|
||||
// caused by an internal MediaMetadataRetriever failure
|
||||
}
|
||||
result.success(thumbnails)
|
||||
}
|
||||
|
||||
private fun getXmpThumbnails(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (mimeType == null || uri == null) {
|
||||
result.error("getXmpThumbnails-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val thumbnails = ArrayList<ByteArray>()
|
||||
if (isSupportedByMetadataExtractor(mimeType)) {
|
||||
if (isSupportedByExifInterface(mimeType)) {
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
|
||||
val xmpMeta = dir.xmpMeta
|
||||
try {
|
||||
if (xmpMeta.doesPropertyExist(XMP.XMP_SCHEMA_NS, XMP.THUMBNAIL_PROP_NAME)) {
|
||||
val count = xmpMeta.countArrayItems(XMP.XMP_SCHEMA_NS, XMP.THUMBNAIL_PROP_NAME)
|
||||
for (i in 1 until count + 1) {
|
||||
val structName = "${XMP.THUMBNAIL_PROP_NAME}[$i]"
|
||||
val image = xmpMeta.getStructField(XMP.XMP_SCHEMA_NS, structName, XMP.IMG_SCHEMA_NS, XMP.THUMBNAIL_IMAGE_PROP_NAME)
|
||||
if (image != null) {
|
||||
thumbnails.add(XMPUtils.decodeBase64(image.value))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: XMPException) {
|
||||
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val exif = ExifInterface(input)
|
||||
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
|
||||
exif.thumbnailBitmap?.let { bitmap ->
|
||||
TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let {
|
||||
thumbnails.add(it.getBytes(canHaveAlpha = false, recycle = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to extract xmp thumbnail", e)
|
||||
} catch (e: NoClassDefFoundError) {
|
||||
Log.w(LOG_TAG, "failed to extract xmp thumbnail", e)
|
||||
// ExifInterface initialization can fail with a RuntimeException
|
||||
// caused by an internal MediaMetadataRetriever failure
|
||||
}
|
||||
}
|
||||
result.success(thumbnails)
|
||||
}
|
||||
|
||||
private fun extractXmpDataProp(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||
val dataPropPath = call.argument<String>("propPath")
|
||||
val embedMimeType = call.argument<String>("propMimeType")
|
||||
if (mimeType == null || uri == null || dataPropPath == null || embedMimeType == null) {
|
||||
result.error("extractXmpDataProp-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
if (isSupportedByMetadataExtractor(mimeType)) {
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
// data can be large and stored in "Extended XMP",
|
||||
// which is returned as a second XMP directory
|
||||
val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java)
|
||||
try {
|
||||
val pathParts = dataPropPath.split('/')
|
||||
|
||||
val embedBytes: ByteArray = if (pathParts.size == 1) {
|
||||
val propName = pathParts[0]
|
||||
val propNs = XMP.namespaceForPropPath(propName)
|
||||
xmpDirs.map { it.xmpMeta.getPropertyBase64(propNs, propName) }.first { it != null }
|
||||
} else {
|
||||
val structName = pathParts[0]
|
||||
val structNs = XMP.namespaceForPropPath(structName)
|
||||
val fieldName = pathParts[1]
|
||||
val fieldNs = XMP.namespaceForPropPath(fieldName)
|
||||
xmpDirs.map { it.xmpMeta.getStructField(structNs, structName, fieldNs, fieldName) }.first { it != null }.let {
|
||||
XMPUtils.decodeBase64(it.value)
|
||||
}
|
||||
}
|
||||
|
||||
val embedFile = File.createTempFile("aves", null, context.cacheDir).apply {
|
||||
deleteOnExit()
|
||||
outputStream().use { outputStream ->
|
||||
embedBytes.inputStream().use { inputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
}
|
||||
}
|
||||
val embedUri = Uri.fromFile(embedFile)
|
||||
val embedFields: FieldMap = hashMapOf(
|
||||
"uri" to embedUri.toString(),
|
||||
"mimeType" to embedMimeType,
|
||||
)
|
||||
if (isImage(embedMimeType) || isVideo(embedMimeType)) {
|
||||
GlobalScope.launch {
|
||||
FileImageProvider().fetchSingle(context, embedUri, embedMimeType, object : ImageProvider.ImageOpCallback {
|
||||
override fun onSuccess(fields: FieldMap) {
|
||||
embedFields.putAll(fields)
|
||||
result.success(embedFields)
|
||||
}
|
||||
|
||||
override fun onFailure(throwable: Throwable) = result.error("extractXmpDataProp-failure", "failed to get entry for uri=$embedUri mime=$embedMimeType", throwable.message)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
result.success(embedFields)
|
||||
}
|
||||
return
|
||||
} catch (e: XMPException) {
|
||||
result.error("extractXmpDataProp-args", "failed to read XMP directory for uri=$uri prop=$dataPropPath", e.message)
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to extract file from XMP", e)
|
||||
} catch (e: NoClassDefFoundError) {
|
||||
Log.w(LOG_TAG, "failed to extract file from XMP", e)
|
||||
}
|
||||
}
|
||||
result.error("extractXmpDataProp-empty", "failed to extract file from XMP uri=$uri prop=$dataPropPath", null)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag(MetadataHandler::class.java)
|
||||
const val CHANNEL = "deckers.thibault/aves/metadata"
|
||||
|
@ -688,14 +623,19 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
// catalog metadata
|
||||
private const val KEY_MIME_TYPE = "mimeType"
|
||||
private const val KEY_DATE_MILLIS = "dateMillis"
|
||||
private const val KEY_IS_ANIMATED = "isAnimated"
|
||||
private const val KEY_IS_FLIPPED = "isFlipped"
|
||||
private const val KEY_FLAGS = "flags"
|
||||
private const val KEY_ROTATION_DEGREES = "rotationDegrees"
|
||||
private const val KEY_LATITUDE = "latitude"
|
||||
private const val KEY_LONGITUDE = "longitude"
|
||||
private const val KEY_XMP_SUBJECTS = "xmpSubjects"
|
||||
private const val KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription"
|
||||
|
||||
private const val MASK_IS_ANIMATED = 1 shl 0
|
||||
private const val MASK_IS_FLIPPED = 1 shl 1
|
||||
private const val MASK_IS_GEOTIFF = 1 shl 2
|
||||
private const val MASK_IS_360 = 1 shl 3
|
||||
private const val XMP_SUBJECTS_SEPARATOR = ";"
|
||||
|
||||
// overlay metadata
|
||||
private const val KEY_APERTURE = "aperture"
|
||||
private const val KEY_EXPOSURE_TIME = "exposureTime"
|
||||
|
|
|
@ -13,6 +13,7 @@ import com.bumptech.glide.load.DecodeFormat
|
|||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import com.bumptech.glide.signature.ObjectKey
|
||||
import deckers.thibault.aves.decoder.TiffThumbnail
|
||||
import deckers.thibault.aves.decoder.VideoThumbnail
|
||||
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||
|
@ -21,7 +22,6 @@ import deckers.thibault.aves.utils.MimeTypes.isVideo
|
|||
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail
|
||||
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||
|
||||
class ThumbnailFetcher internal constructor(
|
||||
private val context: Context,
|
||||
|
@ -45,9 +45,7 @@ class ThumbnailFetcher internal constructor(
|
|||
var exception: Exception? = null
|
||||
|
||||
try {
|
||||
if (mimeType == MimeTypes.TIFF) {
|
||||
bitmap = getTiff()
|
||||
} else if ((width == defaultSize || height == defaultSize) && !isFlipped) {
|
||||
if (mimeType != MimeTypes.TIFF && (width == defaultSize || height == defaultSize) && !isFlipped) {
|
||||
// Fetch low quality thumbnails when size is not specified.
|
||||
// As of Android R, the Media Store content resolver may return a thumbnail
|
||||
// that is automatically rotated according to EXIF orientation, but not flipped,
|
||||
|
@ -121,10 +119,11 @@ class ThumbnailFetcher internal constructor(
|
|||
.load(VideoThumbnail(context, uri))
|
||||
.submit(width, height)
|
||||
} else {
|
||||
val model: Any = if (mimeType == MimeTypes.TIFF) TiffThumbnail(context, uri) else uri
|
||||
Glide.with(context)
|
||||
.asBitmap()
|
||||
.apply(options)
|
||||
.load(uri)
|
||||
.load(model)
|
||||
.submit(width, height)
|
||||
}
|
||||
|
||||
|
@ -138,31 +137,4 @@ class ThumbnailFetcher internal constructor(
|
|||
Glide.with(context).clear(target)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getTiff(): Bitmap? {
|
||||
// determine sample size
|
||||
var sampleSize = 1
|
||||
context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
|
||||
val options = TiffBitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = true
|
||||
}
|
||||
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
|
||||
val imageWidth = options.outWidth
|
||||
val imageHeight = options.outHeight
|
||||
if (imageHeight > height || imageWidth > width) {
|
||||
while (imageHeight / (sampleSize * 2) > height && imageWidth / (sampleSize * 2) > width) {
|
||||
sampleSize *= 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// decode
|
||||
return context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
|
||||
val options = TiffBitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = false
|
||||
inSampleSize = sampleSize
|
||||
}
|
||||
return TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -95,7 +95,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
private fun streamImageByGlide(uri: Uri, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) {
|
||||
val target = Glide.with(activity)
|
||||
.asBitmap()
|
||||
.apply(options)
|
||||
.apply(glideOptions)
|
||||
.load(uri)
|
||||
.submit()
|
||||
try {
|
||||
|
@ -118,7 +118,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
private fun streamVideoByGlide(uri: Uri) {
|
||||
val target = Glide.with(activity)
|
||||
.asBitmap()
|
||||
.apply(options)
|
||||
.apply(glideOptions)
|
||||
.load(VideoThumbnail(activity, uri))
|
||||
.submit()
|
||||
try {
|
||||
|
@ -135,7 +135,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
}
|
||||
}
|
||||
|
||||
private fun streamTiffImage(uri: Uri) {
|
||||
private fun streamTiffImage(uri: Uri, page: Int = 0) {
|
||||
val resolver = activity.contentResolver
|
||||
try {
|
||||
var dirCount = 0
|
||||
|
@ -148,18 +148,17 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
}
|
||||
|
||||
// TODO TLAD handle multipage TIFF
|
||||
if (dirCount > 0) {
|
||||
val i = 0
|
||||
if (dirCount > page) {
|
||||
resolver.openFileDescriptor(uri, "r")?.use { descriptor ->
|
||||
val options = TiffBitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = false
|
||||
inDirectoryNumber = i
|
||||
inDirectoryNumber = page
|
||||
}
|
||||
val bitmap = TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
|
||||
if (bitmap != null) {
|
||||
success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
|
||||
} else {
|
||||
error("streamImage-tiff-null", "failed to get tiff image (dir=$i) from uri=$uri", null)
|
||||
error("streamImage-tiff-null", "failed to get tiff image (dir=$page) from uri=$uri", null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -192,7 +191,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
const val bufferSize = 2 shl 17 // 256kB
|
||||
|
||||
// request a fresh image with the highest quality format
|
||||
val options = RequestOptions()
|
||||
val glideOptions = RequestOptions()
|
||||
.format(DecodeFormat.PREFER_ARGB_8888)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.skipMemoryCache(true)
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -30,7 +30,7 @@ class VideoThumbnailGlideModule : LibraryGlideModule() {
|
|||
class VideoThumbnail(val context: Context, val uri: Uri)
|
||||
|
||||
internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, InputStream> {
|
||||
override fun buildLoadData(model: VideoThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<InputStream>? {
|
||||
override fun buildLoadData(model: VideoThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<InputStream> {
|
||||
return ModelLoader.LoadData(ObjectKey(model.uri), VideoThumbnailFetcher(model))
|
||||
}
|
||||
|
||||
|
|
|
@ -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]
|
||||
}
|
||||
}
|
|
@ -1,6 +1,12 @@
|
|||
package deckers.thibault.aves.metadata
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
@ -13,6 +19,9 @@ object Metadata {
|
|||
// "+51.3328-000.7053+113.474/" (Apple)
|
||||
val VIDEO_LOCATION_PATTERN: Pattern = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+).*")
|
||||
|
||||
// cf https://github.com/google/spatial-media
|
||||
const val SPHERICAL_VIDEO_V1_UUID = "ffcc8263-f855-4a93-8814-587a02521fdd"
|
||||
|
||||
// directory names, as shown when listing all metadata
|
||||
const val DIR_GPS = "GPS" // from metadata-extractor
|
||||
const val DIR_XMP = "XMP" // from metadata-extractor
|
||||
|
@ -41,6 +50,8 @@ object Metadata {
|
|||
}
|
||||
}
|
||||
|
||||
// not sure which standards are used for the different video formats,
|
||||
// but looks like some form of ISO 8601 `basic format`:
|
||||
// yyyyMMddTHHmmss(.sss)?(Z|+/-hhmm)?
|
||||
fun parseVideoMetadataDate(metadataDate: String?): Long {
|
||||
var dateString = metadataDate ?: return 0
|
||||
|
@ -83,4 +94,42 @@ object Metadata {
|
|||
}
|
||||
return dateMillis
|
||||
}
|
||||
|
||||
// opening large TIFF files yields an OOM (both with `metadata-extractor` v2.15.0 and `ExifInterface` v1.3.1),
|
||||
// so we define an arbitrary threshold to avoid a crash on launch.
|
||||
// It is not clear whether it is because of the file itself or its metadata.
|
||||
const val tiffSizeBytesMax = 100 * (1 shl 20) // MB
|
||||
|
||||
// we try and read metadata from large files by copying an arbitrary amount from its beginning
|
||||
// to a temporary file, and reusing that preview file for all metadata reading purposes
|
||||
private const val previewSize = 5 * (1 shl 20) // MB
|
||||
|
||||
private val previewFiles = HashMap<Uri, File>()
|
||||
|
||||
private fun getSafeUri(context: Context, uri: Uri, mimeType: String, sizeBytes: Long?): Uri {
|
||||
if (mimeType != MimeTypes.TIFF) return uri
|
||||
|
||||
if (sizeBytes != null && sizeBytes < tiffSizeBytesMax) return uri
|
||||
|
||||
var previewFile = previewFiles[uri]
|
||||
if (previewFile == null) {
|
||||
previewFile = File.createTempFile("aves", null, context.cacheDir).apply {
|
||||
deleteOnExit()
|
||||
outputStream().use { outputStream ->
|
||||
StorageUtils.openInputStream(context, uri)?.use { inputStream ->
|
||||
val b = ByteArray(previewSize)
|
||||
inputStream.read(b, 0, previewSize)
|
||||
outputStream.write(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
previewFiles[uri] = previewFile
|
||||
}
|
||||
return Uri.fromFile(previewFile)
|
||||
}
|
||||
|
||||
fun openSafeInputStream(context: Context, uri: Uri, mimeType: String, sizeBytes: Long?): InputStream? {
|
||||
val safeUri = getSafeUri(context, uri, mimeType, sizeBytes)
|
||||
return StorageUtils.openInputStream(context, safeUri)
|
||||
}
|
||||
}
|
|
@ -2,6 +2,7 @@ package deckers.thibault.aves.metadata
|
|||
|
||||
import com.drew.lang.Rational
|
||||
import com.drew.metadata.Directory
|
||||
import com.drew.metadata.exif.ExifIFD0Directory
|
||||
import java.util.*
|
||||
|
||||
object MetadataExtractorHelper {
|
||||
|
@ -34,4 +35,25 @@ object MetadataExtractorHelper {
|
|||
fun Directory.getSafeDateMillis(tag: Int, save: (value: Long) -> Unit) {
|
||||
if (this.containsTag(tag)) save(this.getDate(tag, null, TimeZone.getDefault()).time)
|
||||
}
|
||||
|
||||
// geotiff
|
||||
|
||||
/*
|
||||
cf http://docs.opengeospatial.org/is/19-008r4/19-008r4.html#_underlying_tiff_requirements
|
||||
- One of ModelTiepointTag or ModelTransformationTag SHALL be included in an Image File Directory (IFD)
|
||||
- If the ModelTransformationTag is included in an IFD, then a ModelPixelScaleTag SHALL NOT be included
|
||||
- If the ModelPixelScaleTag is included in an IFD, then a ModelTiepointTag SHALL also be included.
|
||||
*/
|
||||
fun ExifIFD0Directory.isGeoTiff(): Boolean {
|
||||
if (!this.containsTag(Geotiff.TAG_GEO_KEY_DIRECTORY)) return false
|
||||
|
||||
val modelTiepoint = this.containsTag(Geotiff.TAG_MODEL_TIEPOINT)
|
||||
val modelTransformation = this.containsTag(Geotiff.TAG_MODEL_TRANSFORMATION)
|
||||
if (!modelTiepoint && !modelTransformation) return false
|
||||
|
||||
val modelPixelScale = this.containsTag(Geotiff.TAG_MODEL_PIXEL_SCALE)
|
||||
if ((modelTransformation && modelPixelScale) || (modelPixelScale && !modelTiepoint)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
|
@ -4,30 +4,105 @@ import android.util.Log
|
|||
import com.adobe.internal.xmp.XMPException
|
||||
import com.adobe.internal.xmp.XMPMeta
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import java.util.*
|
||||
|
||||
object XMP {
|
||||
private val LOG_TAG = LogUtils.createTag(XMP::class.java)
|
||||
|
||||
const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/"
|
||||
const val PHOTOSHOP_SCHEMA_NS = "http://ns.adobe.com/photoshop/1.0/"
|
||||
const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/"
|
||||
const val IMG_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/g/img/"
|
||||
|
||||
const val SUBJECT_PROP_NAME = "dc:subject"
|
||||
const val TITLE_PROP_NAME = "dc:title"
|
||||
const val DESCRIPTION_PROP_NAME = "dc:description"
|
||||
const val THUMBNAIL_PROP_NAME = "xmp:Thumbnails"
|
||||
const val THUMBNAIL_IMAGE_PROP_NAME = "xmpGImg:image"
|
||||
const val PS_DATE_CREATED_PROP_NAME = "photoshop:DateCreated"
|
||||
const val CREATE_DATE_PROP_NAME = "xmp:CreateDate"
|
||||
|
||||
private const val GENERIC_LANG = ""
|
||||
private const val SPECIFIC_LANG = "en-US"
|
||||
|
||||
fun XMPMeta.getSafeLocalizedText(propName: String, save: (value: String) -> Unit) {
|
||||
private val schemas = hashMapOf(
|
||||
"GAudio" to "http://ns.google.com/photos/1.0/audio/",
|
||||
"GDepth" to "http://ns.google.com/photos/1.0/depthmap/",
|
||||
"GImage" to "http://ns.google.com/photos/1.0/image/",
|
||||
"xmp" to XMP_SCHEMA_NS,
|
||||
"xmpGImg" to "http://ns.adobe.com/xap/1.0/g/img/",
|
||||
)
|
||||
|
||||
fun namespaceForPropPath(propPath: String) = schemas[propPath.split(":")[0]]
|
||||
|
||||
// embedded media data properties
|
||||
// cf https://developers.google.com/depthmap-metadata
|
||||
// cf https://developers.google.com/vr/reference/cardboard-camera-vr-photo-format
|
||||
private val knownDataPaths = listOf("GAudio:Data", "GImage:Data", "GDepth:Data", "GDepth:Confidence")
|
||||
|
||||
fun isDataPath(path: String) = knownDataPaths.contains(path)
|
||||
|
||||
// panorama
|
||||
// cf https://developers.google.com/streetview/spherical-metadata
|
||||
|
||||
private const val GPANO_SCHEMA_NS = "http://ns.google.com/photos/1.0/panorama/"
|
||||
private const val PMTM_SCHEMA_NS = "http://www.hdrsoft.com/photomatix_settings01"
|
||||
|
||||
private const val GPANO_CROPPED_AREA_HEIGHT_PROP_NAME = "GPano:CroppedAreaImageHeightPixels"
|
||||
private const val GPANO_CROPPED_AREA_WIDTH_PROP_NAME = "GPano:CroppedAreaImageWidthPixels"
|
||||
private const val GPANO_CROPPED_AREA_LEFT_PROP_NAME = "GPano:CroppedAreaLeftPixels"
|
||||
private const val GPANO_CROPPED_AREA_TOP_PROP_NAME = "GPano:CroppedAreaTopPixels"
|
||||
private const val GPANO_FULL_PANO_HEIGHT_PIXELS_PROP_NAME = "GPano:FullPanoHeightPixels"
|
||||
private const val GPANO_FULL_PANO_WIDTH_PIXELS_PROP_NAME = "GPano:FullPanoWidthPixels"
|
||||
private const val GPANO_PROJECTION_TYPE_PROP_NAME = "GPano:ProjectionType"
|
||||
|
||||
private const val PMTM_IS_PANO360 = "pmtm:IsPano360"
|
||||
|
||||
private val gpanoRequiredProps = listOf(
|
||||
GPANO_CROPPED_AREA_HEIGHT_PROP_NAME,
|
||||
GPANO_CROPPED_AREA_WIDTH_PROP_NAME,
|
||||
GPANO_CROPPED_AREA_LEFT_PROP_NAME,
|
||||
GPANO_CROPPED_AREA_TOP_PROP_NAME,
|
||||
GPANO_FULL_PANO_HEIGHT_PIXELS_PROP_NAME,
|
||||
GPANO_FULL_PANO_WIDTH_PIXELS_PROP_NAME,
|
||||
GPANO_PROJECTION_TYPE_PROP_NAME,
|
||||
)
|
||||
|
||||
// extensions
|
||||
|
||||
fun XMPMeta.isPanorama(): Boolean {
|
||||
// Google
|
||||
if (gpanoRequiredProps.all { doesPropertyExist(GPANO_SCHEMA_NS, it) }) return true
|
||||
// Photomatix
|
||||
if (getPropertyString(PMTM_SCHEMA_NS, PMTM_IS_PANO360) == "Yes") return true
|
||||
return false
|
||||
}
|
||||
|
||||
fun XMPMeta.getSafeLocalizedText(schema: String, propName: String, acceptBlank: Boolean = true, save: (value: String) -> Unit) {
|
||||
try {
|
||||
if (this.doesPropertyExist(DC_SCHEMA_NS, propName)) {
|
||||
val item = this.getLocalizedText(DC_SCHEMA_NS, propName, GENERIC_LANG, SPECIFIC_LANG)
|
||||
if (doesPropertyExist(schema, propName)) {
|
||||
val item = getLocalizedText(schema, propName, GENERIC_LANG, SPECIFIC_LANG)
|
||||
// double check retrieved items as the property sometimes is reported to exist but it is actually null
|
||||
if (item != null) save(item.value)
|
||||
if (item != null && (acceptBlank || item.value.isNotBlank())) {
|
||||
save(item.value)
|
||||
}
|
||||
}
|
||||
} catch (e: XMPException) {
|
||||
Log.w(LOG_TAG, "failed to get text for XMP propName=$propName", e)
|
||||
Log.w(LOG_TAG, "failed to get text for XMP schema=$schema, propName=$propName", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun XMPMeta.getSafeDateMillis(schema: String, propName: String, save: (value: Long) -> Unit) {
|
||||
try {
|
||||
if (doesPropertyExist(schema, propName)) {
|
||||
val item = getPropertyDate(schema, propName)
|
||||
// double check retrieved items as the property sometimes is reported to exist but it is actually null
|
||||
if (item != null) {
|
||||
// strip time zone from XMP dates so that we show date/times as local ones
|
||||
// this aligns with Exif date/times, which are specified without time zones
|
||||
item.timeZone = TimeZone.getDefault()
|
||||
save(item.calendar.timeInMillis)
|
||||
}
|
||||
}
|
||||
} catch (e: XMPException) {
|
||||
Log.w(LOG_TAG, "failed to get text for XMP schema=$schema, propName=$propName", e)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -20,6 +20,7 @@ import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDateMi
|
|||
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt
|
||||
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeLong
|
||||
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeString
|
||||
import deckers.thibault.aves.metadata.Metadata
|
||||
import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
|
||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
|
||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
|
||||
|
@ -27,6 +28,7 @@ import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeLong
|
|||
import deckers.thibault.aves.model.provider.FieldMap
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||
import java.io.IOException
|
||||
|
||||
class SourceImageEntry {
|
||||
|
@ -129,7 +131,10 @@ class SourceImageEntry {
|
|||
fillByExifInterface(context)
|
||||
}
|
||||
if (!isSized) {
|
||||
fillByBitmapDecode(context)
|
||||
when (sourceMimeType) {
|
||||
MimeTypes.TIFF -> fillByTiffDecode(context)
|
||||
else -> fillByBitmapDecode(context)
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
@ -155,10 +160,12 @@ class SourceImageEntry {
|
|||
// finds: width, height, orientation, date, duration
|
||||
private fun fillByMetadataExtractor(context: Context) {
|
||||
// skip raw images because `metadata-extractor` reports the decoded dimensions instead of the raw dimensions
|
||||
if (!MimeTypes.isSupportedByMetadataExtractor(sourceMimeType) || MimeTypes.isRaw(sourceMimeType)) return
|
||||
if (!MimeTypes.isSupportedByMetadataExtractor(sourceMimeType)
|
||||
|| MimeTypes.isRaw(sourceMimeType)
|
||||
) return
|
||||
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
Metadata.openSafeInputStream(context, uri, sourceMimeType, sizeBytes)?.use { input ->
|
||||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
|
||||
// do not switch on specific mime types, as the reported mime type could be wrong
|
||||
|
@ -207,10 +214,10 @@ class SourceImageEntry {
|
|||
|
||||
// finds: width, height, orientation, date
|
||||
private fun fillByExifInterface(context: Context) {
|
||||
if (!ExifInterface.isSupportedMimeType(sourceMimeType)) return;
|
||||
if (!MimeTypes.isSupportedByExifInterface(sourceMimeType)) return
|
||||
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
Metadata.openSafeInputStream(context, uri, sourceMimeType, sizeBytes)?.use { input ->
|
||||
val exif = ExifInterface(input)
|
||||
foundExif = true
|
||||
exif.getSafeInt(ExifInterface.TAG_IMAGE_WIDTH, acceptZero = false) { width = it }
|
||||
|
@ -240,6 +247,22 @@ class SourceImageEntry {
|
|||
}
|
||||
}
|
||||
|
||||
private fun fillByTiffDecode(context: Context) {
|
||||
try {
|
||||
context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
|
||||
val options = TiffBitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = true
|
||||
}
|
||||
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
|
||||
width = options.outWidth
|
||||
height = options.outHeight
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
// convenience method
|
||||
private fun toLong(o: Any?): Long? = when (o) {
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package deckers.thibault.aves.utils
|
||||
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
|
||||
object MimeTypes {
|
||||
private const val IMAGE = "image"
|
||||
|
||||
|
@ -65,12 +67,16 @@ object MimeTypes {
|
|||
else -> false
|
||||
}
|
||||
|
||||
// as of metadata-extractor v2.14.0
|
||||
// as of `metadata-extractor` v2.14.0
|
||||
fun isSupportedByMetadataExtractor(mimeType: String) = when (mimeType) {
|
||||
WBMP, MP2T, WEBM -> false
|
||||
else -> true
|
||||
}
|
||||
|
||||
// as of `ExifInterface` v1.3.1, `isSupportedMimeType` reports
|
||||
// no support for TIFF images, but it can actually open them (maybe other formats too)
|
||||
fun isSupportedByExifInterface(mimeType: String, strict: Boolean = true) = ExifInterface.isSupportedMimeType(mimeType) || !strict
|
||||
|
||||
// Glide automatically applies EXIF orientation when decoding images of known formats
|
||||
// but we need to rotate the decoded bitmap for the other formats
|
||||
// maybe related to ExifInterface version used by Glide:
|
||||
|
|
|
@ -3,4 +3,10 @@
|
|||
<external-path
|
||||
name="external_files"
|
||||
path="." />
|
||||
|
||||
<!-- for images & other media embedded in XMP
|
||||
and exported for viewing and sharing -->
|
||||
<cache-path
|
||||
name="xmp_props"
|
||||
path="." />
|
||||
</paths>
|
|
@ -1,6 +1,6 @@
|
|||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.4.10'
|
||||
ext.kotlin_version = '1.4.20'
|
||||
repositories {
|
||||
google()
|
||||
jcenter()
|
||||
|
|
|
@ -10,6 +10,9 @@ class MimeFilter extends CollectionFilter {
|
|||
|
||||
// fake mime type
|
||||
static const animated = 'aves/animated'; // subset of `image/gif` and `image/webp`
|
||||
static const panorama = 'aves/panorama'; // subset of images
|
||||
static const sphericalVideo = 'aves/spherical_video'; // subset of videos
|
||||
static const geotiff = 'aves/geotiff'; // subset of `image/tiff`
|
||||
|
||||
final String mime;
|
||||
bool Function(ImageEntry) _filter;
|
||||
|
@ -22,6 +25,18 @@ class MimeFilter extends CollectionFilter {
|
|||
_filter = (entry) => entry.isAnimated;
|
||||
_label = 'Animated';
|
||||
_icon = AIcons.animated;
|
||||
} else if (mime == panorama) {
|
||||
_filter = (entry) => entry.isImage && entry.is360;
|
||||
_label = 'Panorama';
|
||||
_icon = AIcons.threesixty;
|
||||
} else if (mime == sphericalVideo) {
|
||||
_filter = (entry) => entry.isVideo && entry.is360;
|
||||
_label = '360° Video';
|
||||
_icon = AIcons.threesixty;
|
||||
} else if (mime == geotiff) {
|
||||
_filter = (entry) => entry.isGeotiff;
|
||||
_label = 'GeoTIFF';
|
||||
_icon = AIcons.geo;
|
||||
} else if (lowMime.endsWith('/*')) {
|
||||
lowMime = lowMime.substring(0, lowMime.length - 2);
|
||||
_filter = (entry) => entry.mimeType.startsWith(lowMime);
|
||||
|
|
|
@ -26,7 +26,7 @@ class QueryFilter extends CollectionFilter {
|
|||
// allow untrimmed queries wrapped with `"..."`
|
||||
final matches = exactRegex.allMatches(upQuery);
|
||||
if (matches.length == 1) {
|
||||
upQuery = matches.elementAt(0).group(1);
|
||||
upQuery = matches.first.group(1);
|
||||
}
|
||||
|
||||
_filter = not ? (entry) => !entry.search(upQuery) : (entry) => entry.search(upQuery);
|
||||
|
|
|
@ -173,12 +173,12 @@ class ImageEntry {
|
|||
bool get isSvg => mimeType == MimeTypes.svg;
|
||||
|
||||
// guess whether this is a photo, according to file type (used as a hint to e.g. display megapixels)
|
||||
bool get isPhoto => [MimeTypes.heic, MimeTypes.heif, MimeTypes.jpeg].contains(mimeType) || isRaw;
|
||||
bool get isPhoto => [MimeTypes.heic, MimeTypes.heif, MimeTypes.jpeg, MimeTypes.tiff].contains(mimeType) || isRaw;
|
||||
|
||||
// Android's `BitmapRegionDecoder` documentation states that "only the JPEG and PNG formats are supported"
|
||||
// but in practice (tested on API 25, 27, 29), it successfully decodes the formats listed below,
|
||||
// and it actually fails to decode GIF, DNG and animated WEBP. Other formats were not tested.
|
||||
bool get canTile =>
|
||||
bool get _supportedByBitmapRegionDecoder =>
|
||||
[
|
||||
MimeTypes.heic,
|
||||
MimeTypes.heif,
|
||||
|
@ -196,14 +196,22 @@ class ImageEntry {
|
|||
].contains(mimeType) &&
|
||||
!isAnimated;
|
||||
|
||||
bool get canTile => _supportedByBitmapRegionDecoder || mimeType == MimeTypes.tiff;
|
||||
|
||||
bool get isRaw => MimeTypes.rawImages.contains(mimeType);
|
||||
|
||||
bool get isVideo => mimeType.startsWith('video');
|
||||
bool get isImage => MimeTypes.isImage(mimeType);
|
||||
|
||||
bool get isVideo => MimeTypes.isVideo(mimeType);
|
||||
|
||||
bool get isCatalogued => _catalogMetadata != null;
|
||||
|
||||
bool get isAnimated => _catalogMetadata?.isAnimated ?? false;
|
||||
|
||||
bool get isGeotiff => _catalogMetadata?.isGeotiff ?? false;
|
||||
|
||||
bool get is360 => _catalogMetadata?.is360 ?? false;
|
||||
|
||||
bool get canEdit => path != null;
|
||||
|
||||
bool get canPrint => !isVideo;
|
||||
|
@ -299,7 +307,7 @@ class ImageEntry {
|
|||
|
||||
bool get isLocated => _addressDetails != null;
|
||||
|
||||
LatLng get latLng => isCatalogued ? LatLng(_catalogMetadata.latitude, _catalogMetadata.longitude) : null;
|
||||
LatLng get latLng => hasGps ? LatLng(_catalogMetadata.latitude, _catalogMetadata.longitude) : null;
|
||||
|
||||
String get geoUri {
|
||||
if (!hasGps) return null;
|
||||
|
@ -373,12 +381,17 @@ class ImageEntry {
|
|||
: call());
|
||||
if (addresses != null && addresses.isNotEmpty) {
|
||||
final address = addresses.first;
|
||||
final cc = address.countryCode;
|
||||
final cn = address.countryName;
|
||||
final aa = address.adminArea;
|
||||
addressDetails = AddressDetails(
|
||||
contentId: contentId,
|
||||
countryCode: address.countryCode,
|
||||
countryName: address.countryName,
|
||||
adminArea: address.adminArea,
|
||||
locality: address.locality,
|
||||
countryCode: cc,
|
||||
countryName: cn,
|
||||
adminArea: aa,
|
||||
// if country & admin fields are null, it is likely the ocean,
|
||||
// which is identified by `featureName` but we default to the address line anyway
|
||||
locality: address.locality ?? (cc == null && cn == null && aa == null ? address.addressLine : null),
|
||||
);
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
|
|
|
@ -30,28 +30,41 @@ class DateMetadata {
|
|||
|
||||
class CatalogMetadata {
|
||||
final int contentId, dateMillis;
|
||||
final bool isAnimated;
|
||||
final bool isAnimated, isGeotiff, is360;
|
||||
bool isFlipped;
|
||||
int rotationDegrees;
|
||||
final String mimeType, xmpSubjects, xmpTitleDescription;
|
||||
final double latitude, longitude;
|
||||
double latitude, longitude;
|
||||
Address address;
|
||||
|
||||
static const double _precisionErrorTolerance = 1e-9;
|
||||
static const _isAnimatedMask = 1 << 0;
|
||||
static const _isFlippedMask = 1 << 1;
|
||||
static const _isGeotiffMask = 1 << 2;
|
||||
static const _is360Mask = 1 << 3;
|
||||
|
||||
CatalogMetadata({
|
||||
this.contentId,
|
||||
this.mimeType,
|
||||
this.dateMillis,
|
||||
this.isAnimated,
|
||||
this.isFlipped,
|
||||
this.isGeotiff,
|
||||
this.is360,
|
||||
this.rotationDegrees,
|
||||
this.xmpSubjects,
|
||||
this.xmpTitleDescription,
|
||||
double latitude,
|
||||
double longitude,
|
||||
})
|
||||
// Geocoder throws an IllegalArgumentException when a coordinate has a funky values like 1.7056881853375E7
|
||||
: latitude = latitude == null || latitude < -90.0 || latitude > 90.0 ? null : latitude,
|
||||
longitude = longitude == null || longitude < -180.0 || longitude > 180.0 ? null : longitude;
|
||||
}) {
|
||||
// Geocoder throws an `IllegalArgumentException` when a coordinate has a funky values like `1.7056881853375E7`
|
||||
// We also exclude zero coordinates, taking into account precision errors (e.g. {5.952380952380953e-11,-2.7777777777777777e-10}),
|
||||
// but Flutter's `precisionErrorTolerance` (1e-10) is slightly too lenient for this case.
|
||||
if (latitude != null && longitude != null && (latitude.abs() > _precisionErrorTolerance || longitude.abs() > _precisionErrorTolerance)) {
|
||||
this.latitude = latitude < -90.0 || latitude > 90.0 ? null : latitude;
|
||||
this.longitude = longitude < -180.0 || longitude > 180.0 ? null : longitude;
|
||||
}
|
||||
}
|
||||
|
||||
CatalogMetadata copyWith({
|
||||
@required int contentId,
|
||||
|
@ -62,6 +75,8 @@ class CatalogMetadata {
|
|||
dateMillis: dateMillis,
|
||||
isAnimated: isAnimated,
|
||||
isFlipped: isFlipped,
|
||||
isGeotiff: isGeotiff,
|
||||
is360: is360,
|
||||
rotationDegrees: rotationDegrees,
|
||||
xmpSubjects: xmpSubjects,
|
||||
xmpTitleDescription: xmpTitleDescription,
|
||||
|
@ -70,15 +85,16 @@ class CatalogMetadata {
|
|||
);
|
||||
}
|
||||
|
||||
factory CatalogMetadata.fromMap(Map map, {bool boolAsInteger = false}) {
|
||||
final isAnimated = map['isAnimated'] ?? (boolAsInteger ? 0 : false);
|
||||
final isFlipped = map['isFlipped'] ?? (boolAsInteger ? 0 : false);
|
||||
factory CatalogMetadata.fromMap(Map map) {
|
||||
final flags = map['flags'] ?? 0;
|
||||
return CatalogMetadata(
|
||||
contentId: map['contentId'],
|
||||
mimeType: map['mimeType'],
|
||||
dateMillis: map['dateMillis'] ?? 0,
|
||||
isAnimated: boolAsInteger ? isAnimated != 0 : isAnimated,
|
||||
isFlipped: boolAsInteger ? isFlipped != 0 : isFlipped,
|
||||
isAnimated: flags & _isAnimatedMask != 0,
|
||||
isFlipped: flags & _isFlippedMask != 0,
|
||||
isGeotiff: flags & _isGeotiffMask != 0,
|
||||
is360: flags & _is360Mask != 0,
|
||||
// `rotationDegrees` should default to `sourceRotationDegrees`, not 0
|
||||
rotationDegrees: map['rotationDegrees'],
|
||||
xmpSubjects: map['xmpSubjects'] ?? '',
|
||||
|
@ -88,12 +104,11 @@ class CatalogMetadata {
|
|||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap({bool boolAsInteger = false}) => {
|
||||
Map<String, dynamic> toMap() => {
|
||||
'contentId': contentId,
|
||||
'mimeType': mimeType,
|
||||
'dateMillis': dateMillis,
|
||||
'isAnimated': boolAsInteger ? (isAnimated ? 1 : 0) : isAnimated,
|
||||
'isFlipped': boolAsInteger ? (isFlipped ? 1 : 0) : isFlipped,
|
||||
'flags': (isAnimated ? _isAnimatedMask : 0) | (isFlipped ? _isFlippedMask : 0) | (isGeotiff ? _isGeotiffMask : 0) | (is360 ? _is360Mask : 0),
|
||||
'rotationDegrees': rotationDegrees,
|
||||
'xmpSubjects': xmpSubjects,
|
||||
'xmpTitleDescription': xmpTitleDescription,
|
||||
|
@ -103,7 +118,7 @@ class CatalogMetadata {
|
|||
|
||||
@override
|
||||
String toString() {
|
||||
return 'CatalogMetadata{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}';
|
||||
return 'CatalogMetadata{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'dart:io';
|
|||
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/image_metadata.dart';
|
||||
import 'package:aves/model/metadata_db_upgrade.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
@ -48,8 +49,7 @@ class MetadataDb {
|
|||
'contentId INTEGER PRIMARY KEY'
|
||||
', mimeType TEXT'
|
||||
', dateMillis INTEGER'
|
||||
', isAnimated INTEGER'
|
||||
', isFlipped INTEGER'
|
||||
', flags INTEGER'
|
||||
', rotationDegrees INTEGER'
|
||||
', xmpSubjects TEXT'
|
||||
', xmpTitleDescription TEXT'
|
||||
|
@ -69,65 +69,8 @@ class MetadataDb {
|
|||
', path TEXT'
|
||||
')');
|
||||
},
|
||||
onUpgrade: (db, oldVersion, newVersion) async {
|
||||
// warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported
|
||||
// on SQLite <3.25.0, bundled on older Android devices
|
||||
while (oldVersion < newVersion) {
|
||||
if (oldVersion == 1) {
|
||||
// rename column 'orientationDegrees' to 'sourceRotationDegrees'
|
||||
await db.transaction((txn) async {
|
||||
const newEntryTable = '${entryTable}TEMP';
|
||||
await db.execute('CREATE TABLE $newEntryTable('
|
||||
'contentId INTEGER PRIMARY KEY'
|
||||
', uri TEXT'
|
||||
', path TEXT'
|
||||
', sourceMimeType TEXT'
|
||||
', width INTEGER'
|
||||
', height INTEGER'
|
||||
', sourceRotationDegrees INTEGER'
|
||||
', sizeBytes INTEGER'
|
||||
', title TEXT'
|
||||
', dateModifiedSecs INTEGER'
|
||||
', sourceDateTakenMillis INTEGER'
|
||||
', durationMillis INTEGER'
|
||||
')');
|
||||
await db.rawInsert('INSERT INTO $newEntryTable(contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis)'
|
||||
' SELECT contentId,uri,path,sourceMimeType,width,height,orientationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis'
|
||||
' FROM $entryTable;');
|
||||
await db.execute('DROP TABLE $entryTable;');
|
||||
await db.execute('ALTER TABLE $newEntryTable RENAME TO $entryTable;');
|
||||
});
|
||||
|
||||
// rename column 'videoRotation' to 'rotationDegrees'
|
||||
await db.transaction((txn) async {
|
||||
const newMetadataTable = '${metadataTable}TEMP';
|
||||
await db.execute('CREATE TABLE $newMetadataTable('
|
||||
'contentId INTEGER PRIMARY KEY'
|
||||
', mimeType TEXT'
|
||||
', dateMillis INTEGER'
|
||||
', isAnimated INTEGER'
|
||||
', rotationDegrees INTEGER'
|
||||
', xmpSubjects TEXT'
|
||||
', xmpTitleDescription TEXT'
|
||||
', latitude REAL'
|
||||
', longitude REAL'
|
||||
')');
|
||||
await db.rawInsert('INSERT INTO $newMetadataTable(contentId,mimeType,dateMillis,isAnimated,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude)'
|
||||
' SELECT contentId,mimeType,dateMillis,isAnimated,videoRotation,xmpSubjects,xmpTitleDescription,latitude,longitude'
|
||||
' FROM $metadataTable;');
|
||||
await db.rawInsert('UPDATE $newMetadataTable SET rotationDegrees = NULL WHERE rotationDegrees = 0;');
|
||||
await db.execute('DROP TABLE $metadataTable;');
|
||||
await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;');
|
||||
});
|
||||
|
||||
// new column 'isFlipped'
|
||||
await db.execute('ALTER TABLE $metadataTable ADD COLUMN isFlipped INTEGER;');
|
||||
|
||||
oldVersion++;
|
||||
}
|
||||
}
|
||||
},
|
||||
version: 2,
|
||||
onUpgrade: MetadataDbUpgrader.upgradeDb,
|
||||
version: 3,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -238,7 +181,7 @@ class MetadataDb {
|
|||
// final stopwatch = Stopwatch()..start();
|
||||
final db = await _database;
|
||||
final maps = await db.query(metadataTable);
|
||||
final metadataEntries = maps.map((map) => CatalogMetadata.fromMap(map, boolAsInteger: true)).toList();
|
||||
final metadataEntries = maps.map((map) => CatalogMetadata.fromMap(map)).toList();
|
||||
// debugPrint('$runtimeType loadMetadataEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries');
|
||||
return metadataEntries;
|
||||
}
|
||||
|
@ -246,11 +189,15 @@ class MetadataDb {
|
|||
Future<void> saveMetadata(Iterable<CatalogMetadata> metadataEntries) async {
|
||||
if (metadataEntries == null || metadataEntries.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final db = await _database;
|
||||
final batch = db.batch();
|
||||
metadataEntries.where((metadata) => metadata != null).forEach((metadata) => _batchInsertMetadata(batch, metadata));
|
||||
await batch.commit(noResult: true);
|
||||
debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries');
|
||||
try {
|
||||
final db = await _database;
|
||||
final batch = db.batch();
|
||||
metadataEntries.where((metadata) => metadata != null).forEach((metadata) => _batchInsertMetadata(batch, metadata));
|
||||
await batch.commit(noResult: true);
|
||||
debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries');
|
||||
} catch (exception, stack) {
|
||||
debugPrint('$runtimeType failed to save metadata with exception=$exception\n$stack');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateMetadataId(int oldId, CatalogMetadata metadata) async {
|
||||
|
@ -273,7 +220,7 @@ class MetadataDb {
|
|||
}
|
||||
batch.insert(
|
||||
metadataTable,
|
||||
metadata.toMap(boolAsInteger: true),
|
||||
metadata.toMap(),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
|
100
lib/model/metadata_db_upgrade.dart
Normal file
100
lib/model/metadata_db_upgrade.dart
Normal 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;');
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:flutter/painting.dart';
|
||||
|
||||
class BrandColors {
|
||||
static const Color adobeAfterEffects = Color(0xFF9A9AFF);
|
||||
static const Color adobeIllustrator = Color(0xFFFF9B00);
|
||||
static const Color adobePhotoshop = Color(0xFF2DAAFF);
|
||||
static const Color android = Color(0xFF3DDC84);
|
||||
|
@ -9,6 +10,8 @@ class BrandColors {
|
|||
static Color get(String text) {
|
||||
if (text != null) {
|
||||
switch (text.toLowerCase()) {
|
||||
case 'after effects':
|
||||
return adobeAfterEffects;
|
||||
case 'illustrator':
|
||||
return adobeIllustrator;
|
||||
case 'photoshop':
|
||||
|
|
639
lib/ref/exif.dart
Normal file
639
lib/ref/exif.dart
Normal 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)';
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,44 +1,48 @@
|
|||
class MimeTypes {
|
||||
static const String anyImage = 'image/*';
|
||||
static const anyImage = 'image/*';
|
||||
|
||||
static const String gif = 'image/gif';
|
||||
static const String heic = 'image/heic';
|
||||
static const String heif = 'image/heif';
|
||||
static const String jpeg = 'image/jpeg';
|
||||
static const String png = 'image/png';
|
||||
static const String svg = 'image/svg+xml';
|
||||
static const String webp = 'image/webp';
|
||||
static const gif = 'image/gif';
|
||||
static const heic = 'image/heic';
|
||||
static const heif = 'image/heif';
|
||||
static const jpeg = 'image/jpeg';
|
||||
static const png = 'image/png';
|
||||
static const svg = 'image/svg+xml';
|
||||
static const webp = 'image/webp';
|
||||
|
||||
static const String tiff = 'image/tiff';
|
||||
static const String psd = 'image/vnd.adobe.photoshop';
|
||||
static const tiff = 'image/tiff';
|
||||
static const psd = 'image/vnd.adobe.photoshop';
|
||||
|
||||
static const String arw = 'image/x-sony-arw';
|
||||
static const String cr2 = 'image/x-canon-cr2';
|
||||
static const String crw = 'image/x-canon-crw';
|
||||
static const String dcr = 'image/x-kodak-dcr';
|
||||
static const String dng = 'image/x-adobe-dng';
|
||||
static const String erf = 'image/x-epson-erf';
|
||||
static const String k25 = 'image/x-kodak-k25';
|
||||
static const String kdc = 'image/x-kodak-kdc';
|
||||
static const String mrw = 'image/x-minolta-mrw';
|
||||
static const String nef = 'image/x-nikon-nef';
|
||||
static const String nrw = 'image/x-nikon-nrw';
|
||||
static const String orf = 'image/x-olympus-orf';
|
||||
static const String pef = 'image/x-pentax-pef';
|
||||
static const String raf = 'image/x-fuji-raf';
|
||||
static const String raw = 'image/x-panasonic-raw';
|
||||
static const String rw2 = 'image/x-panasonic-rw2';
|
||||
static const String sr2 = 'image/x-sony-sr2';
|
||||
static const String srf = 'image/x-sony-srf';
|
||||
static const String srw = 'image/x-samsung-srw';
|
||||
static const String x3f = 'image/x-sigma-x3f';
|
||||
static const arw = 'image/x-sony-arw';
|
||||
static const cr2 = 'image/x-canon-cr2';
|
||||
static const crw = 'image/x-canon-crw';
|
||||
static const dcr = 'image/x-kodak-dcr';
|
||||
static const dng = 'image/x-adobe-dng';
|
||||
static const erf = 'image/x-epson-erf';
|
||||
static const k25 = 'image/x-kodak-k25';
|
||||
static const kdc = 'image/x-kodak-kdc';
|
||||
static const mrw = 'image/x-minolta-mrw';
|
||||
static const nef = 'image/x-nikon-nef';
|
||||
static const nrw = 'image/x-nikon-nrw';
|
||||
static const orf = 'image/x-olympus-orf';
|
||||
static const pef = 'image/x-pentax-pef';
|
||||
static const raf = 'image/x-fuji-raf';
|
||||
static const raw = 'image/x-panasonic-raw';
|
||||
static const rw2 = 'image/x-panasonic-rw2';
|
||||
static const sr2 = 'image/x-sony-sr2';
|
||||
static const srf = 'image/x-sony-srf';
|
||||
static const srw = 'image/x-samsung-srw';
|
||||
static const x3f = 'image/x-sigma-x3f';
|
||||
|
||||
static const String anyVideo = 'video/*';
|
||||
static const anyVideo = 'video/*';
|
||||
|
||||
static const String avi = 'video/avi';
|
||||
static const String mp2t = 'video/mp2t'; // .m2ts
|
||||
static const String mp4 = 'video/mp4';
|
||||
static const avi = 'video/avi';
|
||||
static const mp2t = 'video/mp2t'; // .m2ts
|
||||
static const mp4 = 'video/mp4';
|
||||
|
||||
// groups
|
||||
static const List<String> rawImages = [arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f];
|
||||
|
||||
static bool isImage(String mimeType) => mimeType.startsWith('image');
|
||||
|
||||
static bool isVideo(String mimeType) => mimeType.startsWith('video');
|
||||
}
|
||||
|
|
|
@ -1,29 +1,34 @@
|
|||
class XMP {
|
||||
static const namespaceSeparator = ':';
|
||||
static const propNamespaceSeparator = ':';
|
||||
static const structFieldSeparator = '/';
|
||||
|
||||
// cf https://exiftool.org/TagNames/XMP.html
|
||||
static const Map<String, String> namespaces = {
|
||||
'aux': 'Auxiliary Exif',
|
||||
'adsml-at': 'AdsML',
|
||||
'aux': 'Exif Aux',
|
||||
'avm': 'Astronomy Visualization',
|
||||
'Camera': 'Camera',
|
||||
'creatorAtom': 'After Effects',
|
||||
'crs': 'Camera Raw Settings',
|
||||
'dc': 'Dublin Core',
|
||||
'exif': 'Exif',
|
||||
'drone-dji': 'DJI Drone',
|
||||
'exifEX': 'Exif Ex',
|
||||
'GettyImagesGIFT': 'Getty Images',
|
||||
'GIMP': 'GIMP',
|
||||
'GFocus': 'Google Focus',
|
||||
'GPano': 'Google Panorama',
|
||||
'illustrator': 'Illustrator',
|
||||
'Iptc4xmpCore': 'IPTC Core',
|
||||
'lr': 'Lightroom',
|
||||
'MicrosoftPhoto': 'Microsoft Photo',
|
||||
'panorama': 'Panorama',
|
||||
'pdf': 'PDF',
|
||||
'pdfx': 'PDF/X',
|
||||
'PanoStudioXMP': 'PanoramaStudio',
|
||||
'photomechanic': 'Photo Mechanic',
|
||||
'photoshop': 'Photoshop',
|
||||
'tiff': 'TIFF',
|
||||
'xmp': 'Basic',
|
||||
'plus': 'PLUS',
|
||||
'pmtm': 'Photomatix',
|
||||
'xmpBJ': 'Basic Job Ticket',
|
||||
'xmpDM': 'Dynamic Media',
|
||||
'xmpMM': 'Media Management',
|
||||
'xmpRights': 'Rights Management',
|
||||
'xmpTPg': 'Paged-Text',
|
||||
};
|
||||
|
|
|
@ -31,16 +31,6 @@ class AndroidAppService {
|
|||
return null;
|
||||
}
|
||||
|
||||
static Future<Map> getEnv() async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getEnv');
|
||||
return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getEnv failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
static Future<bool> edit(String uri, String mimeType) async {
|
||||
try {
|
||||
return await platform.invokeMethod('edit', <String, dynamic>{
|
||||
|
@ -91,7 +81,7 @@ class AndroidAppService {
|
|||
return false;
|
||||
}
|
||||
|
||||
static Future<bool> share(Iterable<ImageEntry> entries) async {
|
||||
static Future<bool> shareEntries(Iterable<ImageEntry> entries) async {
|
||||
// loosen mime type to a generic one, so we can share with badly defined apps
|
||||
// e.g. Google Lens declares receiving "image/jpeg" only, but it can actually handle more formats
|
||||
final urisByMimeType = groupBy<ImageEntry, String>(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList()));
|
||||
|
@ -101,7 +91,21 @@ class AndroidAppService {
|
|||
'urisByMimeType': urisByMimeType,
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('share failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
debugPrint('shareEntries failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<bool> shareSingle(String uri, String mimeType) async {
|
||||
try {
|
||||
return await platform.invokeMethod('share', <String, dynamic>{
|
||||
'title': 'Share via:',
|
||||
'urisByMimeType': {
|
||||
mimeType: [uri]
|
||||
},
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('shareSingle failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
112
lib/services/android_debug_service.dart
Normal file
112
lib/services/android_debug_service.dart
Normal 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 {};
|
||||
}
|
||||
}
|
|
@ -17,6 +17,7 @@ class MetadataService {
|
|||
final result = await platform.invokeMethod('getAllMetadata', <String, dynamic>{
|
||||
'mimeType': entry.mimeType,
|
||||
'uri': entry.uri,
|
||||
'sizeBytes': entry.sizeBytes,
|
||||
});
|
||||
return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
|
@ -44,6 +45,7 @@ class MetadataService {
|
|||
'mimeType': entry.mimeType,
|
||||
'uri': entry.uri,
|
||||
'path': entry.path,
|
||||
'sizeBytes': entry.sizeBytes,
|
||||
}) as Map;
|
||||
result['contentId'] = entry.contentId;
|
||||
return CatalogMetadata.fromMap(result);
|
||||
|
@ -69,6 +71,7 @@ class MetadataService {
|
|||
final result = await platform.invokeMethod('getOverlayMetadata', <String, dynamic>{
|
||||
'mimeType': entry.mimeType,
|
||||
'uri': entry.uri,
|
||||
'sizeBytes': entry.sizeBytes,
|
||||
}) as Map;
|
||||
return OverlayMetadata.fromMap(result);
|
||||
} on PlatformException catch (e) {
|
||||
|
@ -77,72 +80,6 @@ class MetadataService {
|
|||
return null;
|
||||
}
|
||||
|
||||
static Future<Map> getBitmapFactoryInfo(ImageEntry entry) async {
|
||||
try {
|
||||
// return map with all data available when decoding image bounds with `BitmapFactory`
|
||||
final result = await platform.invokeMethod('getBitmapFactoryInfo', <String, dynamic>{
|
||||
'uri': entry.uri,
|
||||
}) as Map;
|
||||
return result;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getBitmapFactoryInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
static Future<Map> getContentResolverMetadata(ImageEntry entry) async {
|
||||
try {
|
||||
// return map with all data available from the content resolver
|
||||
final result = await platform.invokeMethod('getContentResolverMetadata', <String, dynamic>{
|
||||
'mimeType': entry.mimeType,
|
||||
'uri': entry.uri,
|
||||
}) as Map;
|
||||
return result;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getContentResolverMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
static Future<Map> getExifInterfaceMetadata(ImageEntry entry) async {
|
||||
try {
|
||||
// return map with all data available from the `ExifInterface` library
|
||||
final result = await platform.invokeMethod('getExifInterfaceMetadata', <String, dynamic>{
|
||||
'uri': entry.uri,
|
||||
}) as Map;
|
||||
return result;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getExifInterfaceMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
static Future<Map> getMediaMetadataRetrieverMetadata(ImageEntry entry) async {
|
||||
try {
|
||||
// return map with all data available from `MediaMetadataRetriever`
|
||||
final result = await platform.invokeMethod('getMediaMetadataRetrieverMetadata', <String, dynamic>{
|
||||
'uri': entry.uri,
|
||||
}) as Map;
|
||||
return result;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getMediaMetadataRetrieverMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
static Future<Map> getMetadataExtractorSummary(ImageEntry entry) async {
|
||||
try {
|
||||
// return map with the mime type and tag count for each directory found by `metadata-extractor`
|
||||
final result = await platform.invokeMethod('getMetadataExtractorSummary', <String, dynamic>{
|
||||
'uri': entry.uri,
|
||||
}) as Map;
|
||||
return result;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getMetadataExtractorSummary failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
static Future<List<Uint8List>> getEmbeddedPictures(String uri) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getEmbeddedPictures', <String, dynamic>{
|
||||
|
@ -155,10 +92,12 @@ class MetadataService {
|
|||
return [];
|
||||
}
|
||||
|
||||
static Future<List<Uint8List>> getExifThumbnails(String uri) async {
|
||||
static Future<List<Uint8List>> getExifThumbnails(ImageEntry entry) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getExifThumbnails', <String, dynamic>{
|
||||
'uri': uri,
|
||||
'mimeType': entry.mimeType,
|
||||
'uri': entry.uri,
|
||||
'sizeBytes': entry.sizeBytes,
|
||||
});
|
||||
return (result as List).cast<Uint8List>();
|
||||
} on PlatformException catch (e) {
|
||||
|
@ -167,16 +106,19 @@ class MetadataService {
|
|||
return [];
|
||||
}
|
||||
|
||||
static Future<List<Uint8List>> getXmpThumbnails(ImageEntry entry) async {
|
||||
static Future<Map> extractXmpDataProp(ImageEntry entry, String propPath, String propMimeType) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getXmpThumbnails', <String, dynamic>{
|
||||
final result = await platform.invokeMethod('extractXmpDataProp', <String, dynamic>{
|
||||
'mimeType': entry.mimeType,
|
||||
'uri': entry.uri,
|
||||
'sizeBytes': entry.sizeBytes,
|
||||
'propPath': propPath,
|
||||
'propMimeType': propMimeType,
|
||||
});
|
||||
return (result as List).cast<Uint8List>();
|
||||
return result;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getXmpThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
debugPrint('extractXmpDataProp failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,6 +36,7 @@ class Durations {
|
|||
|
||||
// info
|
||||
static const mapStyleSwitchAnimation = Duration(milliseconds: 300);
|
||||
static const xmpStructArrayCardTransition = Duration(milliseconds: 300);
|
||||
|
||||
// delays & refresh intervals
|
||||
static const opToastDisplay = Duration(seconds: 2);
|
||||
|
|
|
@ -26,11 +26,9 @@ class AIcons {
|
|||
// actions
|
||||
static const IconData addShortcut = Icons.add_to_home_screen_outlined;
|
||||
static const IconData clear = Icons.clear_outlined;
|
||||
static const IconData collapse = Icons.expand_less_outlined;
|
||||
static const IconData createAlbum = Icons.add_circle_outline;
|
||||
static const IconData debug = Icons.whatshot_outlined;
|
||||
static const IconData delete = Icons.delete_outlined;
|
||||
static const IconData expand = Icons.expand_more_outlined;
|
||||
static const IconData flip = Icons.flip_outlined;
|
||||
static const IconData favourite = Icons.favorite_border;
|
||||
static const IconData favouriteActive = Icons.favorite;
|
||||
|
@ -52,6 +50,10 @@ class AIcons {
|
|||
static const IconData stats = Icons.pie_chart_outlined;
|
||||
static const IconData zoomIn = Icons.add_outlined;
|
||||
static const IconData zoomOut = Icons.remove_outlined;
|
||||
static const IconData collapse = Icons.expand_less_outlined;
|
||||
static const IconData expand = Icons.expand_more_outlined;
|
||||
static const IconData previous = Icons.chevron_left_outlined;
|
||||
static const IconData next = Icons.chevron_right_outlined;
|
||||
|
||||
// albums
|
||||
static const IconData album = Icons.photo_album_outlined;
|
||||
|
@ -61,7 +63,9 @@ class AIcons {
|
|||
|
||||
// thumbnail overlay
|
||||
static const IconData animated = Icons.slideshow;
|
||||
static const IconData geo = Icons.language_outlined;
|
||||
static const IconData play = Icons.play_circle_outline;
|
||||
static const IconData threesixty = Icons.threesixty_outlined;
|
||||
static const IconData selected = Icons.check_circle_outline;
|
||||
static const IconData unselected = Icons.radio_button_unchecked;
|
||||
}
|
||||
|
|
|
@ -18,8 +18,8 @@ class Constants {
|
|||
offset: Offset(0.5, 1.0),
|
||||
);
|
||||
|
||||
static const String overlayUnknown = '—'; // em dash
|
||||
static const String infoUnknown = 'unknown';
|
||||
static const overlayUnknown = '—'; // em dash
|
||||
static const infoUnknown = 'unknown';
|
||||
|
||||
static final pointNemo = LatLng(-48.876667, -123.393333);
|
||||
|
||||
|
@ -209,6 +209,12 @@ class Constants {
|
|||
licenseUrl: 'https://github.com/flutter/packages/blob/master/packages/palette_generator/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/packages/tree/master/packages/palette_generator',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Panorama',
|
||||
license: 'Apache 2.0',
|
||||
licenseUrl: 'https://github.com/zesage/panorama/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/zesage/panorama',
|
||||
),
|
||||
Dependency(
|
||||
name: 'PDF for Dart and Flutter',
|
||||
license: 'Apache 2.0',
|
||||
|
@ -287,6 +293,12 @@ class Constants {
|
|||
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher',
|
||||
),
|
||||
Dependency(
|
||||
name: 'XML',
|
||||
license: 'MIT',
|
||||
licenseUrl: 'https://github.com/renggli/dart-xml/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/renggli/dart-xml',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
9
lib/utils/string_utils.dart
Normal file
9
lib/utils/string_utils.dart
Normal 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();
|
||||
}
|
||||
}
|
|
@ -87,6 +87,7 @@ class _LicensesState extends State<Licenses> {
|
|||
|
||||
Widget _buildHeader() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsetsDirectional.only(start: 8),
|
||||
|
|
|
@ -34,7 +34,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
_showDeleteDialog(context);
|
||||
break;
|
||||
case EntryAction.share:
|
||||
AndroidAppService.share(selection).then((success) {
|
||||
AndroidAppService.shareEntries(selection).then((success) {
|
||||
if (!success) showNoMatchingAppDialog(context);
|
||||
});
|
||||
break;
|
||||
|
|
|
@ -3,7 +3,6 @@ import 'dart:math';
|
|||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/widgets/collection/grid/header_generic.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail_collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
|
@ -15,14 +14,14 @@ class SectionedListLayoutProvider extends StatelessWidget {
|
|||
final Widget Function(ImageEntry entry) thumbnailBuilder;
|
||||
final Widget child;
|
||||
|
||||
SectionedListLayoutProvider({
|
||||
const SectionedListLayoutProvider({
|
||||
@required this.collection,
|
||||
@required this.scrollableWidth,
|
||||
@required this.tileExtent,
|
||||
@required this.columnCount,
|
||||
@required this.thumbnailBuilder,
|
||||
@required this.child,
|
||||
}) : assert(scrollableWidth != 0),
|
||||
columnCount = max((scrollableWidth / tileExtent).round(), ThumbnailCollection.columnCountMin);
|
||||
}) : assert(scrollableWidth != 0);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
|
@ -36,6 +36,7 @@ class ThumbnailEntryOverlay extends StatelessWidget {
|
|||
children: [
|
||||
if (entry.hasGps && settings.showThumbnailLocation) GpsIcon(iconSize: iconSize),
|
||||
if (entry.isRaw && settings.showThumbnailRaw) RawIcon(iconSize: iconSize),
|
||||
if (entry.isGeotiff) GeotiffIcon(iconSize: iconSize),
|
||||
if (entry.isAnimated)
|
||||
AnimatedImageIcon(iconSize: iconSize)
|
||||
else if (entry.isVideo)
|
||||
|
@ -49,7 +50,9 @@ class ThumbnailEntryOverlay extends StatelessWidget {
|
|||
iconSize: iconSize,
|
||||
showDuration: settings.showThumbnailVideoDuration,
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (entry.is360)
|
||||
SphericalImageIcon(iconSize: iconSize),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
|
|
@ -145,7 +145,7 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
|
|||
tag: heroTag,
|
||||
flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) {
|
||||
ImageProvider heroImageProvider = _fastThumbnailProvider;
|
||||
if (!entry.isVideo && !entry.isSvg) {
|
||||
if (!entry.isVideo) {
|
||||
final imageProvider = UriImage(
|
||||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
|
|
|
@ -31,9 +31,9 @@ class ThumbnailCollection extends StatelessWidget {
|
|||
final ValueNotifier<bool> _isScrollingNotifier = ValueNotifier(false);
|
||||
final GlobalKey _scrollableKey = GlobalKey();
|
||||
|
||||
static const columnCountMin = 2;
|
||||
static const columnCountDefault = 4;
|
||||
static const extentMin = 46.0;
|
||||
static const spacing = 0.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
@ -47,11 +47,10 @@ class ThumbnailCollection extends StatelessWidget {
|
|||
|
||||
final tileExtentManager = TileExtentManager(
|
||||
settingsRouteKey: context.currentRouteName,
|
||||
columnCountMin: columnCountMin,
|
||||
extentNotifier: _tileExtentNotifier,
|
||||
columnCountDefault: columnCountDefault,
|
||||
extentMin: extentMin,
|
||||
extentNotifier: _tileExtentNotifier,
|
||||
spacing: 0,
|
||||
spacing: spacing,
|
||||
)..applyTileExtent(viewportSize: viewportSize);
|
||||
final cacheExtent = tileExtentManager.getEffectiveExtentMax(viewportSize) * 2;
|
||||
|
||||
|
@ -77,7 +76,18 @@ class ThumbnailCollection extends StatelessWidget {
|
|||
scrollableKey: _scrollableKey,
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
viewportSize: viewportSize,
|
||||
showScaledGrid: true,
|
||||
gridBuilder: (center, extent, child) => CustomPaint(
|
||||
// painting the thumbnail half-border on top of the grid yields artifacts,
|
||||
// so we use a `foregroundPainter` to cover them instead
|
||||
foregroundPainter: GridPainter(
|
||||
center: center,
|
||||
extent: extent,
|
||||
spacing: tileExtentManager.spacing,
|
||||
strokeWidth: DecoratedThumbnail.borderWidth * 2,
|
||||
color: DecoratedThumbnail.borderColor,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
scaledBuilder: (entry, extent) => DecoratedThumbnail(
|
||||
entry: entry,
|
||||
extent: extent,
|
||||
|
@ -98,6 +108,7 @@ class ThumbnailCollection extends StatelessWidget {
|
|||
collection: collection,
|
||||
scrollableWidth: viewportSize.width,
|
||||
tileExtent: tileExtent,
|
||||
columnCount: tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent),
|
||||
thumbnailBuilder: (entry) => GridThumbnail(
|
||||
key: ValueKey(entry.contentId),
|
||||
collection: collection,
|
||||
|
|
|
@ -56,7 +56,7 @@ mixin FeedbackMixin {
|
|||
stream: opStream,
|
||||
builder: (context, snapshot) {
|
||||
Widget child = SizedBox.shrink();
|
||||
if (!snapshot.hasError && snapshot.connectionState == ConnectionState.active) {
|
||||
if (!snapshot.hasError) {
|
||||
final percent = processed.length.toDouble() / selection.length;
|
||||
child = CircularPercentIndicator(
|
||||
percent: percent,
|
||||
|
|
57
lib/widgets/common/basic/multi_cross_fader.dart
Normal file
57
lib/widgets/common/basic/multi_cross_fader.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -2,6 +2,8 @@ import 'dart:ui';
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
final _filter = ImageFilter.blur(sigmaX: 4, sigmaY: 4);
|
||||
|
||||
class BlurredRect extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
|
@ -11,7 +13,7 @@ class BlurredRect extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4),
|
||||
filter: _filter,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
|
@ -29,7 +31,7 @@ class BlurredRRect extends StatelessWidget {
|
|||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4),
|
||||
filter: _filter,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
|
@ -45,7 +47,7 @@ class BlurredOval extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return ClipOval(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4),
|
||||
filter: _filter,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
|
|
|
@ -1,11 +1,18 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class AvesCircleBorder {
|
||||
static BoxBorder build(BuildContext context) {
|
||||
final subPixel = MediaQuery.of(context).devicePixelRatio > 2;
|
||||
return Border.all(
|
||||
color: Colors.white30,
|
||||
width: subPixel ? 0.5 : 1.0,
|
||||
static const borderColor = Colors.white30;
|
||||
|
||||
static double _borderWidth(BuildContext context) => MediaQuery.of(context).devicePixelRatio > 2 ? 0.5 : 1.0;
|
||||
|
||||
static Border build(BuildContext context) {
|
||||
return Border.fromBorderSide(buildSide(context));
|
||||
}
|
||||
|
||||
static BorderSide buildSide(BuildContext context) {
|
||||
return BorderSide(
|
||||
color: borderColor,
|
||||
width: _borderWidth(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,7 +30,6 @@ class AvesExpansionTile extends StatelessWidget {
|
|||
title: HighlightTitle(
|
||||
title,
|
||||
color: color,
|
||||
fontSize: 18,
|
||||
enabled: enabled,
|
||||
),
|
||||
expandable: enabled,
|
||||
|
|
|
@ -23,9 +23,10 @@ class VideoIcon extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return OverlayIcon(
|
||||
icon: AIcons.play,
|
||||
icon: entry.is360 ? AIcons.threesixty : AIcons.play,
|
||||
size: iconSize,
|
||||
text: showDuration ? entry.durationText : null,
|
||||
iconScale: entry.is360 && showDuration ? .9 : 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -45,6 +46,34 @@ class AnimatedImageIcon extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
class GeotiffIcon extends StatelessWidget {
|
||||
final double iconSize;
|
||||
|
||||
const GeotiffIcon({Key key, this.iconSize}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return OverlayIcon(
|
||||
icon: AIcons.geo,
|
||||
size: iconSize,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SphericalImageIcon extends StatelessWidget {
|
||||
final double iconSize;
|
||||
|
||||
const SphericalImageIcon({Key key, this.iconSize}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return OverlayIcon(
|
||||
icon: AIcons.threesixty,
|
||||
size: iconSize,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GpsIcon extends StatelessWidget {
|
||||
final double iconSize;
|
||||
|
||||
|
|
|
@ -3,46 +3,55 @@ import 'package:aves/widgets/common/fx/highlight_decoration.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class HighlightTitle extends StatelessWidget {
|
||||
final String name;
|
||||
final String title;
|
||||
final Color color;
|
||||
final double fontSize;
|
||||
final bool enabled;
|
||||
final bool enabled, selectable;
|
||||
|
||||
const HighlightTitle(
|
||||
this.name, {
|
||||
this.title, {
|
||||
this.color,
|
||||
this.fontSize = 20,
|
||||
this.fontSize = 18,
|
||||
this.enabled = true,
|
||||
}) : assert(name != null);
|
||||
this.selectable = false,
|
||||
}) : assert(title != null);
|
||||
|
||||
static const disabledColor = Colors.grey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = TextStyle(
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black,
|
||||
offset: Offset(1, 1),
|
||||
blurRadius: 2,
|
||||
)
|
||||
],
|
||||
fontSize: fontSize,
|
||||
fontFamily: 'Concourse Caps',
|
||||
);
|
||||
|
||||
return Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: Container(
|
||||
decoration: HighlightDecoration(
|
||||
color: enabled ? color ?? stringToColor(name) : disabledColor,
|
||||
color: enabled ? color ?? stringToColor(title) : disabledColor,
|
||||
),
|
||||
margin: EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black,
|
||||
offset: Offset(1, 1),
|
||||
blurRadius: 2,
|
||||
child: selectable
|
||||
? SelectableText(
|
||||
title,
|
||||
style: style,
|
||||
maxLines: 1,
|
||||
)
|
||||
],
|
||||
fontSize: fontSize,
|
||||
fontFamily: 'Concourse Caps',
|
||||
),
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
),
|
||||
: Text(
|
||||
title,
|
||||
style: style,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@ import 'dart:math';
|
|||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/common/tile_extent_manager.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -20,7 +19,7 @@ class GridScaleGestureDetector<T> extends StatefulWidget {
|
|||
final GlobalKey scrollableKey;
|
||||
final ValueNotifier<double> appBarHeightNotifier;
|
||||
final Size viewportSize;
|
||||
final bool showScaledGrid;
|
||||
final Widget Function(Offset center, double extent, Widget child) gridBuilder;
|
||||
final Widget Function(T item, double extent) scaledBuilder;
|
||||
final Rect Function(BuildContext context, T item) getScaledItemTileRect;
|
||||
final void Function(T item) onScaled;
|
||||
|
@ -31,7 +30,7 @@ class GridScaleGestureDetector<T> extends StatefulWidget {
|
|||
@required this.scrollableKey,
|
||||
@required this.appBarHeightNotifier,
|
||||
@required this.viewportSize,
|
||||
@required this.showScaledGrid,
|
||||
this.gridBuilder,
|
||||
@required this.scaledBuilder,
|
||||
@required this.getScaledItemTileRect,
|
||||
@required this.onScaled,
|
||||
|
@ -56,10 +55,6 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onHorizontalDragStart: (details) {
|
||||
// if `onHorizontalDragStart` callback is not defined,
|
||||
// horizontal drag gestures are interpreted as scaling
|
||||
},
|
||||
onScaleStart: (details) {
|
||||
// the gesture detector wrongly detects a new scaling gesture
|
||||
// when scaling ends and we apply the new extent, so we prevent this
|
||||
|
@ -91,10 +86,9 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
|
|||
builder: (context) => ScaleOverlay(
|
||||
builder: (extent) => widget.scaledBuilder(_metadata.item, extent),
|
||||
center: thumbnailCenter,
|
||||
gridWidth: gridWidth,
|
||||
spacing: tileExtentManager.spacing,
|
||||
viewportWidth: gridWidth,
|
||||
gridBuilder: widget.gridBuilder,
|
||||
scaledExtentNotifier: _scaledExtentNotifier,
|
||||
showScaledGrid: widget.showScaledGrid,
|
||||
),
|
||||
);
|
||||
Overlay.of(scrollableContext).insert(_overlayEntry);
|
||||
|
@ -133,7 +127,16 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
|
|||
});
|
||||
}
|
||||
},
|
||||
child: widget.child,
|
||||
child: GestureDetector(
|
||||
// Horizontal/vertical drag gestures are interpreted as scaling
|
||||
// if they are not handled by `onHorizontalDragStart`/`onVerticalDragStart`
|
||||
// at the scaling `GestureDetector` level, or handled beforehand down the widget tree.
|
||||
// Setting `onHorizontalDragStart`, `onVerticalDragStart`, and `onScaleStart`
|
||||
// all at once is not allowed, so we use another `GestureDetector` for that.
|
||||
onVerticalDragStart: (details) {},
|
||||
onHorizontalDragStart: (details) {},
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -157,18 +160,16 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
|
|||
class ScaleOverlay extends StatefulWidget {
|
||||
final Widget Function(double extent) builder;
|
||||
final Offset center;
|
||||
final double gridWidth;
|
||||
final double spacing;
|
||||
final double viewportWidth;
|
||||
final ValueNotifier<double> scaledExtentNotifier;
|
||||
final bool showScaledGrid;
|
||||
final Widget Function(Offset center, double extent, Widget child) gridBuilder;
|
||||
|
||||
const ScaleOverlay({
|
||||
@required this.builder,
|
||||
@required this.center,
|
||||
@required this.gridWidth,
|
||||
@required this.spacing,
|
||||
@required this.viewportWidth,
|
||||
@required this.scaledExtentNotifier,
|
||||
@required this.showScaledGrid,
|
||||
this.gridBuilder,
|
||||
});
|
||||
|
||||
@override
|
||||
|
@ -180,7 +181,7 @@ class _ScaleOverlayState extends State<ScaleOverlay> {
|
|||
|
||||
Offset get center => widget.center;
|
||||
|
||||
double get gridWidth => widget.gridWidth;
|
||||
double get gridWidth => widget.viewportWidth;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
@ -241,16 +242,7 @@ class _ScaleOverlayState extends State<ScaleOverlay> {
|
|||
),
|
||||
],
|
||||
);
|
||||
if (widget.showScaledGrid) {
|
||||
child = CustomPaint(
|
||||
painter: GridPainter(
|
||||
center: clampedCenter,
|
||||
extent: extent,
|
||||
spacing: widget.spacing,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
child = widget.gridBuilder?.call(clampedCenter, extent, child) ?? child;
|
||||
return child;
|
||||
},
|
||||
),
|
||||
|
@ -263,31 +255,36 @@ class _ScaleOverlayState extends State<ScaleOverlay> {
|
|||
class GridPainter extends CustomPainter {
|
||||
final Offset center;
|
||||
final double extent, spacing;
|
||||
final double strokeWidth;
|
||||
final Color color;
|
||||
|
||||
const GridPainter({
|
||||
@required this.center,
|
||||
@required this.extent,
|
||||
@required this.spacing,
|
||||
this.spacing = 0.0,
|
||||
this.strokeWidth = 1.0,
|
||||
@required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final radius = extent * 3;
|
||||
final paint = Paint()
|
||||
..strokeWidth = DecoratedThumbnail.borderWidth
|
||||
..strokeWidth = strokeWidth
|
||||
..shader = ui.Gradient.radial(
|
||||
center,
|
||||
size.width * .7,
|
||||
radius,
|
||||
[
|
||||
DecoratedThumbnail.borderColor,
|
||||
color,
|
||||
Colors.transparent,
|
||||
],
|
||||
[
|
||||
min(.5, 2 * extent / size.width),
|
||||
extent / radius,
|
||||
1,
|
||||
],
|
||||
);
|
||||
void draw(Offset topLeft) {
|
||||
for (var i = -2; i <= 3; i++) {
|
||||
for (var i = -1; i <= 2; i++) {
|
||||
final ref = (extent + spacing) * i;
|
||||
canvas.drawLine(Offset(0, topLeft.dy + ref), Offset(size.width, topLeft.dy + ref), paint);
|
||||
canvas.drawLine(Offset(topLeft.dx + ref, 0), Offset(topLeft.dx + ref, size.height), paint);
|
||||
|
|
|
@ -6,15 +6,16 @@ import 'package:flutter/widgets.dart';
|
|||
class TileExtentManager {
|
||||
final String settingsRouteKey;
|
||||
final int columnCountMin, columnCountDefault;
|
||||
final double spacing, extentMin;
|
||||
final double spacing, extentMin, extentMax;
|
||||
final ValueNotifier<double> extentNotifier;
|
||||
|
||||
const TileExtentManager({
|
||||
@required this.settingsRouteKey,
|
||||
@required this.columnCountMin,
|
||||
@required this.extentNotifier,
|
||||
this.columnCountMin = 2,
|
||||
@required this.columnCountDefault,
|
||||
@required this.extentMin,
|
||||
@required this.extentNotifier,
|
||||
this.extentMax = 300,
|
||||
@required this.spacing,
|
||||
});
|
||||
|
||||
|
@ -46,7 +47,7 @@ class TileExtentManager {
|
|||
return newExtent;
|
||||
}
|
||||
|
||||
double _extentMax(Size viewportSize) => (viewportSize.shortestSide - spacing * (columnCountMin - 1)) / columnCountMin;
|
||||
double _extentMax(Size viewportSize) => min(extentMax, (viewportSize.shortestSide - spacing * (columnCountMin - 1)) / columnCountMin);
|
||||
|
||||
double _columnCountForExtent(Size viewportSize, double extent) => (viewportSize.width + spacing) / (extent + spacing);
|
||||
|
||||
|
|
47
lib/widgets/debug/android_dirs.dart
Normal file
47
lib/widgets/debug/android_dirs.dart
Normal 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;
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import 'dart:collection';
|
||||
|
||||
import 'package:aves/services/android_app_service.dart';
|
||||
import 'package:aves/services/android_debug_service.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/common.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -16,7 +16,7 @@ class _DebugAndroidEnvironmentSectionState extends State<DebugAndroidEnvironment
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loader = AndroidAppService.getEnv();
|
||||
_loader = AndroidDebugService.getEnv();
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'package:aves/model/image_entry.dart';
|
|||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/debug/android_dirs.dart';
|
||||
import 'package:aves/widgets/debug/android_env.dart';
|
||||
import 'package:aves/widgets/debug/cache.dart';
|
||||
import 'package:aves/widgets/debug/database.dart';
|
||||
|
@ -41,6 +42,7 @@ class AppDebugPageState extends State<AppDebugPage> {
|
|||
padding: EdgeInsets.all(8),
|
||||
children: [
|
||||
_buildGeneralTabView(),
|
||||
DebugAndroidDirSection(),
|
||||
DebugAndroidEnvironmentSection(),
|
||||
DebugCacheSection(),
|
||||
DebugAppDatabaseSection(),
|
||||
|
|
|
@ -37,9 +37,12 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
final ValueNotifier<double> _tileExtentNotifier = ValueNotifier(0);
|
||||
final GlobalKey _scrollableKey = GlobalKey();
|
||||
|
||||
static const columnCountDefault = 2;
|
||||
static const extentMin = 60.0;
|
||||
static const spacing = 8.0;
|
||||
|
||||
FilterGridPage({
|
||||
Key key,
|
||||
@required this.source,
|
||||
@required this.appBar,
|
||||
@required this.filterEntries,
|
||||
|
@ -50,7 +53,7 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
double appBarHeight = kToolbarHeight,
|
||||
@required this.onTap,
|
||||
this.onLongPress,
|
||||
}) {
|
||||
}) : super(key: key) {
|
||||
_appBarHeightNotifier.value = appBarHeight;
|
||||
}
|
||||
|
||||
|
@ -71,10 +74,9 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
|
||||
final tileExtentManager = TileExtentManager(
|
||||
settingsRouteKey: settingsRouteKey ?? context.currentRouteName,
|
||||
columnCountMin: 2,
|
||||
columnCountDefault: 2,
|
||||
extentMin: 60,
|
||||
extentNotifier: _tileExtentNotifier,
|
||||
columnCountDefault: columnCountDefault,
|
||||
extentMin: extentMin,
|
||||
spacing: spacing,
|
||||
)..applyTileExtent(viewportSize: viewportSize);
|
||||
|
||||
|
@ -98,7 +100,15 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
scrollableKey: _scrollableKey,
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
viewportSize: viewportSize,
|
||||
showScaledGrid: true,
|
||||
gridBuilder: (center, extent, child) => CustomPaint(
|
||||
painter: GridPainter(
|
||||
center: center,
|
||||
extent: extent,
|
||||
spacing: tileExtentManager.spacing,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
scaledBuilder: (item, extent) {
|
||||
final filter = item.filter;
|
||||
return SizedBox(
|
||||
|
|
|
@ -44,6 +44,7 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FilterGridPage<T>(
|
||||
key: ValueKey('filter-grid-page'),
|
||||
source: source,
|
||||
appBar: SliverAppBar(
|
||||
title: TappableAppBarTitle(
|
||||
|
|
|
@ -2,7 +2,8 @@ import 'dart:collection';
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/services/metadata_service.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/services/android_debug_service.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/common.dart';
|
||||
|
@ -18,7 +19,7 @@ class MetadataTab extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _MetadataTabState extends State<MetadataTab> {
|
||||
Future<Map> _bitmapFactoryLoader, _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader, _metadataExtractorLoader;
|
||||
Future<Map> _bitmapFactoryLoader, _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader, _metadataExtractorLoader, _tiffStructureLoader;
|
||||
|
||||
// MediaStore timestamp keys
|
||||
static const secondTimestampKeys = ['date_added', 'date_modified', 'date_expires', 'isPlayed'];
|
||||
|
@ -33,20 +34,19 @@ class _MetadataTabState extends State<MetadataTab> {
|
|||
}
|
||||
|
||||
void _loadMetadata() {
|
||||
_bitmapFactoryLoader = MetadataService.getBitmapFactoryInfo(entry);
|
||||
_contentResolverMetadataLoader = MetadataService.getContentResolverMetadata(entry);
|
||||
_exifInterfaceMetadataLoader = MetadataService.getExifInterfaceMetadata(entry);
|
||||
_mediaMetadataLoader = MetadataService.getMediaMetadataRetrieverMetadata(entry);
|
||||
_metadataExtractorLoader = MetadataService.getMetadataExtractorSummary(entry);
|
||||
_bitmapFactoryLoader = AndroidDebugService.getBitmapFactoryInfo(entry);
|
||||
_contentResolverMetadataLoader = AndroidDebugService.getContentResolverMetadata(entry);
|
||||
_exifInterfaceMetadataLoader = AndroidDebugService.getExifInterfaceMetadata(entry);
|
||||
_mediaMetadataLoader = AndroidDebugService.getMediaMetadataRetrieverMetadata(entry);
|
||||
_metadataExtractorLoader = AndroidDebugService.getMetadataExtractorSummary(entry);
|
||||
_tiffStructureLoader = AndroidDebugService.getTiffStructure(entry);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget builder(BuildContext context, AsyncSnapshot<Map> snapshot, String title) {
|
||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
|
||||
final data = SplayTreeMap.of(snapshot.data.map((k, v) {
|
||||
Widget builderFromSnapshotData(BuildContext context, Map snapshotData, String title) {
|
||||
final data = SplayTreeMap.of(snapshotData.map((k, v) {
|
||||
final key = k.toString();
|
||||
var value = v?.toString() ?? 'null';
|
||||
if ([...secondTimestampKeys, ...millisecondTimestampKeys].contains(key) && v is num && v != 0) {
|
||||
|
@ -76,29 +76,47 @@ class _MetadataTabState extends State<MetadataTab> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget builderFromSnapshot(BuildContext context, AsyncSnapshot<Map> snapshot, String title) {
|
||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
|
||||
return builderFromSnapshotData(context, snapshot.data, title);
|
||||
}
|
||||
|
||||
return ListView(
|
||||
padding: EdgeInsets.all(8),
|
||||
children: [
|
||||
FutureBuilder<Map>(
|
||||
future: _bitmapFactoryLoader,
|
||||
builder: (context, snapshot) => builder(context, snapshot, 'Bitmap Factory'),
|
||||
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Bitmap Factory'),
|
||||
),
|
||||
FutureBuilder<Map>(
|
||||
future: _contentResolverMetadataLoader,
|
||||
builder: (context, snapshot) => builder(context, snapshot, 'Content Resolver'),
|
||||
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Content Resolver'),
|
||||
),
|
||||
FutureBuilder<Map>(
|
||||
future: _exifInterfaceMetadataLoader,
|
||||
builder: (context, snapshot) => builder(context, snapshot, 'Exif Interface'),
|
||||
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Exif Interface'),
|
||||
),
|
||||
FutureBuilder<Map>(
|
||||
future: _mediaMetadataLoader,
|
||||
builder: (context, snapshot) => builder(context, snapshot, 'Media Metadata Retriever'),
|
||||
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Media Metadata Retriever'),
|
||||
),
|
||||
FutureBuilder<Map>(
|
||||
future: _metadataExtractorLoader,
|
||||
builder: (context, snapshot) => builder(context, snapshot, 'Metadata Extractor'),
|
||||
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Metadata Extractor'),
|
||||
),
|
||||
if (entry.mimeType == MimeTypes.tiff)
|
||||
FutureBuilder<Map>(
|
||||
future: _tiffStructureLoader,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: snapshot.data.entries.map((kv) => builderFromSnapshotData(context, kv.value as Map, 'TIFF ${kv.key}')).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:aves/image_providers/uri_image_provider.dart';
|
||||
import 'package:aves/model/actions/entry_actions.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
|
@ -77,7 +79,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
|||
});
|
||||
break;
|
||||
case EntryAction.share:
|
||||
AndroidAppService.share({entry}).then((success) {
|
||||
AndroidAppService.shareEntries({entry}).then((success) {
|
||||
if (!success) showNoMatchingAppDialog(context);
|
||||
});
|
||||
break;
|
||||
|
@ -202,7 +204,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
|||
context,
|
||||
MaterialPageRoute(
|
||||
settings: RouteSettings(name: SourceViewerPage.routeName),
|
||||
builder: (context) => SourceViewerPage(entry: entry),
|
||||
builder: (context) => SourceViewerPage(
|
||||
loader: () => ImageFileService.getImage(entry.uri, entry.mimeType, 0, false).then(utf8.decode),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -5,8 +5,8 @@ import 'package:aves/model/image_entry.dart';
|
|||
import 'package:aves/model/settings/screen_on.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:aves/widgets/collection/collection_page.dart';
|
||||
import 'package:aves/widgets/fullscreen/entry_action_delegate.dart';
|
||||
import 'package:aves/widgets/fullscreen/image_page.dart';
|
||||
|
@ -14,6 +14,7 @@ import 'package:aves/widgets/fullscreen/image_view.dart';
|
|||
import 'package:aves/widgets/fullscreen/info/info_page.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/notifications.dart';
|
||||
import 'package:aves/widgets/fullscreen/overlay/bottom.dart';
|
||||
import 'package:aves/widgets/fullscreen/overlay/panorama.dart';
|
||||
import 'package:aves/widgets/fullscreen/overlay/top.dart';
|
||||
import 'package:aves/widgets/fullscreen/overlay/video.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
@ -223,22 +224,33 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
|
|||
Widget bottomOverlay = ValueListenableBuilder<ImageEntry>(
|
||||
valueListenable: _entryNotifier,
|
||||
builder: (context, entry, child) {
|
||||
Widget videoOverlay;
|
||||
if (entry != null) {
|
||||
final videoController = entry.isVideo ? _videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2 : null;
|
||||
if (entry == null) return SizedBox.shrink();
|
||||
|
||||
Widget extraBottomOverlay;
|
||||
if (entry.isVideo) {
|
||||
final videoController = _videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
|
||||
if (videoController != null) {
|
||||
videoOverlay = VideoControlOverlay(
|
||||
extraBottomOverlay = VideoControlOverlay(
|
||||
entry: entry,
|
||||
controller: videoController,
|
||||
scale: _bottomOverlayScale,
|
||||
viewInsets: _frozenViewInsets,
|
||||
viewPadding: _frozenViewPadding,
|
||||
);
|
||||
}
|
||||
} else if (entry.is360) {
|
||||
extraBottomOverlay = PanoramaOverlay(
|
||||
entry: entry,
|
||||
scale: _bottomOverlayScale,
|
||||
);
|
||||
}
|
||||
|
||||
final child = Column(
|
||||
children: [
|
||||
if (videoOverlay != null) videoOverlay,
|
||||
if (extraBottomOverlay != null)
|
||||
ExtraBottomOverlay(
|
||||
viewInsets: _frozenViewInsets,
|
||||
viewPadding: _frozenViewPadding,
|
||||
child: extraBottomOverlay,
|
||||
),
|
||||
SlideTransition(
|
||||
position: _bottomOverlayOffset,
|
||||
child: FullscreenBottomOverlay(
|
||||
|
@ -255,7 +267,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
|
|||
valueListenable: _overlayAnimationController,
|
||||
builder: (context, animation, child) {
|
||||
return Visibility(
|
||||
visible: entry != null && _overlayAnimationController.status != AnimationStatus.dismissed,
|
||||
visible: _overlayAnimationController.status != AnimationStatus.dismissed,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
|
|
|
@ -96,6 +96,8 @@ class FullscreenDebugPage extends StatelessWidget {
|
|||
'isVideo': '${entry.isVideo}',
|
||||
'isCatalogued': '${entry.isCatalogued}',
|
||||
'isAnimated': '${entry.isAnimated}',
|
||||
'isGeotiff': '${entry.isGeotiff}',
|
||||
'is360': '${entry.is360}',
|
||||
'canEdit': '${entry.canEdit}',
|
||||
'canEditExif': '${entry.canEditExif}',
|
||||
'canPrint': '${entry.canPrint}',
|
||||
|
|
|
@ -48,7 +48,7 @@ class SingleFullscreenPage extends StatelessWidget {
|
|||
body: FullscreenBody(
|
||||
initialEntry: entry,
|
||||
),
|
||||
backgroundColor: Colors.black,
|
||||
backgroundColor: Navigator.canPop(context) ? Colors.transparent : Colors.black,
|
||||
resizeToAvoidBottomInset: false,
|
||||
),
|
||||
);
|
||||
|
|
|
@ -205,7 +205,6 @@ class _ImageViewState extends State<ImageView> {
|
|||
mimeType: entry.mimeType,
|
||||
colorFilter: colorFilter,
|
||||
),
|
||||
placeholderBuilder: (context) => _loadingBuilder(context, fastThumbnailProvider),
|
||||
),
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
controller: _photoViewController,
|
||||
|
|
|
@ -33,17 +33,23 @@ class BasicSection extends StatelessWidget {
|
|||
final showMegaPixels = entry.isPhoto && entry.megaPixels != null && entry.megaPixels > 0;
|
||||
final resolutionText = '${entry.resolutionText}${showMegaPixels ? ' (${entry.megaPixels} MP)' : ''}';
|
||||
|
||||
// TODO TLAD line break on all characters for the following fields when this is fixed: https://github.com/flutter/flutter/issues/61081
|
||||
// inserting ZWSP (\u200B) between characters does help, but it messes with width and height computation (another Flutter issue)
|
||||
final title = entry.bestTitle ?? Constants.infoUnknown;
|
||||
final uri = entry.uri ?? Constants.infoUnknown;
|
||||
final path = entry.path;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InfoRowGroup({
|
||||
'Title': entry.bestTitle ?? Constants.infoUnknown,
|
||||
'Title': title,
|
||||
'Date': dateText,
|
||||
if (entry.isVideo) ..._buildVideoRows(),
|
||||
if (!entry.isSvg) 'Resolution': resolutionText,
|
||||
'Size': entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : Constants.infoUnknown,
|
||||
'URI': entry.uri ?? Constants.infoUnknown,
|
||||
if (entry.path != null) 'Path': entry.path,
|
||||
'URI': uri,
|
||||
if (path != null) 'Path': path,
|
||||
}),
|
||||
_buildChips(),
|
||||
],
|
||||
|
@ -54,8 +60,10 @@ class BasicSection extends StatelessWidget {
|
|||
final tags = entry.xmpSubjects..sort(compareAsciiUpperCase);
|
||||
final album = entry.directory;
|
||||
final filters = [
|
||||
if (entry.isVideo) MimeFilter(MimeTypes.anyVideo),
|
||||
if (entry.isAnimated) MimeFilter(MimeFilter.animated),
|
||||
if (entry.isImage && entry.is360) MimeFilter(MimeFilter.panorama),
|
||||
if (entry.isVideo) MimeFilter(entry.is360 ? MimeFilter.sphericalVideo : MimeTypes.anyVideo),
|
||||
if (entry.isGeotiff) MimeFilter(MimeFilter.geotiff),
|
||||
if (album != null) AlbumFilter(album, collection?.source?.getUniqueAlbumName(album)),
|
||||
...tags.map((tag) => TagFilter(tag)),
|
||||
];
|
||||
|
|
|
@ -40,10 +40,12 @@ class SectionRow extends StatelessWidget {
|
|||
class InfoRowGroup extends StatefulWidget {
|
||||
final Map<String, String> keyValues;
|
||||
final int maxValueLength;
|
||||
final Map<String, InfoLinkHandler> linkHandlers;
|
||||
|
||||
const InfoRowGroup(
|
||||
this.keyValues, {
|
||||
this.maxValueLength = 0,
|
||||
this.linkHandlers,
|
||||
});
|
||||
|
||||
@override
|
||||
|
@ -57,9 +59,13 @@ class _InfoRowGroupState extends State<InfoRowGroup> {
|
|||
|
||||
int get maxValueLength => widget.maxValueLength;
|
||||
|
||||
Map<String, InfoLinkHandler> get linkHandlers => widget.linkHandlers;
|
||||
|
||||
static const keyValuePadding = 16;
|
||||
static const linkColor = Colors.blue;
|
||||
static final baseStyle = TextStyle(fontFamily: 'Concourse');
|
||||
static final keyStyle = baseStyle.copyWith(color: Colors.white70, height: 1.7);
|
||||
static final linkStyle = baseStyle.copyWith(color: linkColor, decoration: TextDecoration.underline);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
@ -85,11 +91,29 @@ class _InfoRowGroupState extends State<InfoRowGroup> {
|
|||
children: keyValues.entries.expand(
|
||||
(kv) {
|
||||
final key = kv.key;
|
||||
var value = kv.value;
|
||||
// long values are clipped, and made expandable by tapping them
|
||||
final showPreviewOnly = maxValueLength > 0 && value.length > maxValueLength && !_expandedKeys.contains(key);
|
||||
if (showPreviewOnly) {
|
||||
value = '${value.substring(0, maxValueLength)}…';
|
||||
String value;
|
||||
TextStyle style;
|
||||
GestureRecognizer recognizer;
|
||||
|
||||
if (linkHandlers?.containsKey(key) == true) {
|
||||
final handler = linkHandlers[key];
|
||||
value = handler.linkText;
|
||||
// open link on tap
|
||||
recognizer = TapGestureRecognizer()..onTap = () => handler.onTap(context);
|
||||
style = linkStyle;
|
||||
} else {
|
||||
value = kv.value;
|
||||
// long values are clipped, and made expandable by tapping them
|
||||
final showPreviewOnly = maxValueLength > 0 && value.length > maxValueLength && !_expandedKeys.contains(key);
|
||||
if (showPreviewOnly) {
|
||||
value = '${value.substring(0, maxValueLength)}…';
|
||||
// show full value on tap
|
||||
recognizer = TapGestureRecognizer()..onTap = () => setState(() => _expandedKeys.add(key));
|
||||
}
|
||||
}
|
||||
|
||||
if (key != lastKey) {
|
||||
value = '$value\n';
|
||||
}
|
||||
|
||||
// as of Flutter v1.22.4, `SelectableText` cannot contain `WidgetSpan`
|
||||
|
@ -98,9 +122,9 @@ class _InfoRowGroupState extends State<InfoRowGroup> {
|
|||
final spaceCount = (100 * thisSpaceSize / baseSpaceWidth).round();
|
||||
|
||||
return [
|
||||
TextSpan(text: '$key', style: keyStyle),
|
||||
TextSpan(text: key, style: keyStyle),
|
||||
TextSpan(text: '\u200A' * spaceCount),
|
||||
TextSpan(text: '$value${key == lastKey ? '' : '\n'}', recognizer: showPreviewOnly ? _buildTapRecognizer(key) : null),
|
||||
TextSpan(text: value, style: style, recognizer: recognizer),
|
||||
];
|
||||
},
|
||||
).toList(),
|
||||
|
@ -121,8 +145,14 @@ class _InfoRowGroupState extends State<InfoRowGroup> {
|
|||
)..layout(BoxConstraints(), parentUsesSize: true);
|
||||
return para.getMaxIntrinsicWidth(double.infinity);
|
||||
}
|
||||
|
||||
GestureRecognizer _buildTapRecognizer(String key) {
|
||||
return TapGestureRecognizer()..onTap = () => setState(() => _expandedKeys.add(key));
|
||||
}
|
||||
}
|
||||
|
||||
class InfoLinkHandler {
|
||||
final String linkText;
|
||||
final void Function(BuildContext context) onTap;
|
||||
|
||||
const InfoLinkHandler({
|
||||
@required this.linkText,
|
||||
@required this.onTap,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import 'package:aves/utils/constants.dart';
|
|||
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/common.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/metadata/svg_tile.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/metadata/xmp_tile.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
@ -86,42 +87,49 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
|||
// warning: placing the `AnimationLimiter` as a parent to the `ScrollView`
|
||||
// triggers dispose & reinitialization of other sections, including heavy widgets like maps
|
||||
return SliverToBoxAdapter(
|
||||
child: AnimatedBuilder(
|
||||
animation: _loadedMetadataUri,
|
||||
builder: (context, child) {
|
||||
Widget content;
|
||||
if (_metadata.isEmpty) {
|
||||
content = SizedBox.shrink();
|
||||
} else {
|
||||
content = Column(
|
||||
children: AnimationConfiguration.toStaggeredList(
|
||||
duration: Durations.staggeredAnimation,
|
||||
delay: Durations.staggeredAnimationDelay,
|
||||
childAnimationBuilder: (child) => SlideAnimation(
|
||||
verticalOffset: 50.0,
|
||||
child: FadeInAnimation(
|
||||
child: child,
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
// cancel notification bubbling so that the info page
|
||||
// does not misinterpret content scrolling for page scrolling
|
||||
onNotification: (notification) => true,
|
||||
child: AnimatedBuilder(
|
||||
animation: _loadedMetadataUri,
|
||||
builder: (context, child) {
|
||||
Widget content;
|
||||
if (_metadata.isEmpty) {
|
||||
content = SizedBox.shrink();
|
||||
} else {
|
||||
content = Column(
|
||||
children: AnimationConfiguration.toStaggeredList(
|
||||
duration: Durations.staggeredAnimation,
|
||||
delay: Durations.staggeredAnimationDelay,
|
||||
childAnimationBuilder: (child) => SlideAnimation(
|
||||
verticalOffset: 50.0,
|
||||
child: FadeInAnimation(
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
SectionRow(AIcons.info),
|
||||
..._metadata.entries.map((kv) => _buildDirTile(kv.key, kv.value)),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
SectionRow(AIcons.info),
|
||||
..._metadata.entries.map((kv) => _buildDirTile(kv.key, kv.value)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return AnimationLimiter(
|
||||
// we update the limiter key after fetching the metadata of a new entry,
|
||||
// in order to restart the staggered animation of the metadata section
|
||||
key: Key(_loadedMetadataUri.value),
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
return AnimationLimiter(
|
||||
// we update the limiter key after fetching the metadata of a new entry,
|
||||
// in order to restart the staggered animation of the metadata section
|
||||
key: Key(_loadedMetadataUri.value),
|
||||
child: content,
|
||||
);
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDirTile(String title, _MetadataDirectory dir) {
|
||||
if (dir.tags.isEmpty) return SizedBox.shrink();
|
||||
|
||||
final dirName = dir.name;
|
||||
if (dirName == xmpDirectory) {
|
||||
return XmpDirTile(
|
||||
|
@ -130,6 +138,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
|||
expandedNotifier: _expandedDirectoryNotifier,
|
||||
);
|
||||
}
|
||||
|
||||
Widget thumbnail;
|
||||
final prefixChildren = <Widget>[];
|
||||
switch (dirName) {
|
||||
|
@ -163,7 +172,11 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
|||
if (thumbnail != null) thumbnail,
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||
child: InfoRowGroup(dir.tags, maxValueLength: Constants.infoGroupMaxValueLength),
|
||||
child: InfoRowGroup(
|
||||
dir.tags,
|
||||
maxValueLength: Constants.infoGroupMaxValueLength,
|
||||
linkHandlers: dirName == SvgMetadata.metadataDirectory ? SvgMetadata.getLinkHandlers(dir.tags) : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
@ -179,7 +192,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
|||
if (entry == null) return;
|
||||
if (_loadedMetadataUri.value == entry.uri) return;
|
||||
if (isVisible) {
|
||||
final rawMetadata = await MetadataService.getAllMetadata(entry) ?? {};
|
||||
final rawMetadata = await (entry.isSvg ? SvgMetadata.getAllMetadata(entry) : MetadataService.getAllMetadata(entry)) ?? {};
|
||||
final directories = rawMetadata.entries.map((dirKV) {
|
||||
var directoryName = dirKV.key as String ?? '';
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ import 'package:aves/model/image_entry.dart';
|
|||
import 'package:aves/services/metadata_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum MetadataThumbnailSource { embedded, exif, xmp }
|
||||
enum MetadataThumbnailSource { embedded, exif }
|
||||
|
||||
class MetadataThumbnails extends StatefulWidget {
|
||||
final MetadataThumbnailSource source;
|
||||
|
@ -36,10 +36,7 @@ class _MetadataThumbnailsState extends State<MetadataThumbnails> {
|
|||
_loader = MetadataService.getEmbeddedPictures(uri);
|
||||
break;
|
||||
case MetadataThumbnailSource.exif:
|
||||
_loader = MetadataService.getExifThumbnails(uri);
|
||||
break;
|
||||
case MetadataThumbnailSource.xmp:
|
||||
_loader = MetadataService.getXmpThumbnails(entry);
|
||||
_loader = MetadataService.getExifThumbnails(entry);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
76
lib/widgets/fullscreen/info/metadata/svg_tile.dart
Normal file
76
lib/widgets/fullscreen/info/metadata/svg_tile.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
137
lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart
Normal file
137
lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart
Normal 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}';
|
||||
}
|
||||
}
|
78
lib/widgets/fullscreen/info/metadata/xmp_ns/exif.dart
Normal file
78
lib/widgets/fullscreen/info/metadata/xmp_ns/exif.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
69
lib/widgets/fullscreen/info/metadata/xmp_ns/google.dart
Normal file
69
lib/widgets/fullscreen/info/metadata/xmp_ns/google.dart
Normal 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';
|
||||
}
|
28
lib/widgets/fullscreen/info/metadata/xmp_ns/iptc.dart
Normal file
28
lib/widgets/fullscreen/info/metadata/xmp_ns/iptc.dart
Normal 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,
|
||||
),
|
||||
];
|
||||
}
|
47
lib/widgets/fullscreen/info/metadata/xmp_ns/photoshop.dart
Normal file
47
lib/widgets/fullscreen/info/metadata/xmp_ns/photoshop.dart
Normal 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)';
|
||||
}
|
||||
}
|
||||
}
|
32
lib/widgets/fullscreen/info/metadata/xmp_ns/tiff.dart
Normal file
32
lib/widgets/fullscreen/info/metadata/xmp_ns/tiff.dart
Normal 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;
|
||||
}
|
||||
}
|
105
lib/widgets/fullscreen/info/metadata/xmp_ns/xmp.dart
Normal file
105
lib/widgets/fullscreen/info/metadata/xmp_ns/xmp.dart
Normal 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;
|
||||
}
|
||||
}
|
141
lib/widgets/fullscreen/info/metadata/xmp_structs.dart
Normal file
141
lib/widgets/fullscreen/info/metadata/xmp_structs.dart
Normal 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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,18 +1,27 @@
|
|||
import 'dart:collection';
|
||||
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/ref/brand_colors.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/ref/xmp.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/services/android_app_service.dart';
|
||||
import 'package:aves/services/metadata_service.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
||||
import 'package:aves/widgets/common/identity/highlight_title.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/common.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:aves/widgets/fullscreen/fullscreen_page.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/exif.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/google.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/iptc.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/photoshop.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/tiff.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/xmp.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pedantic/pedantic.dart';
|
||||
|
||||
class XmpDirTile extends StatelessWidget {
|
||||
class XmpDirTile extends StatefulWidget {
|
||||
final ImageEntry entry;
|
||||
final SplayTreeMap<String, String> tags;
|
||||
final ValueNotifier<String> expandedNotifier;
|
||||
|
@ -23,56 +32,101 @@ class XmpDirTile extends StatelessWidget {
|
|||
@required this.expandedNotifier,
|
||||
});
|
||||
|
||||
@override
|
||||
_XmpDirTileState createState() => _XmpDirTileState();
|
||||
}
|
||||
|
||||
class _XmpDirTileState extends State<XmpDirTile> with FeedbackMixin {
|
||||
ImageEntry get entry => widget.entry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry);
|
||||
final sections = SplayTreeMap.of(
|
||||
groupBy<MapEntry<String, String>, String>(tags.entries, (kv) {
|
||||
final sections = SplayTreeMap<XmpNamespace, List<MapEntry<String, String>>>.of(
|
||||
groupBy(widget.tags.entries, (kv) {
|
||||
final fullKey = kv.key;
|
||||
final i = fullKey.indexOf(XMP.namespaceSeparator);
|
||||
if (i == -1) return '';
|
||||
final namespace = fullKey.substring(0, i);
|
||||
return XMP.namespaces[namespace] ?? namespace;
|
||||
final i = fullKey.indexOf(XMP.propNamespaceSeparator);
|
||||
final namespace = i == -1 ? '' : fullKey.substring(0, i);
|
||||
switch (namespace) {
|
||||
case XmpBasicNamespace.ns:
|
||||
return XmpBasicNamespace();
|
||||
case XmpExifNamespace.ns:
|
||||
return XmpExifNamespace();
|
||||
case XmpGAudioNamespace.ns:
|
||||
return XmpGAudioNamespace();
|
||||
case XmpGDepthNamespace.ns:
|
||||
return XmpGDepthNamespace();
|
||||
case XmpGImageNamespace.ns:
|
||||
return XmpGImageNamespace();
|
||||
case XmpIptcCoreNamespace.ns:
|
||||
return XmpIptcCoreNamespace();
|
||||
case XmpMMNamespace.ns:
|
||||
return XmpMMNamespace();
|
||||
case XmpNoteNamespace.ns:
|
||||
return XmpNoteNamespace();
|
||||
case XmpPhotoshopNamespace.ns:
|
||||
return XmpPhotoshopNamespace();
|
||||
case XmpTiffNamespace.ns:
|
||||
return XmpTiffNamespace();
|
||||
default:
|
||||
return XmpNamespace(namespace);
|
||||
}
|
||||
}),
|
||||
compareAsciiUpperCase,
|
||||
(a, b) => compareAsciiUpperCase(a.displayTitle, b.displayTitle),
|
||||
);
|
||||
return AvesExpansionTile(
|
||||
title: 'XMP',
|
||||
expandedNotifier: expandedNotifier,
|
||||
expandedNotifier: widget.expandedNotifier,
|
||||
children: [
|
||||
if (thumbnail != null) thumbnail,
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: sections.entries.expand((sectionEntry) {
|
||||
final title = sectionEntry.key;
|
||||
|
||||
final entries = sectionEntry.value.map((kv) {
|
||||
final key = kv.key.splitMapJoin(XMP.structFieldSeparator, onNonMatch: (s) {
|
||||
// strip namespace
|
||||
final key = s.split(XMP.namespaceSeparator).last;
|
||||
// uppercase first letter
|
||||
return key.replaceFirstMapped(RegExp('.'), (m) => m.group(0).toUpperCase());
|
||||
});
|
||||
return MapEntry(key, kv.value);
|
||||
}).toList()
|
||||
..sort((a, b) => compareAsciiUpperCaseNatural(a.key, b.key));
|
||||
return [
|
||||
if (title.isNotEmpty)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 8),
|
||||
child: HighlightTitle(
|
||||
title,
|
||||
color: BrandColors.get(title),
|
||||
),
|
||||
),
|
||||
InfoRowGroup(Map.fromEntries(entries), maxValueLength: Constants.infoGroupMaxValueLength),
|
||||
];
|
||||
}).toList(),
|
||||
NotificationListener<OpenEmbeddedDataNotification>(
|
||||
onNotification: (notification) {
|
||||
_openEmbeddedData(notification.propPath, notification.mimeType);
|
||||
return true;
|
||||
},
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: sections.entries
|
||||
.expand((kv) => kv.key.buildNamespaceSection(
|
||||
rawProps: kv.value,
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openEmbeddedData(String propPath, String propMimeType) async {
|
||||
final fields = await MetadataService.extractXmpDataProp(entry, propPath, propMimeType);
|
||||
if (fields == null || !fields.containsKey('mimeType') || !fields.containsKey('uri')) {
|
||||
showFeedback(context, 'Failed');
|
||||
return;
|
||||
}
|
||||
|
||||
final mimeType = fields['mimeType'];
|
||||
final uri = fields['uri'];
|
||||
if (!MimeTypes.isImage(mimeType) && !MimeTypes.isVideo(mimeType)) {
|
||||
// open with another app
|
||||
unawaited(AndroidAppService.open(uri, mimeType).then((success) {
|
||||
if (!success) {
|
||||
// fallback to sharing, so that the file can be saved somewhere
|
||||
AndroidAppService.shareSingle(uri, mimeType).then((success) {
|
||||
if (!success) showNoMatchingAppDialog(context);
|
||||
});
|
||||
}
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
final embedEntry = ImageEntry.fromMap(fields);
|
||||
unawaited(Navigator.push(
|
||||
context,
|
||||
TransparentMaterialPageRoute(
|
||||
settings: RouteSettings(name: SingleFullscreenPage.routeName),
|
||||
pageBuilder: (c, a, sa) => SingleFullscreenPage(entry: embedEntry),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,12 @@ class OverlayButton extends StatelessWidget {
|
|||
final Animation<double> scale;
|
||||
final Widget child;
|
||||
|
||||
const OverlayButton({Key key, this.scale, this.child}) : super(key: key);
|
||||
const OverlayButton({
|
||||
Key key,
|
||||
@required this.scale,
|
||||
@required this.child,
|
||||
}) : assert(scale != null),
|
||||
super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
@ -30,3 +35,45 @@ class OverlayButton extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OverlayTextButton extends StatelessWidget {
|
||||
final Animation<double> scale;
|
||||
final String text;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const OverlayTextButton({
|
||||
Key key,
|
||||
@required this.scale,
|
||||
@required this.text,
|
||||
this.onPressed,
|
||||
}) : assert(scale != null),
|
||||
super(key: key);
|
||||
|
||||
static const _borderRadius = 123.0;
|
||||
static final _minSize = MaterialStateProperty.all<Size>(Size(kMinInteractiveDimension, kMinInteractiveDimension));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizeTransition(
|
||||
sizeFactor: scale,
|
||||
child: BlurredRRect(
|
||||
borderRadius: _borderRadius,
|
||||
child: OutlinedButton(
|
||||
onPressed: onPressed,
|
||||
style: ButtonStyle(
|
||||
backgroundColor: MaterialStateProperty.all<Color>(kOverlayBackgroundColor),
|
||||
foregroundColor: MaterialStateProperty.all<Color>(Colors.white),
|
||||
overlayColor: MaterialStateProperty.all<Color>(Colors.white.withOpacity(0.12)),
|
||||
minimumSize: _minSize,
|
||||
side: MaterialStateProperty.all<BorderSide>(AvesCircleBorder.buildSide(context)),
|
||||
shape: MaterialStateProperty.all<OutlinedBorder>(RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(_borderRadius)),
|
||||
)),
|
||||
// shape: MaterialStateProperty.all<OutlinedBorder>(CircleBorder()),
|
||||
),
|
||||
child: Text(text.toUpperCase()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
37
lib/widgets/fullscreen/overlay/panorama.dart
Normal file
37
lib/widgets/fullscreen/overlay/panorama.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -10,22 +10,17 @@ import 'package:aves/widgets/common/fx/borders.dart';
|
|||
import 'package:aves/widgets/fullscreen/overlay/common.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class VideoControlOverlay extends StatefulWidget {
|
||||
final ImageEntry entry;
|
||||
final Animation<double> scale;
|
||||
final IjkMediaController controller;
|
||||
final EdgeInsets viewInsets, viewPadding;
|
||||
final Animation<double> scale;
|
||||
|
||||
const VideoControlOverlay({
|
||||
Key key,
|
||||
@required this.entry,
|
||||
@required this.controller,
|
||||
@required this.scale,
|
||||
this.viewInsets,
|
||||
this.viewPadding,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
@ -99,63 +94,48 @@ class VideoControlOverlayState extends State<VideoControlOverlay> with SingleTic
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mq = context.select<MediaQueryData, Tuple3<double, EdgeInsets, EdgeInsets>>((mq) => Tuple3(mq.size.width, mq.viewInsets, mq.viewPadding));
|
||||
final mqWidth = mq.item1;
|
||||
final mqViewInsets = mq.item2;
|
||||
final mqViewPadding = mq.item3;
|
||||
|
||||
final viewInsets = widget.viewInsets ?? mqViewInsets;
|
||||
final viewPadding = widget.viewPadding ?? mqViewPadding;
|
||||
final safePadding = (viewInsets + viewPadding).copyWith(bottom: 8) + EdgeInsets.symmetric(horizontal: 8.0);
|
||||
|
||||
return Padding(
|
||||
padding: safePadding,
|
||||
child: SizedBox(
|
||||
width: mqWidth - safePadding.horizontal,
|
||||
child: StreamBuilder<IjkStatus>(
|
||||
stream: controller.ijkStatusStream,
|
||||
builder: (context, snapshot) {
|
||||
// do not use stream snapshot because it is obsolete when switching between videos
|
||||
final status = controller.ijkStatus;
|
||||
return TooltipTheme(
|
||||
data: TooltipTheme.of(context).copyWith(
|
||||
preferBelow: false,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: status == IjkStatus.error
|
||||
? [
|
||||
OverlayButton(
|
||||
scale: scale,
|
||||
child: IconButton(
|
||||
icon: Icon(AIcons.openInNew),
|
||||
onPressed: () => AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype),
|
||||
tooltip: 'Open',
|
||||
),
|
||||
return StreamBuilder<IjkStatus>(
|
||||
stream: controller.ijkStatusStream,
|
||||
builder: (context, snapshot) {
|
||||
// do not use stream snapshot because it is obsolete when switching between videos
|
||||
final status = controller.ijkStatus;
|
||||
return TooltipTheme(
|
||||
data: TooltipTheme.of(context).copyWith(
|
||||
preferBelow: false,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: status == IjkStatus.error
|
||||
? [
|
||||
OverlayButton(
|
||||
scale: scale,
|
||||
child: IconButton(
|
||||
icon: Icon(AIcons.openInNew),
|
||||
onPressed: () => AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype),
|
||||
tooltip: 'Open',
|
||||
),
|
||||
),
|
||||
]
|
||||
: [
|
||||
Expanded(
|
||||
child: _buildProgressBar(),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
OverlayButton(
|
||||
scale: scale,
|
||||
child: IconButton(
|
||||
icon: AnimatedIcon(
|
||||
icon: AnimatedIcons.play_pause,
|
||||
progress: _playPauseAnimation,
|
||||
),
|
||||
]
|
||||
: [
|
||||
Expanded(
|
||||
child: _buildProgressBar(),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
OverlayButton(
|
||||
scale: scale,
|
||||
child: IconButton(
|
||||
icon: AnimatedIcon(
|
||||
icon: AnimatedIcons.play_pause,
|
||||
progress: _playPauseAnimation,
|
||||
),
|
||||
onPressed: _playPause,
|
||||
tooltip: isPlaying ? 'Pause' : 'Play',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
onPressed: _playPause,
|
||||
tooltip: isPlaying ? 'Pause' : 'Play',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildProgressBar() {
|
||||
|
|
32
lib/widgets/fullscreen/panorama_page.dart
Normal file
32
lib/widgets/fullscreen/panorama_page.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,7 +1,3 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/services/image_file_service.dart';
|
||||
import 'package:aves/widgets/common/aves_highlight.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_highlight/themes/darcula.dart';
|
||||
|
@ -9,10 +5,10 @@ import 'package:flutter_highlight/themes/darcula.dart';
|
|||
class SourceViewerPage extends StatefulWidget {
|
||||
static const routeName = '/fullscreen/source';
|
||||
|
||||
final ImageEntry entry;
|
||||
final Future<String> Function() loader;
|
||||
|
||||
const SourceViewerPage({
|
||||
@required this.entry,
|
||||
@required this.loader,
|
||||
});
|
||||
|
||||
@override
|
||||
|
@ -22,12 +18,10 @@ class SourceViewerPage extends StatefulWidget {
|
|||
class _SourceViewerPageState extends State<SourceViewerPage> {
|
||||
Future<String> _loader;
|
||||
|
||||
ImageEntry get entry => widget.entry;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loader = ImageFileService.getImage(entry.uri, entry.mimeType, 0, false).then(utf8.decode);
|
||||
_loader = widget.loader();
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -40,12 +34,8 @@ class _SourceViewerPageState extends State<SourceViewerPage> {
|
|||
child: FutureBuilder<String>(
|
||||
future: _loader,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return Text(snapshot.error.toString());
|
||||
}
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return SizedBox.shrink();
|
||||
}
|
||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||
if (!snapshot.hasData) return SizedBox.shrink();
|
||||
|
||||
final source = snapshot.data;
|
||||
final highlightView = AvesHighlightView(
|
||||
|
|
|
@ -82,6 +82,9 @@ class ImageSearchDelegate {
|
|||
MimeFilter(MimeTypes.anyImage),
|
||||
MimeFilter(MimeTypes.anyVideo),
|
||||
MimeFilter(MimeFilter.animated),
|
||||
MimeFilter(MimeFilter.panorama),
|
||||
MimeFilter(MimeFilter.sphericalVideo),
|
||||
MimeFilter(MimeFilter.geotiff),
|
||||
MimeFilter(MimeTypes.svg),
|
||||
].where((f) => f != null && containQuery(f.label)),
|
||||
// usually perform hero animation only on tapped chips,
|
||||
|
|
|
@ -5,7 +5,6 @@ import 'package:aves/model/settings/settings.dart';
|
|||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
||||
import 'package:aves/widgets/common/identity/highlight_title.dart';
|
||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
|
||||
import 'package:aves/widgets/settings/access_grants.dart';
|
||||
|
@ -225,17 +224,3 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SectionTitle extends StatelessWidget {
|
||||
final String text;
|
||||
|
||||
const SectionTitle(this.text);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(left: 16, top: 6, right: 16, bottom: 12),
|
||||
child: HighlightTitle(text),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
41
pubspec.lock
41
pubspec.lock
|
@ -63,7 +63,7 @@ packages:
|
|||
name: cached_network_image
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.3.3"
|
||||
version: "2.4.1"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -200,14 +200,14 @@ packages:
|
|||
name: firebase
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "7.3.2"
|
||||
version: "7.3.3"
|
||||
firebase_analytics:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: firebase_analytics
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "6.2.0"
|
||||
version: "6.3.0"
|
||||
firebase_analytics_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -228,35 +228,35 @@ packages:
|
|||
name: firebase_core
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.5.2"
|
||||
version: "0.5.3"
|
||||
firebase_core_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
version: "2.1.0"
|
||||
firebase_core_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_web
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
version: "0.2.1+1"
|
||||
firebase_crashlytics:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: firebase_crashlytics
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.2.3"
|
||||
version: "0.2.4"
|
||||
firebase_crashlytics_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_crashlytics_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.1.3"
|
||||
version: "1.1.4"
|
||||
flushbar:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -283,6 +283,13 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
flutter_cube:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_cube
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.6"
|
||||
flutter_driver:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
|
@ -522,6 +529,13 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.9.7"
|
||||
motion_sensors:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: motion_sensors
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.4"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -585,6 +599,13 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.2.3"
|
||||
panorama:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: panorama
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -747,7 +768,7 @@ packages:
|
|||
name: provider
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.3.2+2"
|
||||
version: "4.3.2+3"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -1132,7 +1153,7 @@ packages:
|
|||
source: hosted
|
||||
version: "0.1.2"
|
||||
xml:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: xml
|
||||
url: "https://pub.dartlang.org"
|
||||
|
|
16
pubspec.yaml
16
pubspec.yaml
|
@ -1,21 +1,9 @@
|
|||
name: aves
|
||||
description: Aves is a gallery and metadata explorer app, built for Android.
|
||||
|
||||
# The following line prevents the package from being accidentally published to
|
||||
# pub.dev using `pub publish`. This is preferred for private packages.
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
|
||||
# The following defines the version and build number for your application.
|
||||
# A version number is three numbers separated by dots, like 1.2.43
|
||||
# followed by an optional build number separated by a +.
|
||||
# Both the version and the builder number may be overridden in flutter
|
||||
# build by specifying --build-name and --build-number, respectively.
|
||||
# In Android, build-name is used as versionName while build-number used as versionCode.
|
||||
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
|
||||
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
version: 1.2.8+34
|
||||
version: 1.2.9+35
|
||||
|
||||
# brendan-duncan/image (as of v2.1.19):
|
||||
# - does not support TIFF with JPEG compression (issue #184)
|
||||
|
@ -77,6 +65,7 @@ dependencies:
|
|||
overlay_support:
|
||||
package_info:
|
||||
palette_generator:
|
||||
panorama:
|
||||
pdf:
|
||||
pedantic:
|
||||
percent_indicator:
|
||||
|
@ -93,6 +82,7 @@ dependencies:
|
|||
streams_channel:
|
||||
tuple:
|
||||
url_launcher:
|
||||
xml:
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
File diff suppressed because one or more lines are too long
1
shaders_1.22.5.sksl.json
Normal file
1
shaders_1.22.5.sksl.json
Normal file
File diff suppressed because one or more lines are too long
17
test/utils/string_utils_test.dart
Normal file
17
test/utils/string_utils_test.dart
Normal 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]');
|
||||
});
|
||||
}
|
|
@ -30,6 +30,8 @@ void main() {
|
|||
});
|
||||
|
||||
agreeToTerms();
|
||||
visitAbout();
|
||||
visitSettings();
|
||||
sortCollection();
|
||||
groupCollection();
|
||||
selectFirstAlbum();
|
||||
|
@ -43,7 +45,7 @@ void main() {
|
|||
test('contemplation', () async {
|
||||
await Future.delayed(Duration(seconds: 5));
|
||||
});
|
||||
}, timeout: Timeout(Duration(seconds: 10)));
|
||||
}, timeout: Timeout(Duration(seconds: 30)));
|
||||
}
|
||||
|
||||
void agreeToTerms() {
|
||||
|
@ -60,6 +62,30 @@ void agreeToTerms() {
|
|||
});
|
||||
}
|
||||
|
||||
void visitAbout() {
|
||||
test('[collection] visit about page', () async {
|
||||
await driver.tap(find.byValueKey('appbar-leading-button'));
|
||||
await driver.waitUntilNoTransientCallbacks();
|
||||
|
||||
await driver.tap(find.byValueKey('About-tile'));
|
||||
await driver.waitUntilNoTransientCallbacks();
|
||||
|
||||
await pressDeviceBackButton();
|
||||
});
|
||||
}
|
||||
|
||||
void visitSettings() {
|
||||
test('[collection] visit about page', () async {
|
||||
await driver.tap(find.byValueKey('appbar-leading-button'));
|
||||
await driver.waitUntilNoTransientCallbacks();
|
||||
|
||||
await driver.tap(find.byValueKey('Settings-tile'));
|
||||
await driver.waitUntilNoTransientCallbacks();
|
||||
|
||||
await pressDeviceBackButton();
|
||||
});
|
||||
}
|
||||
|
||||
void sortCollection() {
|
||||
test('[collection] sort', () async {
|
||||
await driver.tap(find.byValueKey('appbar-menu-button'));
|
||||
|
@ -92,8 +118,11 @@ void selectFirstAlbum() {
|
|||
await driver.tap(find.byValueKey('Albums-tile'));
|
||||
await driver.waitUntilNoTransientCallbacks();
|
||||
|
||||
// wait for collection loading
|
||||
await driver.waitForCondition(NoPendingPlatformMessages());
|
||||
|
||||
await driver.tap(find.descendant(
|
||||
of: find.byType('FilterGridPage'),
|
||||
of: find.byValueKey('filter-grid-page'),
|
||||
matching: find.byType('DecoratedFilterChip'),
|
||||
firstMatchOnly: true,
|
||||
));
|
||||
|
|
|
@ -42,3 +42,5 @@ Future<void> copyContent(String sourceDir, String targetDir) async {
|
|||
Future<void> grantPermissions(String packageName, Iterable<String> permissions) async {
|
||||
await Future.forEach(permissions, (permission) => runAdb(['shell', 'pm', 'grant', packageName, permission]));
|
||||
}
|
||||
|
||||
Future<void> pressDeviceBackButton() => runAdb(['shell', 'input', 'keyevent', 'KEYCODE_BACK']);
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
Thanks for using Aves!
|
||||
v1.2.8:
|
||||
- pinch to scale albums, countries & tags
|
||||
- SVG source viewer
|
||||
- improved detailed metadata layout
|
||||
v1.2.9:
|
||||
- identify 360 photos/videos, GeoTIFF
|
||||
- open panoramas (360 photos)
|
||||
- open GImage/GAudio/GDepth media and thumbnails embedded in XMP
|
||||
- improved large TIFF handling
|
||||
Full changelog available on Github
|
Loading…
Reference in a new issue