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