Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2021-04-29 12:28:00 +09:00
commit f0b87f39c3
117 changed files with 2251 additions and 1363 deletions

View file

@ -15,7 +15,7 @@ jobs:
- uses: subosito/flutter-action@v1
with:
channel: beta
flutter-version: '2.1.0-12.2.pre'
flutter-version: '2.2.0-10.1.pre'
- name: Clone the repository.
uses: actions/checkout@v2

View file

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

View file

@ -3,6 +3,19 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
## [v1.4.1] - 2021-04-29
### Added
- Motion photo support
- Viewer: play videos in multi-track HEIC
- Handle share intent
### Changed
- Upgraded Flutter to beta v2.2.0-10.1.pre
### Fixed
- fixed crash when cataloguing large MP4/PSD
- prevent videos playing in the background when quickly switching entries
## [v1.4.0] - 2021-04-16
### Added
- Viewer: support for videos with EAC3/FLAC/OPUS audio

View file

@ -62,6 +62,14 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="vnd.android.cursor.dir/image" />
<data android:mimeType="vnd.android.cursor.dir/video" />
</intent-filter>
<intent-filter tools:ignore="AppLinkUrlError">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@ -69,6 +77,7 @@
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="vnd.android.cursor.dir/image" />
<data android:mimeType="vnd.android.cursor.dir/video" />
</intent-filter>
<intent-filter>
<action android:name="com.android.camera.action.REVIEW" />
@ -88,6 +97,7 @@
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="vnd.android.cursor.dir/image" />
<data android:mimeType="vnd.android.cursor.dir/video" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.PICK" />
@ -96,6 +106,7 @@
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="vnd.android.cursor.dir/image" />
<data android:mimeType="vnd.android.cursor.dir/video" />
</intent-filter>
</activity>
<!-- file provider to share files having a file:// URI -->

View file

@ -4,6 +4,7 @@ import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.content.pm.ShortcutInfoCompat
@ -12,6 +13,7 @@ import androidx.core.graphics.drawable.IconCompat
import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.channel.calls.*
import deckers.thibault.aves.channel.streams.*
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.PermissionManager
import io.flutter.embedding.android.FlutterActivity
@ -33,6 +35,7 @@ class MainActivity : FlutterActivity() {
MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
MethodChannel(messenger, AppShortcutHandler.CHANNEL).setMethodCallHandler(AppShortcutHandler(this))
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this))
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
@ -83,21 +86,29 @@ class MainActivity : FlutterActivity() {
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == PermissionManager.VOLUME_ACCESS_REQUEST_CODE) {
val treeUri = data?.data
if (resultCode != RESULT_OK || treeUri == null) {
PermissionManager.onPermissionResult(requestCode, null)
return
when (requestCode) {
VOLUME_ACCESS_REQUEST -> {
val treeUri = data?.data
if (resultCode != RESULT_OK || treeUri == null) {
PermissionManager.onPermissionResult(requestCode, null)
return
}
// save access permissions across reboots
val takeFlags = (data.flags
and (Intent.FLAG_GRANT_READ_URI_PERMISSION
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION))
contentResolver.takePersistableUriPermission(treeUri, takeFlags)
// resume pending action
PermissionManager.onPermissionResult(requestCode, treeUri)
}
DELETE_PERMISSION_REQUEST -> {
// delete permission may be requested on Android 10+ only
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
MediaStoreImageProvider.pendingDeleteCompleter?.complete(resultCode == RESULT_OK)
}
}
// save access permissions across reboots
val takeFlags = (data.flags
and (Intent.FLAG_GRANT_READ_URI_PERMISSION
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION))
contentResolver.takePersistableUriPermission(treeUri, takeFlags)
// resume pending action
PermissionManager.onPermissionResult(requestCode, treeUri)
}
}
@ -111,8 +122,8 @@ class MainActivity : FlutterActivity() {
)
}
}
Intent.ACTION_VIEW, "com.android.camera.action.REVIEW" -> {
intent.data?.let { uri ->
Intent.ACTION_VIEW, Intent.ACTION_SEND, "com.android.camera.action.REVIEW" -> {
(intent.data ?: (intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri))?.let { uri ->
return hashMapOf(
"action" to "view",
"uri" to uri.toString(),
@ -171,7 +182,9 @@ class MainActivity : FlutterActivity() {
}
companion object {
private val LOG_TAG = LogUtils.createTag(MainActivity::class.java)
private val LOG_TAG = LogUtils.createTag<MainActivity>()
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer"
const val VOLUME_ACCESS_REQUEST = 1
const val DELETE_PERMISSION_REQUEST = 2
}
}

View file

@ -12,6 +12,7 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.channel.calls.Coresult.Companion.safesus
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils
@ -30,7 +31,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getPackages" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPackages) }
"getAppIcon" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getAppIcon) }
"getAppIcon" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getAppIcon) }
"edit" -> {
val title = call.argument<String>("title")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
@ -109,7 +110,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
result.success(ArrayList(packages.values))
}
private fun getAppIcon(call: MethodCall, result: MethodChannel.Result) {
private suspend fun getAppIcon(call: MethodCall, result: MethodChannel.Result) {
val packageName = call.argument<String>("packageName")
val sizeDip = call.argument<Double>("sizeDip")
if (packageName == null || sizeDip == null) {
@ -254,8 +255,8 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
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))
val authority = "${context.applicationContext.packageName}.fileprovider"
FileProvider.getUriForFile(context, authority, File(path))
}
}
else -> uri
@ -263,7 +264,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
}
companion object {
private val LOG_TAG = LogUtils.createTag(AppAdapterHandler::class.java)
private val LOG_TAG = LogUtils.createTag<AppAdapterHandler>()
const val CHANNEL = "deckers.thibault/aves/app"
}
}

View file

@ -305,7 +305,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
)
companion object {
private val LOG_TAG = LogUtils.createTag(DebugHandler::class.java)
private val LOG_TAG = LogUtils.createTag<DebugHandler>()
const val CHANNEL = "deckers.thibault/aves/debug"
}
}

View file

@ -0,0 +1,237 @@
package deckers.thibault.aves.channel.calls
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.core.content.FileProvider
import androidx.exifinterface.media.ExifInterface
import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.XMPUtils
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
import com.drew.imaging.ImageMetadataReader
import com.drew.metadata.file.FileTypeDirectory
import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.channel.calls.Coresult.Companion.safesus
import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
import deckers.thibault.aves.metadata.MultiPage
import deckers.thibault.aves.metadata.XMP
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.provider.ContentImageProvider
import deckers.thibault.aves.model.provider.ImageProvider
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.extensionFor
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.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.io.File
import java.io.InputStream
import java.util.*
class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getExifThumbnails) }
"extractMotionPhotoVideo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractMotionPhotoVideo) }
"extractVideoEmbeddedPicture" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractVideoEmbeddedPicture) }
"extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractXmpDataProp) }
else -> result.notImplemented()
}
}
private suspend fun getExifThumbnails(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("getExifThumbnails-args", "failed because of missing arguments", null)
return
}
val thumbnails = ArrayList<ByteArray>()
if (isSupportedByExifInterface(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val exif = ExifInterface(input)
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
exif.thumbnailBitmap?.let { bitmap ->
TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let {
it.getBytes(canHaveAlpha = false, recycle = false)?.let { bytes -> thumbnails.add(bytes) }
}
}
}
} catch (e: Exception) {
// ExifInterface initialization can fail with a RuntimeException
// caused by an internal MediaMetadataRetriever failure
}
}
result.success(thumbnails)
}
private fun extractMotionPhotoVideo(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val displayName = call.argument<String>("displayName")
if (mimeType == null || uri == null || sizeBytes == null) {
result.error("extractMotionPhotoVideo-args", "failed because of missing arguments", null)
return
}
MultiPage.getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
val videoStartOffset = sizeBytes - videoSizeBytes
StorageUtils.openInputStream(context, uri)?.let { input ->
input.skip(videoStartOffset)
copyEmbeddedBytes(result, MimeTypes.MP4, displayName, input)
}
return
}
result.error("extractMotionPhotoVideo-empty", "failed to extract video from motion photo at uri=$uri", null)
}
private fun extractVideoEmbeddedPicture(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val displayName = call.argument<String>("displayName")
if (uri == null) {
result.error("extractVideoEmbeddedPicture-args", "failed because of missing arguments", null)
return
}
val retriever = StorageUtils.openMetadataRetriever(context, uri)
if (retriever != null) {
try {
retriever.embeddedPicture?.let { bytes ->
var embedMimeType: String? = null
bytes.inputStream().use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
metadata.getFirstDirectoryOfType(FileTypeDirectory::class.java)?.let { dir ->
dir.getSafeString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) { embedMimeType = it }
}
}
embedMimeType?.let { mime ->
copyEmbeddedBytes(result, mime, displayName, bytes.inputStream())
return
}
}
} catch (e: Exception) {
result.error("extractVideoEmbeddedPicture-fetch", "failed to fetch picture for uri=$uri", e.message)
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release()
}
}
result.error("extractVideoEmbeddedPicture-empty", "failed to extract picture for uri=$uri", null)
}
private fun extractXmpDataProp(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val displayName = call.argument<String>("displayName")
val dataPropPath = call.argument<String>("propPath")
val embedMimeType = call.argument<String>("propMimeType")
if (mimeType == null || uri == null || dataPropPath == null || embedMimeType == null) {
result.error("extractXmpDataProp-args", "failed because of missing arguments", null)
return
}
if (isSupportedByMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
// data can be large and stored in "Extended XMP",
// which is returned as a second XMP directory
val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java)
try {
val pathParts = dataPropPath.split('/')
val embedBytes: ByteArray = if (pathParts.size == 1) {
val propName = pathParts[0]
val propNs = XMP.namespaceForPropPath(propName)
xmpDirs.map { it.xmpMeta.getPropertyBase64(propNs, propName) }.first { it != null }
} else {
val structName = pathParts[0]
val structNs = XMP.namespaceForPropPath(structName)
val fieldName = pathParts[1]
val fieldNs = XMP.namespaceForPropPath(fieldName)
xmpDirs.map { it.xmpMeta.getStructField(structNs, structName, fieldNs, fieldName) }.first { it != null }.let {
XMPUtils.decodeBase64(it.value)
}
}
copyEmbeddedBytes(result, embedMimeType, displayName, embedBytes.inputStream())
return
} catch (e: XMPException) {
result.error("extractXmpDataProp-xmp", "failed to read XMP directory for uri=$uri prop=$dataPropPath", e.message)
return
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to extract file from XMP", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to extract file from XMP", e)
}
}
result.error("extractXmpDataProp-empty", "failed to extract file from XMP uri=$uri prop=$dataPropPath", null)
}
private fun copyEmbeddedBytes(result: MethodChannel.Result, mimeType: String, displayName: String?, embeddedByteStream: InputStream) {
val extension = extensionFor(mimeType)
val file = File.createTempFile("aves", extension, context.cacheDir).apply {
deleteOnExit()
outputStream().use { output ->
embeddedByteStream.use { input ->
input.copyTo(output)
}
}
}
val authority = "${context.applicationContext.packageName}.fileprovider"
val uri = if (displayName != null) {
// add extension to ease type identification when sharing this content
val displayNameWithExtension = if (extension == null || displayName.endsWith(extension, ignoreCase = true)) {
displayName
} else {
"$displayName$extension"
}
FileProvider.getUriForFile(context, authority, file, displayNameWithExtension)
} else {
FileProvider.getUriForFile(context, authority, file)
}
val resultFields: FieldMap = hashMapOf(
"uri" to uri.toString(),
"mimeType" to mimeType,
)
if (isImage(mimeType) || isVideo(mimeType)) {
GlobalScope.launch(Dispatchers.IO) {
ContentImageProvider().fetchSingle(context, uri, mimeType, object : ImageProvider.ImageOpCallback {
override fun onSuccess(fields: FieldMap) {
resultFields.putAll(fields)
result.success(resultFields)
}
override fun onFailure(throwable: Throwable) = result.error("copyEmbeddedBytes-failure", "failed to get entry for uri=$uri mime=$mimeType", throwable.message)
})
}
} else {
result.success(resultFields)
}
}
companion object {
private val LOG_TAG = LogUtils.createTag<EmbeddedDataHandler>()
const val CHANNEL = "deckers.thibault/aves/embedded"
}
}

View file

@ -3,7 +3,6 @@ package deckers.thibault.aves.channel.calls
import android.app.Activity
import android.graphics.Rect
import android.net.Uri
import android.util.Size
import com.bumptech.glide.Glide
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.channel.calls.Coresult.Companion.safesus
@ -31,8 +30,8 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getEntry" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getEntry) }
"getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getThumbnail) }
"getRegion" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getRegion) }
"getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getThumbnail) }
"getRegion" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getRegion) }
"rename" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::rename) }
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
@ -61,7 +60,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
})
}
private fun getThumbnail(call: MethodCall, result: MethodChannel.Result) {
private suspend fun getThumbnail(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")
val mimeType = call.argument<String>("mimeType")
val dateModifiedSecs = call.argument<Number>("dateModifiedSecs")?.toLong()
@ -93,7 +92,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
).fetch()
}
private fun getRegion(call: MethodCall, result: MethodChannel.Result) {
private suspend fun getRegion(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val mimeType = call.argument<String>("mimeType")
val pageId = call.argument<Int>("pageId")
@ -185,7 +184,8 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
val path = entryMap["path"] as String?
val mimeType = entryMap["mimeType"] as String?
if (uri == null || path == null || mimeType == null) {
val sizeBytes = (entryMap["sizeBytes"] as Number?)?.toLong()
if (uri == null || path == null || mimeType == null || sizeBytes == null) {
result.error("changeOrientation-args", "failed because entry fields are missing", null)
return
}
@ -196,7 +196,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
return
}
provider.changeOrientation(activity, path, uri, mimeType, op, object : ImageOpCallback {
provider.changeOrientation(activity, path, uri, mimeType, sizeBytes, op, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("changeOrientation-failure", "failed to change orientation", throwable.message)
})

View file

@ -4,17 +4,13 @@ import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import android.media.MediaExtractor
import android.media.MediaFormat
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.provider.MediaStore
import android.util.Log
import androidx.exifinterface.media.ExifInterface
import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.XMPUtils
import com.adobe.internal.xmp.properties.XMPPropertyInfo
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
import com.drew.imaging.ImageMetadataReader
import com.drew.lang.Rational
import com.drew.metadata.Tag
@ -51,12 +47,9 @@ import deckers.thibault.aves.metadata.XMP.getSafeDateMillis
import deckers.thibault.aves.metadata.XMP.getSafeInt
import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText
import deckers.thibault.aves.metadata.XMP.getSafeString
import deckers.thibault.aves.metadata.XMP.isMotionPhoto
import deckers.thibault.aves.metadata.XMP.isPanorama
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.provider.FileImageProvider
import deckers.thibault.aves.model.provider.ImageProvider
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isHeic
@ -73,8 +66,6 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import java.io.File
import java.text.ParseException
import java.util.*
import kotlin.math.roundToLong
@ -88,9 +79,6 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) }
"getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPanoramaInfo) }
"getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) }
"getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getExifThumbnails) }
"extractVideoEmbeddedPicture" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractVideoEmbeddedPicture) }
"extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractXmpDataProp) }
else -> result.notImplemented()
}
}
@ -315,10 +303,10 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
// File type
for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) {
// * `metadata-extractor` sometimes detect the the wrong mime type (e.g. `pef` file as `tiff`)
// * the content resolver / media store sometimes report the wrong mime type (e.g. `png` file as `jpeg`, `tiff` as `srw`)
// * `context.getContentResolver().getType()` sometimes return incorrect value
// * `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000`
// * `metadata-extractor` sometimes detects the wrong mime type (e.g. `pef` file as `tiff`)
// * the content resolver / media store sometimes reports the wrong mime type (e.g. `png` file as `jpeg`, `tiff` as `srw`)
// * `context.getContentResolver().getType()` sometimes returns an incorrect value
// * `MediaMetadataRetriever.setDataSource()` sometimes fails with `status = 0x80000000`
// * file extension is unreliable
// In the end, `metadata-extractor` is the most reliable, except for `tiff` (false positives, false negatives),
// in which case we trust the file extension
@ -382,6 +370,11 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
if (xmpMeta.isPanorama()) {
flags = flags or MASK_IS_360
}
// identification of motion photo
if (xmpMeta.isMotionPhoto()) {
flags = flags or MASK_IS_MULTIPAGE
}
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
}
@ -471,7 +464,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
}
if (mimeType == MimeTypes.TIFF && isMultiPageTiff(uri)) flags = flags or MASK_IS_MULTIPAGE
if (mimeType == MimeTypes.TIFF && MultiPage.isMultiPageTiff(context, uri)) flags = flags or MASK_IS_MULTIPAGE
metadataMap[KEY_FLAGS] = flags
}
@ -591,67 +584,23 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private fun getMultiPageInfo(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) {
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null || sizeBytes == null) {
result.error("getMultiPageInfo-args", "failed because of missing arguments", null)
return
}
val pages = ArrayList<Map<String, Any>>()
if (mimeType == MimeTypes.TIFF) {
fun toMap(page: Int, options: TiffBitmapFactory.Options): HashMap<String, Any> {
return hashMapOf(
KEY_PAGE to page,
KEY_MIME_TYPE to mimeType,
KEY_WIDTH to options.outWidth,
KEY_HEIGHT to options.outHeight,
)
}
getTiffPageInfo(uri, 0)?.let { first ->
pages.add(toMap(0, first))
val pageCount = first.outDirectoryCount
for (i in 1 until pageCount) {
getTiffPageInfo(uri, i)?.let { pages.add(toMap(i, it)) }
}
}
} else if (isHeic(mimeType)) {
fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) {
if (this.containsKey(key)) save(this.getInteger(key))
}
fun MediaFormat.getSafeLong(key: String, save: (value: Long) -> Unit) {
if (this.containsKey(key)) save(this.getLong(key))
}
val extractor = MediaExtractor()
extractor.setDataSource(context, uri, null)
for (i in 0 until extractor.trackCount) {
try {
val format = extractor.getTrackFormat(i)
format.getString(MediaFormat.KEY_MIME)?.let { mime ->
val trackMime = if (mime == MediaFormat.MIMETYPE_IMAGE_ANDROID_HEIC) MimeTypes.HEIC else mime
val page = hashMapOf<String, Any>(
KEY_PAGE to i,
KEY_MIME_TYPE to trackMime,
)
// do not use `MediaFormat.KEY_TRACK_ID` as it is actually not unique between tracks
// e.g. there could be both a video track and an image track with KEY_TRACK_ID == 1
format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { page[KEY_IS_DEFAULT] = it != 0 }
format.getSafeInt(MediaFormat.KEY_WIDTH) { page[KEY_WIDTH] = it }
format.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it }
if (isVideo(trackMime)) {
format.getSafeLong(MediaFormat.KEY_DURATION) { page[KEY_DURATION] = it / 1000 }
}
pages.add(page)
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get track information for uri=$uri, track num=$i", e)
}
}
extractor.release()
val pages: ArrayList<FieldMap>? = when (mimeType) {
MimeTypes.HEIC, MimeTypes.HEIF -> MultiPage.getHeicTracks(context, uri)
MimeTypes.JPEG -> MultiPage.getMotionPhotoPages(context, uri, mimeType, sizeBytes = sizeBytes)
MimeTypes.TIFF -> MultiPage.getTiffPages(context, uri)
else -> null
}
if (pages?.isEmpty() == true) {
result.error("getMultiPageInfo-empty", "failed to get pages for uri=$uri", null)
} else {
result.success(pages)
}
result.success(pages)
}
private fun getPanoramaInfo(call: MethodCall, result: MethodChannel.Result) {
@ -745,176 +694,13 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
result.success(value?.toString())
}
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 sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getExifThumbnails-args", "failed because of missing arguments", null)
return
}
val thumbnails = ArrayList<ByteArray>()
if (isSupportedByExifInterface(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val exif = ExifInterface(input)
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
exif.thumbnailBitmap?.let { bitmap ->
TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let {
it.getBytes(canHaveAlpha = false, recycle = false)?.let { bytes -> thumbnails.add(bytes) }
}
}
}
} catch (e: Exception) {
// ExifInterface initialization can fail with a RuntimeException
// caused by an internal MediaMetadataRetriever failure
}
}
result.success(thumbnails)
}
private fun extractVideoEmbeddedPicture(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {
result.error("extractVideoEmbeddedPicture-args", "failed because of missing arguments", null)
return
}
val retriever = StorageUtils.openMetadataRetriever(context, uri)
if (retriever != null) {
try {
retriever.embeddedPicture?.let { bytes ->
var embedMimeType: String? = null
bytes.inputStream().use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
metadata.getFirstDirectoryOfType(FileTypeDirectory::class.java)?.let { dir ->
dir.getSafeString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) { embedMimeType = it }
}
}
embedMimeType?.let { mime ->
copyEmbeddedBytes(bytes, mime, result)
return
}
}
} catch (e: Exception) {
result.error("extractVideoEmbeddedPicture-fetch", "failed to fetch picture for uri=$uri", e.message)
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release()
}
}
result.error("extractVideoEmbeddedPicture-empty", "failed to extract picture for uri=$uri", null)
}
private fun extractXmpDataProp(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val dataPropPath = call.argument<String>("propPath")
val embedMimeType = call.argument<String>("propMimeType")
if (mimeType == null || uri == null || dataPropPath == null || embedMimeType == null) {
result.error("extractXmpDataProp-args", "failed because of missing arguments", null)
return
}
if (isSupportedByMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
// data can be large and stored in "Extended XMP",
// which is returned as a second XMP directory
val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java)
try {
val pathParts = dataPropPath.split('/')
val embedBytes: ByteArray = if (pathParts.size == 1) {
val propName = pathParts[0]
val propNs = XMP.namespaceForPropPath(propName)
xmpDirs.map { it.xmpMeta.getPropertyBase64(propNs, propName) }.first { it != null }
} else {
val structName = pathParts[0]
val structNs = XMP.namespaceForPropPath(structName)
val fieldName = pathParts[1]
val fieldNs = XMP.namespaceForPropPath(fieldName)
xmpDirs.map { it.xmpMeta.getStructField(structNs, structName, fieldNs, fieldName) }.first { it != null }.let {
XMPUtils.decodeBase64(it.value)
}
}
copyEmbeddedBytes(embedBytes, embedMimeType, result)
return
} catch (e: XMPException) {
result.error("extractXmpDataProp-xmp", "failed to read XMP directory for uri=$uri prop=$dataPropPath", e.message)
return
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to extract file from XMP", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to extract file from XMP", e)
}
}
result.error("extractXmpDataProp-empty", "failed to extract file from XMP uri=$uri prop=$dataPropPath", null)
}
private fun copyEmbeddedBytes(embedBytes: ByteArray, embedMimeType: String, result: MethodChannel.Result) {
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(Dispatchers.IO) {
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("copyEmbeddedBytes-failure", "failed to get entry for uri=$embedUri mime=$embedMimeType", throwable.message)
})
}
} else {
result.success(embedFields)
}
}
private fun isMultiPageTiff(uri: Uri) = getTiffPageInfo(uri, 0)?.outDirectoryCount ?: 1 > 1
private fun getTiffPageInfo(uri: Uri, page: Int): TiffBitmapFactory.Options? {
try {
val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
if (fd == null) {
Log.w(LOG_TAG, "failed to get file descriptor for uri=$uri")
return null
}
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true
inDirectoryNumber = page
}
TiffBitmapFactory.decodeFileDescriptor(fd, options)
return options
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get TIFF page info for uri=$uri page=$page", e)
}
return null
}
companion object {
private val LOG_TAG = LogUtils.createTag(MetadataHandler::class.java)
private val LOG_TAG = LogUtils.createTag<MetadataHandler>()
const val CHANNEL = "deckers.thibault/aves/metadata"
private val allMetadataRedundantDirNames = setOf(
"MP4",
"MP4 Metadata",
"MP4 Sound",
"MP4 Video",
"QuickTime",
@ -922,7 +708,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
"QuickTime Video",
)
// catalog metadata & page info
// catalog metadata
private const val KEY_MIME_TYPE = "mimeType"
private const val KEY_DATE_MILLIS = "dateMillis"
private const val KEY_FLAGS = "flags"
@ -931,11 +717,6 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private const val KEY_LONGITUDE = "longitude"
private const val KEY_XMP_SUBJECTS = "xmpSubjects"
private const val KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription"
private const val KEY_HEIGHT = "height"
private const val KEY_WIDTH = "width"
private const val KEY_PAGE = "page"
private const val KEY_IS_DEFAULT = "isDefault"
private const val KEY_DURATION = "durationMillis"
private const val MASK_IS_ANIMATED = 1 shl 0
private const val MASK_IS_FLIPPED = 1 shl 1

View file

@ -30,7 +30,7 @@ class RegionFetcher internal constructor(
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true)
fun fetch(
suspend fun fetch(
uri: Uri,
mimeType: String,
pageId: Int?,
@ -114,8 +114,8 @@ class RegionFetcher internal constructor(
val bitmap = target.get()
val tempFile = File.createTempFile("aves", null, context.cacheDir).apply {
deleteOnExit()
outputStream().use { outputStream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
outputStream().use { output ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, output)
}
}
return Uri.fromFile(tempFile)

View file

@ -45,7 +45,7 @@ class ThumbnailFetcher internal constructor(
private val multiTrackFetch = isHeic(mimeType) && pageId != null
private val customFetch = tiffFetch || multiTrackFetch
fun fetch() {
suspend fun fetch() {
var bitmap: Bitmap? = null
var exception: Exception? = null

View file

@ -11,7 +11,7 @@ import org.beyka.tiffbitmapfactory.TiffBitmapFactory
class TiffRegionFetcher internal constructor(
private val context: Context,
) {
fun fetch(
suspend fun fetch(
uri: Uri,
page: Int,
sampleSize: Int,

View file

@ -58,7 +58,7 @@ class ContentChangeStreamHandler(private val context: Context) : EventChannel.St
}
companion object {
private val LOG_TAG = LogUtils.createTag(ContentChangeStreamHandler::class.java)
private val LOG_TAG = LogUtils.createTag<ContentChangeStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/contentchange"
}
}

View file

@ -76,7 +76,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
// - Flutter (as of v1.20): JPEG, PNG, GIF, Animated GIF, WebP, Animated WebP, BMP, and WBMP
// - Android: https://developer.android.com/guide/topics/media/media-formats#image-formats
// - Glide: https://github.com/bumptech/glide/blob/master/library/src/main/java/com/bumptech/glide/load/ImageHeaderParser.java
private fun streamImage() {
private suspend fun streamImage() {
if (arguments !is Map<*, *>) {
endOfStream()
return
@ -114,7 +114,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
}
}
private fun streamImageByGlide(uri: Uri, pageId: Int?, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) {
private suspend fun streamImageByGlide(uri: Uri, pageId: Int?, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) {
val model: Any = if (isHeic(mimeType) && pageId != null) {
MultiTrackImage(activity, uri, pageId)
} else if (mimeType == MimeTypes.TIFF) {
@ -145,7 +145,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
}
}
private fun streamVideoByGlide(uri: Uri) {
private suspend fun streamVideoByGlide(uri: Uri) {
val target = Glide.with(activity)
.asBitmap()
.apply(glideOptions)
@ -175,7 +175,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
}
private fun streamBytes(inputStream: InputStream) {
val buffer = ByteArray(bufferSize)
val buffer = ByteArray(BUFFER_SIZE)
var len: Int
while (inputStream.read(buffer).also { len = it } != -1) {
// cannot decode image on Flutter side when using `buffer` directly
@ -184,10 +184,10 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
}
companion object {
private val LOG_TAG = LogUtils.createTag(ImageByteStreamHandler::class.java)
private val LOG_TAG = LogUtils.createTag<ImageByteStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/imagebytestream"
const val bufferSize = 2 shl 17 // 256kB
const val BUFFER_SIZE = 2 shl 17 // 256kB
// request a fresh image with the highest quality format
val glideOptions = RequestOptions()

View file

@ -1,6 +1,6 @@
package deckers.thibault.aves.channel.streams
import android.content.Context
import android.app.Activity
import android.net.Uri
import android.os.Handler
import android.os.Looper
@ -18,7 +18,7 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.util.*
class ImageOpStreamHandler(private val context: Context, private val arguments: Any?) : EventChannel.StreamHandler {
class ImageOpStreamHandler(private val activity: Activity, private val arguments: Any?) : EventChannel.StreamHandler {
private lateinit var eventSink: EventSink
private lateinit var handler: Handler
@ -103,7 +103,7 @@ class ImageOpStreamHandler(private val context: Context, private val arguments:
"uri" to uri.toString(),
)
try {
provider.delete(context, uri, path)
provider.delete(activity, uri, path)
result["success"] = true
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to delete entry with path=$path", e)
@ -138,7 +138,7 @@ class ImageOpStreamHandler(private val context: Context, private val arguments:
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
val entries = entryMapList.map(::AvesEntry)
provider.exportMultiple(context, mimeType, destinationDir, entries, object : ImageOpCallback {
provider.exportMultiple(activity, mimeType, destinationDir, entries, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = success(fields)
override fun onFailure(throwable: Throwable) = error("export-failure", "failed to export entries", throwable)
})
@ -168,7 +168,7 @@ class ImageOpStreamHandler(private val context: Context, private val arguments:
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
val entries = entryMapList.map(::AvesEntry)
provider.moveMultiple(context, copy, destinationDir, entries, object : ImageOpCallback {
provider.moveMultiple(activity, copy, destinationDir, entries, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = success(fields)
override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable)
})
@ -176,7 +176,7 @@ class ImageOpStreamHandler(private val context: Context, private val arguments:
}
companion object {
private val LOG_TAG = LogUtils.createTag(ImageOpStreamHandler::class.java)
private val LOG_TAG = LogUtils.createTag<ImageOpStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/imageopstream"
}
}

View file

@ -61,7 +61,7 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E
}
companion object {
private val LOG_TAG = LogUtils.createTag(MediaStoreStreamHandler::class.java)
private val LOG_TAG = LogUtils.createTag<MediaStoreStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/mediastorestream"
}
}

View file

@ -75,7 +75,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
}
companion object {
private val LOG_TAG = LogUtils.createTag(StorageAccessStreamHandler::class.java)
private val LOG_TAG = LogUtils.createTag<StorageAccessStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/storageaccessstream"
}
}

View file

@ -19,6 +19,9 @@ import com.bumptech.glide.module.LibraryGlideModule
import com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.StorageUtils.openMetadataRetriever
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.io.ByteArrayInputStream
import java.io.InputStream
@ -47,40 +50,42 @@ internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, InputStream> {
internal class VideoThumbnailFetcher(private val model: VideoThumbnail) : DataFetcher<InputStream> {
override fun loadData(priority: Priority, callback: DataCallback<in InputStream>) {
val retriever = openMetadataRetriever(model.context, model.uri)
if (retriever != null) {
try {
var bytes = retriever.embeddedPicture
if (bytes == null) {
// try to match the thumbnails returned by the content resolver / Media Store
// the following strategies are from empirical evidence from a few test devices:
// - API 29: sync frame closest to the middle
// - API 26/27: default representative frame at any time position
var timeMillis: Long? = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val durationMillis = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull()
if (durationMillis != null) {
timeMillis = durationMillis / 2
GlobalScope.launch(Dispatchers.IO) {
val retriever = openMetadataRetriever(model.context, model.uri)
if (retriever != null) {
try {
var bytes = retriever.embeddedPicture
if (bytes == null) {
// try to match the thumbnails returned by the content resolver / Media Store
// the following strategies are from empirical evidence from a few test devices:
// - API 29: sync frame closest to the middle
// - API 26/27: default representative frame at any time position
var timeMillis: Long? = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val durationMillis = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull()
if (durationMillis != null) {
timeMillis = durationMillis / 2
}
}
val frame = if (timeMillis != null) {
retriever.getFrameAtTime(timeMillis * 1000)
} else {
retriever.frameAtTime
}
bytes = frame?.getBytes(canHaveAlpha = false, recycle = false)
}
val frame = if (timeMillis != null) {
retriever.getFrameAtTime(timeMillis * 1000)
} else {
retriever.frameAtTime
}
bytes = frame?.getBytes(canHaveAlpha = false, recycle = false)
}
if (bytes != null) {
callback.onDataReady(ByteArrayInputStream(bytes))
} else {
callback.onLoadFailed(Exception("failed to get embedded picture or any frame"))
if (bytes != null) {
callback.onDataReady(ByteArrayInputStream(bytes))
} else {
callback.onLoadFailed(Exception("failed to get embedded picture or any frame"))
}
} catch (e: Exception) {
callback.onLoadFailed(e)
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release()
}
} catch (e: Exception) {
callback.onLoadFailed(e)
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release()
}
}
}

View file

@ -17,7 +17,7 @@ import kotlin.math.floor
import kotlin.math.roundToLong
object ExifInterfaceHelper {
private val LOG_TAG = LogUtils.createTag(ExifInterfaceHelper::class.java)
private val LOG_TAG = LogUtils.createTag<ExifInterfaceHelper>()
private val DATETIME_FORMAT = SimpleDateFormat("yyyy:MM:dd hh:mm:ss", Locale.ROOT)
private const val precisionErrorTolerance = 1e-10

View file

@ -2,7 +2,9 @@ package deckers.thibault.aves.metadata
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.exifinterface.media.ExifInterface
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils
import java.io.File
@ -13,6 +15,8 @@ import java.util.*
import java.util.regex.Pattern
object Metadata {
private val LOG_TAG = LogUtils.createTag<Metadata>()
// Pattern to extract latitude & longitude from a video location tag (cf ISO 6709)
// Examples:
// "+37.5090+127.0243/" (Samsung)
@ -96,10 +100,10 @@ object Metadata {
return dateMillis
}
// opening large TIFF files yields an OOM (both with `metadata-extractor` v2.15.0 and `ExifInterface` v1.3.1),
// opening large PSD/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.
private const val tiffSizeBytesMax = 100 * (1 shl 20) // MB
private const val fileSizeBytesMax = 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
@ -108,25 +112,39 @@ object Metadata {
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)
return when (mimeType) {
// formats known to yield OOM for large files
MimeTypes.MP4,
MimeTypes.PSD_VND,
MimeTypes.PSD_X,
MimeTypes.TIFF -> {
if (sizeBytes != null && sizeBytes < fileSizeBytesMax) {
// small enough to be safe as it is
uri
} else {
// make a preview from the beginning of the file,
// hoping the metadata is accessible in the copied chunk
Log.d(LOG_TAG, "use a preview for uri=$uri mimeType=$mimeType size=$sizeBytes")
var previewFile = previewFiles[uri]
if (previewFile == null) {
previewFile = File.createTempFile("aves", null, context.cacheDir).apply {
deleteOnExit()
outputStream().use { output ->
StorageUtils.openInputStream(context, uri)?.use { input ->
val b = ByteArray(previewSize)
input.read(b, 0, previewSize)
output.write(b)
}
}
}
previewFiles[uri] = previewFile
}
Uri.fromFile(previewFile)
}
}
previewFiles[uri] = previewFile
// *probably* safe
else -> uri
}
return Uri.fromFile(previewFile)
}
fun openSafeInputStream(context: Context, uri: Uri, mimeType: String, sizeBytes: Long?): InputStream? {

View file

@ -0,0 +1,196 @@
package deckers.thibault.aves.metadata
import android.content.Context
import android.media.MediaExtractor
import android.media.MediaFormat
import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
import android.util.Log
import com.drew.imaging.ImageMetadataReader
import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.metadata.XMP.getSafeLong
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import java.util.*
object MultiPage {
private val LOG_TAG = LogUtils.createTag<MultiPage>()
// page info
private const val KEY_MIME_TYPE = "mimeType"
private const val KEY_HEIGHT = "height"
private const val KEY_WIDTH = "width"
private const val KEY_PAGE = "page"
private const val KEY_IS_DEFAULT = "isDefault"
private const val KEY_DURATION = "durationMillis"
private const val KEY_ROTATION_DEGREES = "rotationDegrees"
fun getHeicTracks(context: Context, uri: Uri): ArrayList<FieldMap> {
fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) {
if (this.containsKey(key)) save(this.getInteger(key))
}
fun MediaFormat.getSafeLong(key: String, save: (value: Long) -> Unit) {
if (this.containsKey(key)) save(this.getLong(key))
}
val tracks = ArrayList<FieldMap>()
val extractor = MediaExtractor()
extractor.setDataSource(context, uri, null)
for (i in 0 until extractor.trackCount) {
try {
val format = extractor.getTrackFormat(i)
format.getString(MediaFormat.KEY_MIME)?.let { mime ->
val trackMime = if (mime == MediaFormat.MIMETYPE_IMAGE_ANDROID_HEIC) MimeTypes.HEIC else mime
val track = hashMapOf<String, Any?>(
KEY_PAGE to i,
KEY_MIME_TYPE to trackMime,
)
// do not use `MediaFormat.KEY_TRACK_ID` as it is actually not unique between tracks
// e.g. there could be both a video track and an image track with KEY_TRACK_ID == 1
format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { track[KEY_IS_DEFAULT] = it != 0 }
format.getSafeInt(MediaFormat.KEY_WIDTH) { track[KEY_WIDTH] = it }
format.getSafeInt(MediaFormat.KEY_HEIGHT) { track[KEY_HEIGHT] = it }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
format.getSafeInt(MediaFormat.KEY_ROTATION) { track[KEY_ROTATION_DEGREES] = it }
}
if (MimeTypes.isVideo(trackMime)) {
format.getSafeLong(MediaFormat.KEY_DURATION) { track[KEY_DURATION] = it / 1000 }
}
tracks.add(track)
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get HEIC track information for uri=$uri, track num=$i", e)
}
}
extractor.release()
return tracks
}
fun getMotionPhotoPages(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): ArrayList<FieldMap> {
fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) {
if (this.containsKey(key)) save(this.getInteger(key))
}
fun MediaFormat.getSafeLong(key: String, save: (value: Long) -> Unit) {
if (this.containsKey(key)) save(this.getLong(key))
}
val tracks = ArrayList<FieldMap>()
val extractor = MediaExtractor()
var pfd: ParcelFileDescriptor? = null
try {
getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
val videoStartOffset = sizeBytes - videoSizeBytes
pfd = context.contentResolver.openFileDescriptor(uri, "r")
pfd?.fileDescriptor?.let { fd ->
extractor.setDataSource(fd, videoStartOffset, videoSizeBytes)
// set the original image as the first and default track
var trackCount = 0
tracks.add(
hashMapOf(
KEY_PAGE to trackCount++,
KEY_MIME_TYPE to mimeType,
KEY_IS_DEFAULT to true,
)
)
// add video tracks from the appended video
for (i in 0 until extractor.trackCount) {
try {
val format = extractor.getTrackFormat(i)
format.getString(MediaFormat.KEY_MIME)?.let { mime ->
if (MimeTypes.isVideo(mime)) {
val track = hashMapOf<String, Any?>(
KEY_PAGE to trackCount++,
KEY_MIME_TYPE to MimeTypes.MP4,
KEY_IS_DEFAULT to false,
)
format.getSafeInt(MediaFormat.KEY_WIDTH) { track[KEY_WIDTH] = it }
format.getSafeInt(MediaFormat.KEY_HEIGHT) { track[KEY_HEIGHT] = it }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
format.getSafeInt(MediaFormat.KEY_ROTATION) { track[KEY_ROTATION_DEGREES] = it }
}
format.getSafeLong(MediaFormat.KEY_DURATION) { track[KEY_DURATION] = it / 1000 }
tracks.add(track)
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get motion photo track information for uri=$uri, track num=$i", e)
}
}
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to open motion photo for uri=$uri", e)
} finally {
extractor.release()
pfd?.close()
}
return tracks
}
fun getMotionPhotoOffset(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Long? {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
var offsetFromEnd: Long? = null
dir.xmpMeta.getSafeLong(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME) { offsetFromEnd = it }
return offsetFromEnd
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get motion photo offset from uri=$uri", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to get motion photo offset from uri=$uri", e)
}
return null
}
fun getTiffPages(context: Context, uri: Uri): ArrayList<FieldMap> {
fun toMap(page: Int, options: TiffBitmapFactory.Options): FieldMap {
return hashMapOf(
KEY_PAGE to page,
KEY_MIME_TYPE to MimeTypes.TIFF,
KEY_WIDTH to options.outWidth,
KEY_HEIGHT to options.outHeight,
)
}
val pages = ArrayList<FieldMap>()
getTiffPageInfo(context, uri, 0)?.let { first ->
pages.add(toMap(0, first))
val pageCount = first.outDirectoryCount
for (i in 1 until pageCount) {
getTiffPageInfo(context, uri, i)?.let { pages.add(toMap(i, it)) }
}
}
return pages
}
fun isMultiPageTiff(context: Context, uri: Uri) = getTiffPageInfo(context, uri, 0)?.outDirectoryCount ?: 1 > 1
private fun getTiffPageInfo(context: Context, uri: Uri, page: Int): TiffBitmapFactory.Options? {
try {
val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
if (fd == null) {
Log.w(LOG_TAG, "failed to get TIFF file descriptor for uri=$uri")
return null
}
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true
inDirectoryNumber = page
}
TiffBitmapFactory.decodeFileDescriptor(fd, options)
return options
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get TIFF page info for uri=$uri page=$page", e)
}
return null
}
}

View file

@ -13,7 +13,7 @@ import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils
object MultiTrackMedia {
private val LOG_TAG = LogUtils.createTag(MultiTrackMedia::class.java)
private val LOG_TAG = LogUtils.createTag<MultiTrackMedia>()
@RequiresApi(Build.VERSION_CODES.P)
fun getImage(context: Context, uri: Uri, trackIndex: Int?): Bitmap? {

View file

@ -82,7 +82,7 @@ class GSpherical(xmlBytes: ByteArray) {
).filterValues { it != null }
companion object SphericalVideo {
private val LOG_TAG = LogUtils.createTag(SphericalVideo::class.java)
private val LOG_TAG = LogUtils.createTag<SphericalVideo>()
// cf https://github.com/google/spatial-media
const val SPHERICAL_VIDEO_V1_UUID = "ffcc8263-f855-4a93-8814-587a02521fdd"

View file

@ -8,7 +8,7 @@ import deckers.thibault.aves.utils.LogUtils
import java.util.*
object XMP {
private val LOG_TAG = LogUtils.createTag(XMP::class.java)
private val LOG_TAG = LogUtils.createTag<XMP>()
// standard namespaces
// cf com.adobe.internal.xmp.XMPConst
@ -42,6 +42,12 @@ object XMP {
fun isDataPath(path: String) = knownDataPaths.contains(path)
// motion photo
const val GCAMERA_SCHEMA_NS = "http://ns.google.com/photos/1.0/camera/"
const val GCAMERA_VIDEO_OFFSET_PROP_NAME = "GCamera:MicroVideoOffset"
// panorama
// cf https://developers.google.com/streetview/spherical-metadata
@ -71,6 +77,19 @@ object XMP {
// extensions
fun XMPMeta.isMotionPhoto(): Boolean {
try {
return doesPropertyExist(GCAMERA_SCHEMA_NS, GCAMERA_VIDEO_OFFSET_PROP_NAME)
} catch (e: XMPException) {
if (e.errorCode != XMPError.BADSCHEMA) {
// `BADSCHEMA` code is reported when we check a property
// from a non standard namespace, and that namespace is not declared in the XMP
Log.w(LOG_TAG, "failed to check Google motion photo props from XMP", e)
}
}
return false
}
fun XMPMeta.isPanorama(): Boolean {
// Google
try {
@ -111,6 +130,20 @@ object XMP {
}
}
fun XMPMeta.getSafeLong(schema: String, propName: String, save: (value: Long) -> Unit) {
try {
if (doesPropertyExist(schema, propName)) {
val item = getPropertyLong(schema, propName)
// double check retrieved items as the property sometimes is reported to exist but it is actually null
if (item != null) {
save(item)
}
}
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to get long for XMP schema=$schema, propName=$propName", e)
}
}
fun XMPMeta.getSafeString(schema: String, propName: String, save: (value: String) -> Unit) {
try {
if (doesPropertyExist(schema, propName)) {

View file

@ -3,6 +3,7 @@ package deckers.thibault.aves.model.provider
import android.content.Context
import android.net.Uri
import android.provider.MediaStore
import android.provider.OpenableColumns
import deckers.thibault.aves.model.SourceEntry
internal class ContentImageProvider : ImageProvider() {
@ -19,8 +20,9 @@ internal class ContentImageProvider : ImageProvider() {
try {
val cursor = context.contentResolver.query(uri, projection, null, null, null)
if (cursor != null && cursor.moveToFirst()) {
cursor.getColumnIndex(MediaStore.MediaColumns.SIZE).let { if (it != -1) map["sizeBytes"] = cursor.getLong(it) }
cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME).let { if (it != -1) map["title"] = cursor.getString(it) }
cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).let { if (it != -1) map["title"] = cursor.getString(it) }
cursor.getColumnIndex(OpenableColumns.SIZE).let { if (it != -1) map["sizeBytes"] = cursor.getLong(it) }
cursor.getColumnIndex(PATH).let { if (it != -1) map["path"] = cursor.getString(it) }
cursor.close()
}
} catch (e: Exception) {
@ -37,9 +39,15 @@ internal class ContentImageProvider : ImageProvider() {
}
companion object {
@Suppress("DEPRECATION")
const val PATH = MediaStore.MediaColumns.DATA
private val projection = arrayOf(
MediaStore.MediaColumns.SIZE,
MediaStore.MediaColumns.DISPLAY_NAME
// standard columns for openable URI
OpenableColumns.DISPLAY_NAME,
OpenableColumns.SIZE,
// optional path underlying media content
PATH,
)
}
}

View file

@ -1,5 +1,6 @@
package deckers.thibault.aves.model.provider
import android.app.Activity
import android.content.ContentUris
import android.content.Context
import android.graphics.Bitmap
@ -16,16 +17,18 @@ import com.bumptech.glide.request.RequestOptions
import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.decoder.MultiTrackImage
import deckers.thibault.aves.decoder.TiffImage
import deckers.thibault.aves.metadata.MultiPage
import deckers.thibault.aves.model.AvesEntry
import deckers.thibault.aves.model.ExifOrientationOp
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils.copyFileToTemp
import deckers.thibault.aves.utils.*
import deckers.thibault.aves.utils.MimeTypes.extensionFor
import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent
import deckers.thibault.aves.utils.StorageUtils.getDocumentFile
import deckers.thibault.aves.utils.UriUtils.tryParseId
import java.io.ByteArrayInputStream
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
@ -39,21 +42,25 @@ abstract class ImageProvider {
callback.onFailure(UnsupportedOperationException())
}
open suspend fun delete(context: Context, uri: Uri, path: String?) {
open suspend fun delete(activity: Activity, uri: Uri, path: String?) {
throw UnsupportedOperationException()
}
open suspend fun moveMultiple(context: Context, copy: Boolean, destinationDir: String, entries: List<AvesEntry>, callback: ImageOpCallback) {
open suspend fun moveMultiple(activity: Activity, copy: Boolean, destinationDir: String, entries: List<AvesEntry>, callback: ImageOpCallback) {
callback.onFailure(UnsupportedOperationException())
}
suspend fun exportMultiple(
context: Context,
mimeType: String,
imageExportMimeType: String,
destinationDir: String,
entries: List<AvesEntry>,
callback: ImageOpCallback,
) {
if (!supportedExportMimeTypes.contains(imageExportMimeType)) {
throw Exception("unsupported export MIME type=$imageExportMimeType")
}
val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir)
if (destinationDirDocFile == null) {
callback.onFailure(Exception("failed to create directory at path=$destinationDir"))
@ -71,13 +78,15 @@ abstract class ImageProvider {
"success" to false,
)
val sourceMimeType = entry.mimeType
val exportMimeType = if (isVideo(sourceMimeType)) sourceMimeType else imageExportMimeType
try {
val newFields = exportSingleByTreeDocAndScan(
context = context,
sourceEntry = entry,
destinationDir = destinationDir,
destinationDirDocFile = destinationDirDocFile,
exportMimeType = mimeType,
exportMimeType = exportMimeType,
)
result["newFields"] = newFields
result["success"] = true
@ -111,12 +120,7 @@ abstract class ImageProvider {
val page = if (sourceMimeType == MimeTypes.TIFF) pageId + 1 else pageId
desiredNameWithoutExtension += "_${page.toString().padStart(3, '0')}"
}
val desiredFileName = desiredNameWithoutExtension + when (exportMimeType) {
MimeTypes.JPEG -> ".jpg"
MimeTypes.PNG -> ".png"
MimeTypes.WEBP -> ".webp"
else -> throw Exception("unsupported export MIME type=$exportMimeType")
}
val desiredFileName = desiredNameWithoutExtension + extensionFor(exportMimeType)
if (File(destinationDir, desiredFileName).exists()) {
throw Exception("file with name=$desiredFileName already exists in destination directory")
@ -130,56 +134,65 @@ abstract class ImageProvider {
val destinationTreeFile = destinationDirDocFile.createFile(exportMimeType, desiredNameWithoutExtension)
val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri)
val model: Any = if (MimeTypes.isHeic(sourceMimeType) && pageId != null) {
MultiTrackImage(context, sourceUri, pageId)
} else if (sourceMimeType == MimeTypes.TIFF) {
TiffImage(context, sourceUri, pageId)
if (isVideo(sourceMimeType)) {
val sourceDocFile = DocumentFileCompat.fromSingleUri(context, sourceUri)
@Suppress("BlockingMethodInNonBlockingContext")
sourceDocFile.copyTo(destinationDocFile)
} else {
sourceUri
}
// request a fresh image with the highest quality format
val glideOptions = RequestOptions()
.format(DecodeFormat.PREFER_ARGB_8888)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true)
val target = Glide.with(context)
.asBitmap()
.apply(glideOptions)
.load(model)
.submit()
try {
@Suppress("BlockingMethodInNonBlockingContext")
var bitmap = target.get()
if (MimeTypes.needRotationAfterGlide(sourceMimeType)) {
bitmap = BitmapUtils.applyExifOrientation(context, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped)
val model: Any = if (MimeTypes.isHeic(sourceMimeType) && pageId != null) {
MultiTrackImage(context, sourceUri, pageId)
} else if (sourceMimeType == MimeTypes.TIFF) {
TiffImage(context, sourceUri, pageId)
} else {
sourceUri
}
bitmap ?: throw Exception("failed to get image from uri=$sourceUri page=$pageId")
val quality = 100
val format = when (exportMimeType) {
MimeTypes.JPEG -> Bitmap.CompressFormat.JPEG
MimeTypes.PNG -> Bitmap.CompressFormat.PNG
MimeTypes.WEBP -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (quality == 100) {
Bitmap.CompressFormat.WEBP_LOSSLESS
} else {
Bitmap.CompressFormat.WEBP_LOSSY
}
} else {
@Suppress("DEPRECATION")
Bitmap.CompressFormat.WEBP
// request a fresh image with the highest quality format
val glideOptions = RequestOptions()
.format(DecodeFormat.PREFER_ARGB_8888)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true)
val target = Glide.with(context)
.asBitmap()
.apply(glideOptions)
.load(model)
.submit()
try {
@Suppress("BlockingMethodInNonBlockingContext")
var bitmap = target.get()
if (MimeTypes.needRotationAfterGlide(sourceMimeType)) {
bitmap = BitmapUtils.applyExifOrientation(context, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped)
}
else -> throw Exception("unsupported export MIME type=$exportMimeType")
}
bitmap ?: throw Exception("failed to get image from uri=$sourceUri page=$pageId")
@Suppress("BlockingMethodInNonBlockingContext")
destinationDocFile.openOutputStream().use {
bitmap.compress(format, quality, it)
@Suppress("BlockingMethodInNonBlockingContext")
destinationDocFile.openOutputStream().use { output ->
if (exportMimeType == MimeTypes.BMP) {
BmpWriter.writeRGB24(bitmap, output)
} else {
val quality = 100
val format = when (exportMimeType) {
MimeTypes.JPEG -> Bitmap.CompressFormat.JPEG
MimeTypes.PNG -> Bitmap.CompressFormat.PNG
MimeTypes.WEBP -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (quality == 100) {
Bitmap.CompressFormat.WEBP_LOSSLESS
} else {
Bitmap.CompressFormat.WEBP_LOSSY
}
} else {
@Suppress("DEPRECATION")
Bitmap.CompressFormat.WEBP
}
else -> throw Exception("unsupported export MIME type=$exportMimeType")
}
bitmap.compress(format, quality, output)
}
}
} finally {
Glide.with(context).clear(target)
}
} finally {
Glide.with(context).clear(target)
}
val fileName = destinationDocFile.name
@ -218,7 +231,7 @@ abstract class ImageProvider {
}
}
fun changeOrientation(context: Context, path: String, uri: Uri, mimeType: String, op: ExifOrientationOp, callback: ImageOpCallback) {
fun changeOrientation(context: Context, path: String, uri: Uri, mimeType: String, sizeBytes: Long, op: ExifOrientationOp, callback: ImageOpCallback) {
if (!canEditExif(mimeType)) {
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
return
@ -230,16 +243,44 @@ abstract class ImageProvider {
return
}
// copy original file to a temporary file for editing
val editablePath = copyFileToTemp(originalDocumentFile, path)
if (editablePath == null) {
callback.onFailure(Exception("failed to create a temporary file for path=$path"))
return
val videoSizeBytes = MultiPage.getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.toInt()
var videoBytes: ByteArray? = null
val editableFile = File.createTempFile("aves", null).apply {
deleteOnExit()
try {
outputStream().use { output ->
if (videoSizeBytes != null) {
// handle motion photo and embedded video separately
val imageSizeBytes = (sizeBytes - videoSizeBytes).toInt()
videoBytes = ByteArray(videoSizeBytes)
StorageUtils.openInputStream(context, uri)?.let { input ->
val imageBytes = ByteArray(imageSizeBytes)
input.read(imageBytes, 0, imageSizeBytes)
input.read(videoBytes, 0, videoSizeBytes)
// copy only the image to a temporary file for editing
// video will be appended after EXIF modification
ByteArrayInputStream(imageBytes).use { imageInput ->
imageInput.copyTo(output)
}
}
} else {
// copy original file to a temporary file for editing
originalDocumentFile.openInputStream().use { imageInput ->
imageInput.copyTo(output)
}
}
}
} catch (e: Exception) {
callback.onFailure(e)
return
}
}
val newFields = HashMap<String, Any?>()
try {
val exif = ExifInterface(editablePath)
val exif = ExifInterface(editableFile)
// when the orientation is not defined, it returns `undefined (0)` instead of the orientation default value `normal (1)`
// in that case we explicitly set it to `normal` first
// because ExifInterface fails to rotate an image with undefined orientation
@ -255,8 +296,12 @@ abstract class ImageProvider {
}
exif.saveAttributes()
if (videoBytes != null) {
// append motion photo video, if any
editableFile.appendBytes(videoBytes!!)
}
// copy the edited temporary file back to the original
DocumentFileCompat.fromFile(File(editablePath)).copyTo(originalDocumentFile)
DocumentFileCompat.fromFile(editableFile).copyTo(originalDocumentFile)
newFields["rotationDegrees"] = exif.rotationDegrees
newFields["isFlipped"] = exif.isFlipped
@ -285,7 +330,7 @@ abstract class ImageProvider {
// as of androidx.exifinterface:exifinterface:1.3.0
private fun canEditExif(mimeType: String): Boolean {
return when (mimeType) {
"image/jpeg", "image/png", "image/webp" -> true
MimeTypes.JPEG, MimeTypes.PNG, MimeTypes.WEBP -> true
else -> false
}
}
@ -300,9 +345,9 @@ abstract class ImageProvider {
// but we need an image/video media URI (e.g. "content://media/external/images/media/62872")
contentId = newUri.tryParseId()
if (contentId != null) {
if (MimeTypes.isImage(mimeType)) {
if (isImage(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId)
} else if (MimeTypes.isVideo(mimeType)) {
} else if (isVideo(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId)
}
}
@ -349,6 +394,8 @@ abstract class ImageProvider {
}
companion object {
private val LOG_TAG = LogUtils.createTag(ImageProvider::class.java)
private val LOG_TAG = LogUtils.createTag<ImageProvider>()
val supportedExportMimeTypes = listOf(MimeTypes.BMP, MimeTypes.JPEG, MimeTypes.PNG, MimeTypes.WEBP)
}
}

View file

@ -1,6 +1,8 @@
package deckers.thibault.aves.model.provider
import android.annotation.SuppressLint
import android.app.Activity
import android.app.RecoverableSecurityException
import android.content.ContentUris
import android.content.Context
import android.net.Uri
@ -8,6 +10,7 @@ import android.os.Build
import android.provider.MediaStore
import android.util.Log
import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.MainActivity.Companion.DELETE_PERMISSION_REQUEST
import deckers.thibault.aves.model.AvesEntry
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.SourceEntry
@ -22,6 +25,7 @@ import deckers.thibault.aves.utils.StorageUtils.requireAccessPermission
import deckers.thibault.aves.utils.UriUtils.tryParseId
import java.io.File
import java.util.*
import java.util.concurrent.CompletableFuture
import kotlin.collections.ArrayList
class MediaStoreImageProvider : ImageProvider() {
@ -205,31 +209,55 @@ class MediaStoreImageProvider : ImageProvider() {
private fun needSize(mimeType: String) = MimeTypes.SVG != mimeType
// `uri` is a media URI, not a document URI
override suspend fun delete(context: Context, uri: Uri, path: String?) {
override suspend fun delete(activity: Activity, uri: Uri, path: String?) {
path ?: throw Exception("failed to delete file because path is null")
if (File(path).exists() && requireAccessPermission(context, path)) {
if (File(path).exists() && requireAccessPermission(activity, path)) {
// if the file is on SD card, calling the content resolver `delete()` removes the entry from the Media Store
// but it doesn't delete the file, even if the app has the permission
val df = getDocumentFile(context, path, uri)
val df = getDocumentFile(activity, path, uri)
@Suppress("BlockingMethodInNonBlockingContext")
if (df != null && df.delete()) return
throw Exception("failed to delete file with df=$df")
}
if (context.contentResolver.delete(uri, null, null) > 0) return
try {
if (activity.contentResolver.delete(uri, null, null) > 0) return
} catch (securityException: SecurityException) {
// even if the app has access permission granted on the containing directory,
// the delete request may yield a `RecoverableSecurityException` on Android 10+
// when the underlying file no longer exists and this is an orphaned entry in the Media Store
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val rse = securityException as? RecoverableSecurityException ?: throw securityException
val intentSender = rse.userAction.actionIntent.intentSender
// request user permission for this item
pendingDeleteCompleter = CompletableFuture<Boolean>()
activity.startIntentSenderForResult(intentSender, DELETE_PERMISSION_REQUEST, null, 0, 0, 0, null)
val granted = pendingDeleteCompleter!!.join()
pendingDeleteCompleter = null
if (granted) {
delete(activity, uri, path)
} else {
throw Exception("failed to get delete permission")
}
} else {
throw securityException
}
}
throw Exception("failed to delete row from content provider")
}
override suspend fun moveMultiple(
context: Context,
activity: Activity,
copy: Boolean,
destinationDir: String,
entries: List<AvesEntry>,
callback: ImageOpCallback,
) {
val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir)
val destinationDirDocFile = createDirectoryIfAbsent(activity, destinationDir)
if (destinationDirDocFile == null) {
callback.onFailure(Exception("failed to create directory at path=$destinationDir"))
return
@ -262,7 +290,7 @@ class MediaStoreImageProvider : ImageProvider() {
// - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage
try {
val newFields = moveSingleByTreeDocAndScan(
context, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy,
activity, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy,
)
result["newFields"] = newFields
result["success"] = true
@ -275,7 +303,7 @@ class MediaStoreImageProvider : ImageProvider() {
}
private suspend fun moveSingleByTreeDocAndScan(
context: Context,
activity: Activity,
sourcePath: String,
sourceUri: Uri,
destinationDir: String,
@ -303,12 +331,12 @@ class MediaStoreImageProvider : ImageProvider() {
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
@Suppress("BlockingMethodInNonBlockingContext")
val destinationTreeFile = destinationDirDocFile.createFile(mimeType, desiredNameWithoutExtension)
val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri)
val destinationDocFile = DocumentFileCompat.fromSingleUri(activity, destinationTreeFile.uri)
// `DocumentsContract.moveDocument()` needs `sourceParentDocumentUri`, which could be different for each entry
// `DocumentsContract.copyDocument()` yields "Unsupported call: android:copyDocument"
// when used with entry URI as `sourceDocumentUri`, and destinationDirDocFile URI as `targetParentDocumentUri`
val source = DocumentFileCompat.fromSingleUri(context, sourceUri)
val source = DocumentFileCompat.fromSingleUri(activity, sourceUri)
@Suppress("BlockingMethodInNonBlockingContext")
source.copyTo(destinationDocFile)
@ -322,20 +350,20 @@ class MediaStoreImageProvider : ImageProvider() {
if (!copy) {
// delete original entry
try {
delete(context, sourceUri, sourcePath)
delete(activity, sourceUri, sourcePath)
deletedSource = true
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to delete entry with path=$sourcePath", e)
}
}
return scanNewPath(context, destinationFullPath, mimeType).apply {
return scanNewPath(activity, destinationFullPath, mimeType).apply {
put("deletedSource", deletedSource)
}
}
companion object {
private val LOG_TAG = LogUtils.createTag(MediaStoreImageProvider::class.java)
private val LOG_TAG = LogUtils.createTag<MediaStoreImageProvider>()
private val IMAGE_CONTENT_URI = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
private val VIDEO_CONTENT_URI = MediaStore.Video.Media.EXTERNAL_CONTENT_URI
@ -366,6 +394,8 @@ class MediaStoreImageProvider : ImageProvider() {
MediaStore.MediaColumns.ORIENTATION,
) else emptyArray()
)
var pendingDeleteCompleter: CompletableFuture<Boolean>? = null
}
}

View file

@ -6,24 +6,46 @@ import android.util.Log
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
import deckers.thibault.aves.metadata.Metadata.getExifCode
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.ByteArrayOutputStream
object BitmapUtils {
private val LOG_TAG = LogUtils.createTag(BitmapUtils::class.java)
private val LOG_TAG = LogUtils.createTag<BitmapUtils>()
private const val INITIAL_BUFFER_SIZE = 2 shl 17 // 256kB
fun Bitmap.getBytes(canHaveAlpha: Boolean = false, quality: Int = 100, recycle: Boolean = true): ByteArray? {
private val freeBaos = ArrayList<ByteArrayOutputStream>()
private val mutex = Mutex()
suspend fun Bitmap.getBytes(canHaveAlpha: Boolean = false, quality: Int = 100, recycle: Boolean): ByteArray? {
val stream: ByteArrayOutputStream
mutex.withLock {
// this method is called a lot, so we try and reuse output streams
// to reduce inner array allocations, and make the GC run less frequently
stream = if (freeBaos.isNotEmpty()) {
freeBaos.removeAt(0)
} else {
ByteArrayOutputStream(INITIAL_BUFFER_SIZE)
}
}
try {
val stream = ByteArrayOutputStream()
// we compress the bitmap because Flutter cannot decode the raw bytes
// the Bitmap raw bytes are not decodable by Flutter
// we need to format them (compress, or add a BMP header) before sending them
// `Bitmap.CompressFormat.PNG` is slower than `JPEG`, but it allows transparency
if (canHaveAlpha) {
// the BMP format allows an alpha channel, but Android decoding seems to ignore it
if (canHaveAlpha && hasAlpha()) {
this.compress(Bitmap.CompressFormat.PNG, quality, stream)
} else {
this.compress(Bitmap.CompressFormat.JPEG, quality, stream)
}
if (recycle) this.recycle()
return stream.toByteArray()
} catch (e: IllegalStateException) {
val byteArray = stream.toByteArray()
stream.reset()
mutex.withLock {
freeBaos.add(stream)
}
return byteArray
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to get bytes from bitmap", e)
}
return null

View file

@ -0,0 +1,111 @@
package deckers.thibault.aves.utils
import android.graphics.Bitmap
import java.io.OutputStream
import java.nio.ByteBuffer
object BmpWriter {
private const val FILE_HEADER_SIZE = 14
private const val INFO_HEADER_SIZE = 40
private const val BYTE_PER_PIXEL = 3
private val pad = ByteArray(3)
// file header
private val bfType = byteArrayOf('B'.toByte(), 'M'.toByte())
private val bfReserved1 = intToWord(0)
private val bfReserved2 = intToWord(0)
private val bfOffBits = intToDWord(FILE_HEADER_SIZE + INFO_HEADER_SIZE)
// info header
private val biSize = intToDWord(INFO_HEADER_SIZE)
private val biPlanes = intToWord(1)
private val biBitCount = intToWord(BYTE_PER_PIXEL * 8)
private val biCompression = intToDWord(0)
private val biXPelsPerMeter = intToDWord(0)
private val biYPelsPerMeter = intToDWord(0)
private val biClrUsed = intToDWord(0)
private val biClrImportant = intToDWord(0)
// converts an int to a word (2-byte array)
private fun intToWord(v: Int): ByteArray {
val retValue = ByteArray(2)
retValue[0] = (v and 0xFF).toByte()
retValue[1] = (v shr 8 and 0xFF).toByte()
return retValue
}
// converts an int to a double word (4-byte array)
private fun intToDWord(v: Int): ByteArray {
val retValue = ByteArray(4)
retValue[0] = (v and 0xFF).toByte()
retValue[1] = (v shr 8 and 0xFF).toByte()
retValue[2] = (v shr 16 and 0xFF).toByte()
retValue[3] = (v shr 24 and 0xFF).toByte()
return retValue
}
fun writeRGB24(
bitmap: Bitmap,
outputStream: OutputStream
) {
// init
val biWidth = bitmap.width
val biHeight = bitmap.height
val padPerRow = (4 - (biWidth * BYTE_PER_PIXEL) % 4) % 4
val biSizeImage = (biWidth * BYTE_PER_PIXEL + padPerRow) * biHeight
val bfSize = FILE_HEADER_SIZE + INFO_HEADER_SIZE + biSizeImage
val buffer = ByteBuffer.allocate(bfSize)
val pixels = IntArray(biWidth * biHeight)
bitmap.getPixels(pixels, 0, biWidth, 0, 0, biWidth, biHeight)
// file header
buffer.put(bfType)
buffer.put(intToDWord(bfSize))
buffer.put(bfReserved1)
buffer.put(bfReserved2)
buffer.put(bfOffBits)
// info header
buffer.put(biSize)
buffer.put(intToDWord(biWidth))
buffer.put(intToDWord(biHeight))
buffer.put(biPlanes)
buffer.put(biBitCount)
buffer.put(biCompression)
buffer.put(intToDWord(biSizeImage))
buffer.put(biXPelsPerMeter)
buffer.put(biYPelsPerMeter)
buffer.put(biClrUsed)
buffer.put(biClrImportant)
// pixels
val rgb = ByteArray(BYTE_PER_PIXEL)
var value: Int
var row = biHeight - 1
while (row >= 0) {
var column = 0
while (column < biWidth) {
/*
alpha: (value shr 24 and 0xFF).toByte()
red: (value shr 16 and 0xFF).toByte()
green: (value shr 8 and 0xFF).toByte()
blue: (value and 0xFF).toByte()
*/
value = pixels[row * biWidth + column]
// blue: [0], green: [1], red: [2]
rgb[0] = (value and 0xFF).toByte()
rgb[1] = (value shr 8 and 0xFF).toByte()
rgb[2] = (value shr 16 and 0xFF).toByte()
buffer.put(rgb)
column++
}
if (padPerRow > 0) {
buffer.put(pad, 0, padPerRow)
}
row--
}
// write to output stream
outputStream.write(buffer.array())
}
}

View file

@ -3,16 +3,17 @@ package deckers.thibault.aves.utils
import java.util.regex.Pattern
object LogUtils {
private const val LOG_TAG_MAX_LENGTH = 23
private val LOG_TAG_PACKAGE_PATTERN = Pattern.compile("(\\w)(\\w*)\\.")
const val LOG_TAG_MAX_LENGTH = 23
val LOG_TAG_PACKAGE_PATTERN: Pattern = Pattern.compile("(\\w)(\\w*)\\.")
// create an Android logger friendly log tag for the specified class
fun createTag(clazz: Class<*>): String {
inline fun <reified T> createTag(): String {
val kClass = T::class
// shorten class name to "a.b.CccDdd"
var logTag = LOG_TAG_PACKAGE_PATTERN.matcher(clazz.name).replaceAll("$1.")
var logTag = LOG_TAG_PACKAGE_PATTERN.matcher(kClass.qualifiedName!!).replaceAll("$1.")
if (logTag.length > LOG_TAG_MAX_LENGTH) {
// shorten class name to "a.b.CD"
val simpleName = clazz.simpleName
val simpleName = kClass.simpleName!!
val shortSimpleName = simpleName.replace("[a-z]".toRegex(), "")
logTag = logTag.replace(simpleName, shortSimpleName)
if (logTag.length > LOG_TAG_MAX_LENGTH) {

View file

@ -6,14 +6,16 @@ object MimeTypes {
private const val IMAGE = "image"
// generic raster
private const val BMP = "image/bmp"
const val BMP = "image/bmp"
private const val DJVU = "image/vnd.djvu"
const val GIF = "image/gif"
const val HEIC = "image/heic"
private const val HEIF = "image/heif"
const val HEIF = "image/heif"
private const val ICO = "image/x-icon"
const val JPEG = "image/jpeg"
const val PNG = "image/png"
const val PSD_VND = "image/vnd.adobe.photoshop"
const val PSD_X = "image/x-photoshop"
const val TIFF = "image/tiff"
private const val WBMP = "image/vnd.wap.wbmp"
const val WEBP = "image/webp"
@ -37,6 +39,7 @@ object MimeTypes {
private const val MP2T = "video/mp2t"
private const val MP2TS = "video/mp2ts"
const val MP4 = "video/mp4"
private const val WEBM = "video/webm"
fun isImage(mimeType: String?) = mimeType != null && mimeType.startsWith(IMAGE)
@ -95,5 +98,17 @@ object MimeTypes {
// extensions
fun extensionFor(mimeType: String): String? = when (mimeType) {
BMP -> ".bmp"
GIF -> ".gif"
HEIC, HEIF -> ".heif"
JPEG -> ".jpg"
MP4 -> ".mp4"
PNG -> ".png"
TIFF -> ".tiff"
WEBP -> ".webp"
else -> null
}
val tiffExtensionPattern = Regex(".*\\.tiff?", RegexOption.IGNORE_CASE)
}

View file

@ -9,6 +9,7 @@ import android.os.Environment
import android.os.storage.StorageManager
import android.util.Log
import androidx.annotation.RequiresApi
import deckers.thibault.aves.MainActivity.Companion.VOLUME_ACCESS_REQUEST
import deckers.thibault.aves.utils.StorageUtils.PathSegments
import java.io.File
import java.util.*
@ -16,9 +17,7 @@ import java.util.concurrent.ConcurrentHashMap
import kotlin.collections.ArrayList
object PermissionManager {
private val LOG_TAG = LogUtils.createTag(PermissionManager::class.java)
const val VOLUME_ACCESS_REQUEST_CODE = 1
private val LOG_TAG = LogUtils.createTag<PermissionManager>()
// permission request code to pending runnable
private val pendingPermissionMap = ConcurrentHashMap<Int, PendingPermissionHandler>()
@ -39,8 +38,8 @@ object PermissionManager {
}
if (intent.resolveActivity(activity.packageManager) != null) {
pendingPermissionMap[VOLUME_ACCESS_REQUEST_CODE] = PendingPermissionHandler(path, onGranted, onDenied)
activity.startActivityForResult(intent, VOLUME_ACCESS_REQUEST_CODE, null)
pendingPermissionMap[VOLUME_ACCESS_REQUEST] = PendingPermissionHandler(path, onGranted, onDenied)
activity.startActivityForResult(intent, VOLUME_ACCESS_REQUEST, null)
} else {
Log.e(LOG_TAG, "failed to resolve activity for intent=$intent")
onDenied()

View file

@ -11,19 +11,17 @@ import android.provider.DocumentsContract
import android.provider.MediaStore
import android.text.TextUtils
import android.util.Log
import android.webkit.MimeTypeMap
import androidx.annotation.RequiresApi
import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.utils.PermissionManager.getGrantedDirForPath
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.io.InputStream
import java.util.*
import java.util.regex.Pattern
object StorageUtils {
private val LOG_TAG = LogUtils.createTag(StorageUtils::class.java)
private val LOG_TAG = LogUtils.createTag<StorageUtils>()
/**
* Volume paths
@ -350,19 +348,6 @@ object StorageUtils {
}
}
fun copyFileToTemp(documentFile: DocumentFileCompat, path: String): String? {
val extension = MimeTypeMap.getFileExtensionFromUrl(Uri.fromFile(File(path)).toString())
try {
val temp = File.createTempFile("aves", ".$extension")
documentFile.copyTo(DocumentFileCompat.fromFile(temp))
temp.deleteOnExit()
return temp.path
} catch (e: IOException) {
Log.e(LOG_TAG, "failed to copy file from path=$path")
}
return null
}
private fun getDocumentFileFromVolumeTree(context: Context, rootTreeUri: Uri, anyPath: String): DocumentFileCompat? {
var documentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeUri) ?: return null

View file

@ -5,7 +5,7 @@ import android.net.Uri
import android.util.Log
object UriUtils {
private val LOG_TAG = LogUtils.createTag(UriUtils::class.java)
private val LOG_TAG = LogUtils.createTag<UriUtils>()
fun Uri.tryParseId(): Long? {
try {

View file

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

View file

@ -4,7 +4,7 @@ import 'package:aves/geo/topojson.dart';
import 'package:country_code/country_code.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:latlong/latlong.dart';
import 'package:latlong2/latlong.dart';
final CountryTopology countryTopology = CountryTopology._private();

View file

@ -1,6 +1,6 @@
import 'package:aves/utils/math_utils.dart';
import 'package:intl/intl.dart';
import 'package:latlong/latlong.dart';
import 'package:latlong2/latlong.dart';
String _decimal2sexagesimal(final double degDecimal) {
List<int> _split(final double value) {

View file

@ -99,6 +99,8 @@
"@filterTagEmptyLabel": {},
"filterTypeAnimatedLabel": "Animated",
"@filterTypeAnimatedLabel": {},
"filterTypeMotionPhotoLabel": "Motion Photo",
"@filterTypeMotionPhotoLabel": {},
"filterTypePanoramaLabel": "Panorama",
"@filterTypePanoramaLabel": {},
"filterTypeSphericalVideoLabel": "360° Video",

View file

@ -50,6 +50,7 @@
"filterLocationEmptyLabel": "장소 없음",
"filterTagEmptyLabel": "태그 없음",
"filterTypeAnimatedLabel": "애니메이션",
"filterTypeMotionPhotoLabel": "모션 포토",
"filterTypePanoramaLabel": "파노라마",
"filterTypeSphericalVideoLabel": "360° 동영상",
"filterTypeGeotiffLabel": "GeoTIFF",

View file

@ -44,6 +44,12 @@ class EntryActions {
EntryAction.setAs,
EntryAction.openMap,
];
static const pageActions = [
EntryAction.rotateCCW,
EntryAction.rotateCW,
EntryAction.flip,
];
}
extension ExtraEntryAction on EntryAction {

View file

@ -4,7 +4,6 @@ import 'package:aves/geo/countries.dart';
import 'package:aves/model/entry_cache.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/metadata.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/geocoding_service.dart';
import 'package:aves/services/service_policy.dart';
@ -17,7 +16,7 @@ import 'package:collection/collection.dart';
import 'package:country_code/country_code.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:latlong/latlong.dart';
import 'package:latlong2/latlong.dart';
import '../ref/mime_types.dart';
@ -43,7 +42,13 @@ class AvesEntry {
final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier();
// TODO TLAD make it dynamic if it depends on OS/lib versions
static const List<String> undecodable = [MimeTypes.crw, MimeTypes.djvu, MimeTypes.psd];
static const List<String> undecodable = [
MimeTypes.art,
MimeTypes.crw,
MimeTypes.djvu,
MimeTypes.psdVnd,
MimeTypes.psdX,
];
AvesEntry({
this.uri,
@ -97,36 +102,6 @@ class AvesEntry {
return copied;
}
AvesEntry getPageEntry(SinglePageInfo pageInfo, {bool eraseDefaultPageId = true}) {
if (pageInfo == null) return this;
// do not provide the page ID for the default page,
// so that we can treat this page like the main entry
// and retrieve cached images for it
final pageId = eraseDefaultPageId && pageInfo.isDefault ? null : pageInfo.pageId;
return AvesEntry(
uri: uri,
path: path,
contentId: contentId,
pageId: pageId,
sourceMimeType: pageInfo.mimeType ?? sourceMimeType,
width: pageInfo.width ?? width,
height: pageInfo.height ?? height,
sourceRotationDegrees: sourceRotationDegrees,
sizeBytes: sizeBytes,
sourceTitle: sourceTitle,
dateModifiedSecs: dateModifiedSecs,
sourceDateTakenMillis: sourceDateTakenMillis,
durationMillis: pageInfo.durationMillis ?? durationMillis,
)
..catalogMetadata = _catalogMetadata?.copyWith(
mimeType: pageInfo.mimeType,
isMultipage: false,
)
..addressDetails = _addressDetails?.copyWith();
}
// from DB or platform source entry
factory AvesEntry.fromMap(Map map) {
return AvesEntry(
@ -251,7 +226,9 @@ class AvesEntry {
bool get is360 => _catalogMetadata?.is360 ?? false;
bool get isMultipage => _catalogMetadata?.isMultipage ?? false;
bool get isMultiPage => _catalogMetadata?.isMultiPage ?? false;
bool get isMotionPhoto => isMultiPage && mimeType == MimeTypes.jpeg;
bool get canEdit => path != null;

View file

@ -12,7 +12,7 @@ class EntryCache {
int oldRotationDegrees,
bool oldIsFlipped,
) async {
// TODO TLAD provide pageId parameter for multipage items, if someday image editing features are added for them
// TODO TLAD provide pageId parameter for multi page items, if someday image editing features are added for them
int pageId;
// evict fullscreen image

View file

@ -9,6 +9,7 @@ class TypeFilter extends CollectionFilter {
static const _animated = 'animated'; // subset of `image/gif` and `image/webp`
static const _geotiff = 'geotiff'; // subset of `image/tiff`
static const _motionPhoto = 'motion_photo'; // subset of `image/jpeg`
static const _panorama = 'panorama'; // subset of images
static const _sphericalVideo = 'spherical_video'; // subset of videos
@ -18,6 +19,7 @@ class TypeFilter extends CollectionFilter {
static final animated = TypeFilter._private(_animated);
static final geotiff = TypeFilter._private(_geotiff);
static final motionPhoto = TypeFilter._private(_motionPhoto);
static final panorama = TypeFilter._private(_panorama);
static final sphericalVideo = TypeFilter._private(_sphericalVideo);
@ -27,13 +29,17 @@ class TypeFilter extends CollectionFilter {
_test = (entry) => entry.isAnimated;
_icon = AIcons.animated;
break;
case _motionPhoto:
_test = (entry) => entry.isMotionPhoto;
_icon = AIcons.motionPhoto;
break;
case _panorama:
_test = (entry) => entry.isImage && entry.is360;
_icon = AIcons.threesixty;
_icon = AIcons.threeSixty;
break;
case _sphericalVideo:
_test = (entry) => entry.isVideo && entry.is360;
_icon = AIcons.threesixty;
_icon = AIcons.threeSixty;
break;
case _geotiff:
_test = (entry) => entry.isGeotiff;
@ -64,6 +70,8 @@ class TypeFilter extends CollectionFilter {
switch (itemType) {
case _animated:
return context.l10n.filterTypeAnimatedLabel;
case _motionPhoto:
return context.l10n.filterTypeMotionPhotoLabel;
case _panorama:
return context.l10n.filterTypePanoramaLabel;
case _sphericalVideo:

View file

@ -29,7 +29,7 @@ class DateMetadata {
class CatalogMetadata {
final int contentId, dateMillis;
final bool isAnimated, isGeotiff, is360, isMultipage;
final bool isAnimated, isGeotiff, is360, isMultiPage;
bool isFlipped;
int rotationDegrees;
final String mimeType, xmpSubjects, xmpTitleDescription;
@ -41,7 +41,7 @@ class CatalogMetadata {
static const _isFlippedMask = 1 << 1;
static const _isGeotiffMask = 1 << 2;
static const _is360Mask = 1 << 3;
static const _isMultipageMask = 1 << 4;
static const _isMultiPageMask = 1 << 4;
CatalogMetadata({
this.contentId,
@ -51,7 +51,7 @@ class CatalogMetadata {
this.isFlipped = false,
this.isGeotiff = false,
this.is360 = false,
this.isMultipage = false,
this.isMultiPage = false,
this.rotationDegrees,
this.xmpSubjects,
this.xmpTitleDescription,
@ -70,7 +70,8 @@ class CatalogMetadata {
CatalogMetadata copyWith({
int contentId,
String mimeType,
bool isMultipage,
bool isMultiPage,
int rotationDegrees,
}) {
return CatalogMetadata(
contentId: contentId ?? this.contentId,
@ -80,8 +81,8 @@ class CatalogMetadata {
isFlipped: isFlipped,
isGeotiff: isGeotiff,
is360: is360,
isMultipage: isMultipage ?? this.isMultipage,
rotationDegrees: rotationDegrees,
isMultiPage: isMultiPage ?? this.isMultiPage,
rotationDegrees: rotationDegrees ?? this.rotationDegrees,
xmpSubjects: xmpSubjects,
xmpTitleDescription: xmpTitleDescription,
latitude: latitude,
@ -99,7 +100,7 @@ class CatalogMetadata {
isFlipped: flags & _isFlippedMask != 0,
isGeotiff: flags & _isGeotiffMask != 0,
is360: flags & _is360Mask != 0,
isMultipage: flags & _isMultipageMask != 0,
isMultiPage: flags & _isMultiPageMask != 0,
// `rotationDegrees` should default to `sourceRotationDegrees`, not 0
rotationDegrees: map['rotationDegrees'],
xmpSubjects: map['xmpSubjects'] ?? '',
@ -113,7 +114,7 @@ class CatalogMetadata {
'contentId': contentId,
'mimeType': mimeType,
'dateMillis': dateMillis,
'flags': (isAnimated ? _isAnimatedMask : 0) | (isFlipped ? _isFlippedMask : 0) | (isGeotiff ? _isGeotiffMask : 0) | (is360 ? _is360Mask : 0) | (isMultipage ? _isMultipageMask : 0),
'flags': (isAnimated ? _isAnimatedMask : 0) | (isFlipped ? _isFlippedMask : 0) | (isGeotiff ? _isGeotiffMask : 0) | (is360 ? _is360Mask : 0) | (isMultiPage ? _isMultiPageMask : 0),
'rotationDegrees': rotationDegrees,
'xmpSubjects': xmpSubjects,
'xmpTitleDescription': xmpTitleDescription,
@ -122,7 +123,7 @@ class CatalogMetadata {
};
@override
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, isMultipage=$isMultipage, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}';
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, isMultiPage=$isMultiPage, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}';
}
class OverlayMetadata {

View file

@ -1,69 +1,144 @@
import 'package:aves/model/entry.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart';
class MultiPageInfo {
final String uri;
final List<SinglePageInfo> pages;
final AvesEntry mainEntry;
final List<SinglePageInfo> _pages;
final Map<SinglePageInfo, AvesEntry> _pageEntries = {};
int get pageCount => pages.length;
int get pageCount => _pages.length;
MultiPageInfo({
@required this.uri,
this.pages,
}) {
if (pages.isNotEmpty) {
pages.sort();
@required this.mainEntry,
List<SinglePageInfo> pages,
}) : _pages = pages {
if (_pages.isNotEmpty) {
_pages.sort();
// make sure there is a page marked as default
if (defaultPage == null) {
final firstPage = pages.removeAt(0);
pages.insert(0, firstPage.copyWith(isDefault: true));
final firstPage = _pages.removeAt(0);
_pages.insert(0, firstPage.copyWith(isDefault: true));
}
}
}
factory MultiPageInfo.fromPageMaps(String uri, List<Map> pageMaps) {
factory MultiPageInfo.fromPageMaps(AvesEntry mainEntry, List<Map> pageMaps) {
return MultiPageInfo(
uri: uri,
mainEntry: mainEntry,
pages: pageMaps.map((page) => SinglePageInfo.fromMap(page)).toList(),
);
}
SinglePageInfo get defaultPage => pages.firstWhere((page) => page.isDefault, orElse: () => null);
SinglePageInfo get defaultPage => _pages.firstWhere((page) => page.isDefault, orElse: () => null);
SinglePageInfo getByIndex(int index) => pages.firstWhere((page) => page.index == index, orElse: () => null);
SinglePageInfo getById(int pageId) => _pages.firstWhere((page) => page.pageId == pageId, orElse: () => null);
SinglePageInfo getById(int pageId) => pages.firstWhere((page) => page.pageId == pageId, orElse: () => null);
SinglePageInfo getByIndex(int pageIndex) => _pages.firstWhere((page) => page.index == pageIndex, orElse: () => null);
AvesEntry getPageEntryByIndex(int pageIndex) => _getPageEntry(getByIndex(pageIndex));
AvesEntry _getPageEntry(SinglePageInfo pageInfo) {
if (pageInfo != null) {
return _pageEntries.putIfAbsent(pageInfo, () => _createPageEntry(pageInfo));
} else {
return mainEntry;
}
}
Set<AvesEntry> get videoPageEntries => _pages.where((page) => page.isVideo).map(_getPageEntry).toSet();
List<AvesEntry> get exportEntries => _pages.map((pageInfo) => _createPageEntry(pageInfo, eraseDefaultPageId: false)).toList();
Future<void> extractMotionPhotoVideo() async {
final videoPage = _pages.firstWhere((page) => page.isVideo, orElse: () => null);
if (videoPage != null && videoPage.uri == null) {
final fields = await embeddedDataService.extractMotionPhotoVideo(mainEntry);
if (fields != null) {
final pageIndex = _pages.indexOf(videoPage);
_pages.removeAt(pageIndex);
_pages.insert(
pageIndex,
videoPage.copyWith(
uri: fields['uri'] as String,
// the initial fake page may contain inaccurate values for the following fields
// so we override them with values from the extracted standalone video
rotationDegrees: fields['sourceRotationDegrees'] as int,
durationMillis: fields['durationMillis'] as int,
));
_pageEntries.remove(videoPage);
}
}
}
AvesEntry _createPageEntry(SinglePageInfo pageInfo, {bool eraseDefaultPageId = true}) {
// do not provide the page ID for the default page,
// so that we can treat this page like the main entry
// and retrieve cached images for it
final pageId = eraseDefaultPageId && pageInfo.isDefault ? null : pageInfo.pageId;
return AvesEntry(
uri: pageInfo.uri ?? mainEntry.uri,
path: mainEntry.path,
contentId: mainEntry.contentId,
pageId: pageId,
sourceMimeType: pageInfo.mimeType ?? mainEntry.sourceMimeType,
width: pageInfo.width ?? mainEntry.width,
height: pageInfo.height ?? mainEntry.height,
sourceRotationDegrees: pageInfo.rotationDegrees ?? mainEntry.sourceRotationDegrees,
sizeBytes: mainEntry.sizeBytes,
sourceTitle: mainEntry.sourceTitle,
dateModifiedSecs: mainEntry.dateModifiedSecs,
sourceDateTakenMillis: mainEntry.sourceDateTakenMillis,
durationMillis: pageInfo.durationMillis ?? mainEntry.durationMillis,
)
..catalogMetadata = mainEntry.catalogMetadata?.copyWith(
mimeType: pageInfo.mimeType,
isMultiPage: false,
rotationDegrees: pageInfo.rotationDegrees,
)
..addressDetails = mainEntry.addressDetails?.copyWith();
}
@override
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, pages=$pages}';
String toString() => '$runtimeType#${shortHash(this)}{mainEntry=$mainEntry, pages=$_pages}';
}
class SinglePageInfo implements Comparable<SinglePageInfo> {
final int index, pageId;
final String mimeType;
final bool isDefault;
final int width, height, durationMillis;
final String uri, mimeType;
final int width, height, rotationDegrees, durationMillis;
const SinglePageInfo({
this.index,
this.pageId,
this.mimeType,
this.isDefault,
this.uri,
this.mimeType,
this.width,
this.height,
this.rotationDegrees,
this.durationMillis,
});
SinglePageInfo copyWith({
bool isDefault,
String uri,
int rotationDegrees,
int durationMillis,
}) {
return SinglePageInfo(
index: index,
pageId: pageId,
mimeType: mimeType,
isDefault: isDefault ?? this.isDefault,
uri: uri ?? this.uri,
mimeType: mimeType,
width: width,
height: height,
durationMillis: durationMillis,
rotationDegrees: rotationDegrees ?? this.rotationDegrees,
durationMillis: durationMillis ?? this.durationMillis,
);
}
@ -72,16 +147,19 @@ class SinglePageInfo implements Comparable<SinglePageInfo> {
return SinglePageInfo(
index: index,
pageId: index,
mimeType: map['mimeType'] as String,
isDefault: map['isDefault'] as bool ?? false,
mimeType: map['mimeType'] as String,
width: map['width'] as int ?? 0,
height: map['height'] as int ?? 0,
rotationDegrees: map['rotationDegrees'] as int,
durationMillis: map['durationMillis'] as int,
);
}
bool get isVideo => MimeTypes.isVideo(mimeType);
@override
String toString() => '$runtimeType#${shortHash(this)}{index=$index, pageId=$pageId, mimeType=$mimeType, isDefault=$isDefault, width=$width, height=$height, durationMillis=$durationMillis}';
String toString() => '$runtimeType#${shortHash(this)}{index=$index, pageId=$pageId, isDefault=$isDefault, uri=$uri, mimeType=$mimeType, width=$width, height=$height, rotationDegrees=$rotationDegrees, durationMillis=$durationMillis}';
@override
int compareTo(SinglePageInfo other) => index.compareTo(other.index);

View file

@ -1,7 +1,7 @@
import 'package:aves/geo/format.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart';
import 'package:latlong/latlong.dart';
import 'package:latlong2/latlong.dart';
import 'enums.dart';

View file

@ -10,7 +10,7 @@ import 'package:aves/utils/file_utils.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:aves/utils/string_utils.dart';
import 'package:aves/utils/time_utils.dart';
import 'package:aves/widgets/common/video/fijkplayer.dart';
import 'package:aves/widgets/viewer/video/fijkplayer.dart';
import 'package:fijkplayer/fijkplayer.dart';
import 'package:flutter/foundation.dart';
@ -33,8 +33,12 @@ class VideoMetadataFormatter {
static Future<Map> getVideoMetadata(AvesEntry entry) async {
final player = FijkPlayer();
await player.setDataSourceUntilPrepared(entry.uri);
final info = await player.getInfo();
final info = await player.setDataSourceUntilPrepared(entry.uri).then((v) {
return player.getInfo();
}).catchError((error) {
debugPrint('failed to get video metadata for entry=$entry, error=$error');
return {};
});
await player.release();
return info;
}
@ -111,7 +115,9 @@ class VideoMetadataFormatter {
save('Channel Layout', _formatChannelLayout(value));
break;
case Keys.codecName:
save('Format', _formatCodecName(value));
if (value != 'none') {
save('Format', _formatCodecName(value));
}
break;
case Keys.codecPixelFormat:
if (streamType == StreamTypes.video) {
@ -292,6 +298,7 @@ class VideoMetadataFormatter {
}
class StreamTypes {
static const attachment = 'attachment';
static const audio = 'audio';
static const metadata = 'metadata';
static const subtitle = 'subtitle';

View file

@ -12,13 +12,15 @@ class MimeTypes {
static const tiff = 'image/tiff';
static const webp = 'image/webp';
static const psd = 'image/vnd.adobe.photoshop';
static const art = 'image/x-jg';
static const djvu = 'image/vnd.djvu';
static const psdVnd = 'image/vnd.adobe.photoshop';
static const psdX = 'image/x-photoshop';
static const arw = 'image/x-sony-arw';
static const cr2 = 'image/x-canon-cr2';
static const crw = 'image/x-canon-crw';
static const dcr = 'image/x-kodak-dcr';
static const djvu = 'image/vnd.djvu';
static const dng = 'image/x-adobe-dng';
static const erf = 'image/x-epson-erf';
static const k25 = 'image/x-kodak-k25';

View file

@ -16,6 +16,7 @@ class XMP {
'GettyImagesGIFT': 'Getty Images',
'GIMP': 'GIMP',
'GCamera': 'Google Camera',
'GCreations': 'Google Creations',
'GFocus': 'Google Focus',
'GPano': 'Google Panorama',
'illustrator': 'Illustrator',

View file

@ -0,0 +1,82 @@
import 'dart:typed_data';
import 'package:aves/model/entry.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
abstract class EmbeddedDataService {
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry);
Future<Map> extractMotionPhotoVideo(AvesEntry entry);
Future<Map> extractVideoEmbeddedPicture(AvesEntry entry);
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType);
}
class PlatformEmbeddedDataService implements EmbeddedDataService {
static const platform = MethodChannel('deckers.thibault/aves/embedded');
@override
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry) async {
try {
final result = await platform.invokeMethod('getExifThumbnails', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
});
return (result as List).cast<Uint8List>();
} on PlatformException catch (e) {
debugPrint('getExifThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return [];
}
@override
Future<Map> extractMotionPhotoVideo(AvesEntry entry) async {
try {
final result = await platform.invokeMethod('extractMotionPhotoVideo', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
'displayName': '${entry.bestTitle} • Video',
});
return result;
} on PlatformException catch (e) {
debugPrint('extractMotionPhotoVideo failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return null;
}
@override
Future<Map> extractVideoEmbeddedPicture(AvesEntry entry) async {
try {
final result = await platform.invokeMethod('extractVideoEmbeddedPicture', <String, dynamic>{
'uri': entry.uri,
'displayName': '${entry.bestTitle} • Cover',
});
return result;
} on PlatformException catch (e) {
debugPrint('extractVideoEmbeddedPicture failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return null;
}
@override
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async {
try {
final result = await platform.invokeMethod('extractXmpDataProp', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
'displayName': '${entry.bestTitle}$propPath',
'propPath': propPath,
'propMimeType': propMimeType,
});
return result;
} on PlatformException catch (e) {
debugPrint('extractXmpDataProp failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return null;
}
}

View file

@ -3,7 +3,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:latlong/latlong.dart';
import 'package:latlong2/latlong.dart';
class GeocodingService {
static const platform = MethodChannel('deckers.thibault/aves/geocoding');

View file

@ -75,7 +75,7 @@ abstract class ImageFileService {
Stream<ExportOpEvent> export(
Iterable<AvesEntry> entries, {
String mimeType = MimeTypes.jpeg,
@required String mimeType,
@required String destinationAlbum,
});
@ -103,6 +103,7 @@ class PlatformImageFileService implements ImageFileService {
'rotationDegrees': entry.rotationDegrees,
'isFlipped': entry.isFlipped,
'dateModifiedSecs': entry.dateModifiedSecs,
'sizeBytes': entry.sizeBytes,
};
}
@ -316,7 +317,7 @@ class PlatformImageFileService implements ImageFileService {
@override
Stream<ExportOpEvent> export(
Iterable<AvesEntry> entries, {
String mimeType = MimeTypes.jpeg,
@required String mimeType,
@required String destinationAlbum,
}) {
try {

View file

@ -1,5 +1,3 @@
import 'dart:typed_data';
import 'package:aves/model/entry.dart';
import 'package:aves/model/metadata.dart';
import 'package:aves/model/multipage.dart';
@ -21,12 +19,6 @@ abstract class MetadataService {
Future<PanoramaInfo> getPanoramaInfo(AvesEntry entry);
Future<String> getContentResolverProp(AvesEntry entry, String prop);
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry);
Future<Map> extractVideoEmbeddedPicture(String uri);
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType);
}
class PlatformMetadataService implements MetadataService {
@ -111,9 +103,16 @@ class PlatformMetadataService implements MetadataService {
final result = await platform.invokeMethod('getMultiPageInfo', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
});
final pageMaps = (result as List).cast<Map>();
return MultiPageInfo.fromPageMaps(entry.uri, pageMaps);
if (entry.isMotionPhoto && pageMaps.isNotEmpty) {
final imagePage = pageMaps[0];
imagePage['width'] = entry.width;
imagePage['height'] = entry.height;
imagePage['rotationDegrees'] = entry.rotationDegrees;
}
return MultiPageInfo.fromPageMaps(entry, pageMaps);
} on PlatformException catch (e) {
debugPrint('getMultiPageInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
@ -151,49 +150,4 @@ class PlatformMetadataService implements MetadataService {
}
return null;
}
@override
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry) async {
try {
final result = await platform.invokeMethod('getExifThumbnails', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
});
return (result as List).cast<Uint8List>();
} on PlatformException catch (e) {
debugPrint('getExifThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return [];
}
@override
Future<Map> extractVideoEmbeddedPicture(String uri) async {
try {
final result = await platform.invokeMethod('extractVideoEmbeddedPicture', <String, dynamic>{
'uri': uri,
});
return result;
} on PlatformException catch (e) {
debugPrint('extractVideoEmbeddedPicture failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return null;
}
@override
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async {
try {
final result = await platform.invokeMethod('extractXmpDataProp', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
'propPath': propPath,
'propMimeType': propMimeType,
});
return result;
} on PlatformException catch (e) {
debugPrint('extractXmpDataProp failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return null;
}
}

View file

@ -1,5 +1,6 @@
import 'package:aves/model/availability.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/services/embedded_data_service.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/media_store_service.dart';
import 'package:aves/services/metadata_service.dart';
@ -14,6 +15,7 @@ final pContext = getIt<p.Context>();
final availability = getIt<AvesAvailability>();
final metadataDb = getIt<MetadataDb>();
final embeddedDataService = getIt<EmbeddedDataService>();
final imageFileService = getIt<ImageFileService>();
final mediaStoreService = getIt<MediaStoreService>();
final metadataService = getIt<MetadataService>();
@ -25,6 +27,7 @@ void initPlatformServices() {
getIt.registerLazySingleton<AvesAvailability>(() => LiveAvesAvailability());
getIt.registerLazySingleton<MetadataDb>(() => SqfliteMetadataDb());
getIt.registerLazySingleton<EmbeddedDataService>(() => PlatformEmbeddedDataService());
getIt.registerLazySingleton<ImageFileService>(() => PlatformImageFileService());
getIt.registerLazySingleton<MediaStoreService>(() => PlatformMediaStoreService());
getIt.registerLazySingleton<MetadataService>(() => PlatformMetadataService());

View file

@ -4,6 +4,7 @@ class Durations {
// Flutter animations (with margin)
static const popupMenuAnimation = Duration(milliseconds: 300 + 10); // ref `_kMenuDuration` used in `_PopupMenuRoute`
static const dialogTransitionAnimation = Duration(milliseconds: 150 + 10); // ref `transitionDuration` used in `DialogRoute`
static const drawerTransitionAnimation = Duration(milliseconds: 246 + 10); // ref `_kBaseSettleDuration` used in `DrawerControllerState`
static const toggleableTransitionAnimation = Duration(milliseconds: 200 + 10); // ref `_kToggleDuration` used in `ToggleableStateMixin`
// common animations
@ -12,12 +13,15 @@ class Durations {
static const sweepingAnimation = Duration(milliseconds: 650);
static const staggeredAnimation = Duration(milliseconds: 375);
static const staggeredAnimationPageTarget = Duration(milliseconds: 900);
static const staggeredAnimationPageTarget = Duration(milliseconds: 800);
static const dialogFieldReachAnimation = Duration(milliseconds: 300);
static const appBarTitleAnimation = Duration(milliseconds: 300);
static const appBarActionChangeAnimation = Duration(milliseconds: 200);
// drawer
static const newsBadgeAnimation = Duration(milliseconds: 200);
// filter grids animations
static const chipDecorationAnimation = Duration(milliseconds: 200);
static const highlightScrollAnimationMinMillis = 400;
@ -60,7 +64,7 @@ class Durations {
static const doubleBackTimerDelay = Duration(milliseconds: 1000);
static const softKeyboardDisplayDelay = Duration(milliseconds: 300);
static const searchDebounceDelay = Duration(milliseconds: 250);
static const contentChangeDebounceDelay = Duration(milliseconds: 500);
static const contentChangeDebounceDelay = Duration(milliseconds: 1000);
// app life
static const lastVersionCheckInterval = Duration(days: 7);

View file

@ -8,6 +8,7 @@ class AIcons {
static const IconData vector = Icons.code_outlined;
static const IconData android = Icons.android;
static const IconData broken = Icons.broken_image_outlined;
static const IconData checked = Icons.done_outlined;
static const IconData date = Icons.calendar_today_outlined;
static const IconData disc = Icons.fiber_manual_record;
@ -71,9 +72,10 @@ class AIcons {
// thumbnail overlay
static const IconData animated = Icons.slideshow;
static const IconData geo = Icons.language_outlined;
static const IconData multipage = Icons.burst_mode_outlined;
static const IconData motionPhoto = Icons.motion_photos_on_outlined;
static const IconData multiPage = Icons.burst_mode_outlined;
static const IconData play = Icons.play_circle_outline;
static const IconData threesixty = Icons.threesixty_outlined;
static const IconData threeSixty = Icons.threesixty_outlined;
static const IconData selected = Icons.check_circle_outline;
static const IconData unselected = Icons.radio_button_unchecked;
}

View file

@ -10,7 +10,6 @@ class Themes {
brightness: Brightness.dark,
accentColor: _accentColor,
scaffoldBackgroundColor: Colors.grey[900],
buttonColor: _accentColor,
dialogBackgroundColor: Colors.grey[850],
toggleableActiveColor: _accentColor,
tooltipTheme: TooltipThemeData(
@ -25,6 +24,12 @@ class Themes {
),
),
),
colorScheme: ColorScheme.dark(
primary: _accentColor,
secondary: _accentColor,
onPrimary: Colors.white,
onSecondary: Colors.white,
),
snackBarTheme: SnackBarThemeData(
backgroundColor: Colors.grey[800],
contentTextStyle: TextStyle(
@ -32,16 +37,6 @@ class Themes {
),
behavior: SnackBarBehavior.floating,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
primary: _accentColor,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
primary: _accentColor,
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
primary: Colors.white,

View file

@ -2,7 +2,7 @@ import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/painting.dart';
import 'package:latlong/latlong.dart';
import 'package:latlong2/latlong.dart';
class Constants {
// as of Flutter v1.22.3, overflowing `Text` miscalculates height and some text (e.g. 'Å') is clipped
@ -42,8 +42,8 @@ class Constants {
Dependency(
name: 'Android-TiffBitmapFactory',
license: 'MIT',
licenseUrl: 'https://github.com/Beyka/Android-TiffBitmapFactory/blob/master/license.txt',
sourceUrl: 'https://github.com/Beyka/Android-TiffBitmapFactory',
licenseUrl: 'https://github.com/deckerst/Android-TiffBitmapFactory/blob/master/license.txt',
sourceUrl: 'https://github.com/deckerst/Android-TiffBitmapFactory',
),
Dependency(
name: 'CWAC-Document',

View file

@ -1,8 +1,13 @@
import 'dart:io';
import 'package:aves/model/entry.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/mime_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class ErrorThumbnail extends StatelessWidget {
class ErrorThumbnail extends StatefulWidget {
final AvesEntry entry;
final double extent;
final String tooltip;
@ -13,23 +18,53 @@ class ErrorThumbnail extends StatelessWidget {
@required this.tooltip,
});
@override
_ErrorThumbnailState createState() => _ErrorThumbnailState();
}
class _ErrorThumbnailState extends State<ErrorThumbnail> {
Future<bool> _exists;
AvesEntry get entry => widget.entry;
double get extent => widget.extent;
@override
void initState() {
super.initState();
_exists = entry.path != null ? File(entry.path).exists() : SynchronousFuture(true);
}
@override
Widget build(BuildContext context) {
return Container(
alignment: Alignment.center,
color: Colors.black,
child: Tooltip(
message: tooltip,
preferBelow: false,
child: Text(
MimeUtils.displayType(entry.mimeType),
style: TextStyle(
color: Colors.blueGrey,
fontSize: extent / 5,
),
textAlign: TextAlign.center,
),
),
);
const color = Colors.blueGrey;
return FutureBuilder<bool>(
future: _exists,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) return SizedBox();
final exists = snapshot.data;
return Container(
alignment: Alignment.center,
color: Colors.black,
child: Tooltip(
message: exists ? widget.tooltip : context.l10n.viewerErrorDoesNotExist,
preferBelow: false,
child: exists
? Text(
MimeUtils.displayType(entry.mimeType),
style: TextStyle(
color: color,
fontSize: extent / 5,
),
textAlign: TextAlign.center,
)
: Icon(
AIcons.broken,
size: extent / 2,
color: color,
),
),
);
});
}
}

View file

@ -34,7 +34,7 @@ class ThumbnailEntryOverlay extends StatelessWidget {
AnimatedImageIcon()
else ...[
if (entry.isRaw && context.select<ThumbnailThemeData, bool>((t) => t.showRaw)) RawIcon(),
if (entry.isMultipage) MultipageIcon(),
if (entry.isMultiPage) MultiPageIcon(entry: entry),
if (entry.isGeotiff) GeotiffIcon(),
if (entry.is360) SphericalImageIcon(),
]

View file

@ -6,10 +6,12 @@ import 'package:provider/provider.dart';
class ThumbnailTheme extends StatelessWidget {
final double extent;
final bool showLocation;
final Widget child;
const ThumbnailTheme({
@required this.extent,
this.showLocation,
@required this.child,
});
@ -22,7 +24,7 @@ class ThumbnailTheme extends StatelessWidget {
return ThumbnailThemeData(
iconSize: iconSize,
fontSize: fontSize,
showLocation: settings.showThumbnailLocation,
showLocation: showLocation ?? settings.showThumbnailLocation,
showRaw: settings.showThumbnailRaw,
showVideoDuration: settings.showThumbnailVideoDuration,
);

View file

@ -23,7 +23,7 @@ class VideoIcon extends StatelessWidget {
final thumbnailTheme = context.watch<ThumbnailThemeData>();
final showDuration = thumbnailTheme.showVideoDuration;
Widget child = OverlayIcon(
icon: entry.is360 ? AIcons.threesixty : AIcons.play,
icon: entry.is360 ? AIcons.threeSixty : AIcons.play,
size: thumbnailTheme.iconSize,
text: showDuration ? entry.durationText : null,
iconScale: entry.is360 && showDuration ? .9 : 1,
@ -72,7 +72,7 @@ class SphericalImageIcon extends StatelessWidget {
@override
Widget build(BuildContext context) {
return OverlayIcon(
icon: AIcons.threesixty,
icon: AIcons.threeSixty,
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
);
}
@ -102,13 +102,18 @@ class RawIcon extends StatelessWidget {
}
}
class MultipageIcon extends StatelessWidget {
const MultipageIcon({Key key}) : super(key: key);
class MultiPageIcon extends StatelessWidget {
final AvesEntry entry;
const MultiPageIcon({
Key key,
this.entry,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return OverlayIcon(
icon: AIcons.multipage,
icon: entry.isMotionPhoto ? AIcons.motionPhoto : AIcons.multiPage,
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
iconScale: .8,
);

View file

@ -40,12 +40,12 @@ class _DebugCacheSectionState extends State<DebugCacheSection> with AutomaticKee
Row(
children: [
Expanded(
child: Text('SVG cache: ${PictureProvider.cacheCount} items'),
child: Text('SVG cache: ${PictureProvider.cache.count} items'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () {
PictureProvider.clearCache();
PictureProvider.cache.clear();
setState(() {});
},

View file

@ -8,10 +8,10 @@ import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/tag.dart';
import 'package:aves/services/services.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/about/about_page.dart';
import 'package:aves/widgets/about/news_badge.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:aves/widgets/common/identity/aves_logo.dart';
@ -58,9 +58,6 @@ class _AppDrawerState extends State<AppDrawer> {
albumListTile,
countryListTile,
tagListTile,
Divider(),
settingsTile,
aboutTile,
if (kDebugMode) ...[
Divider(),
debugTile,
@ -92,8 +89,19 @@ class _AppDrawerState extends State<AppDrawer> {
}
Widget _buildHeader(BuildContext context) {
Future<void> goTo(String routeName, WidgetBuilder pageBuilder) async {
Navigator.pop(context);
await Future.delayed(Durations.drawerTransitionAnimation);
await Navigator.push(
context,
MaterialPageRoute(
settings: RouteSettings(name: routeName),
builder: pageBuilder,
));
}
return Container(
padding: EdgeInsets.all(16),
padding: EdgeInsets.only(left: 16, top: 16, right: 16, bottom: 8),
color: Theme.of(context).accentColor,
child: SafeArea(
bottom: false,
@ -119,6 +127,61 @@ class _AppDrawerState extends State<AppDrawer> {
],
),
),
SizedBox(height: 8),
OutlinedButtonTheme(
data: OutlinedButtonThemeData(
style: ButtonStyle(
foregroundColor: MaterialStateProperty.all<Color>(Colors.white),
overlayColor: MaterialStateProperty.all<Color>(Colors.white24),
),
),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
onPressed: () => goTo(AboutPage.routeName, (_) => AboutPage()),
icon: Icon(AIcons.info),
label: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.aboutPageTitle),
FutureBuilder<bool>(
future: _newVersionLoader,
builder: (context, snapshot) {
final newVersion = snapshot.data == true;
final badgeSize = 8.0 * MediaQuery.textScaleFactorOf(context);
return AnimatedOpacity(
duration: Durations.newsBadgeAnimation,
opacity: newVersion ? 1 : 0,
child: Padding(
padding: EdgeInsetsDirectional.only(start: 2),
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: Colors.white70),
borderRadius: BorderRadius.circular(badgeSize),
),
child: Icon(
Icons.circle,
size: badgeSize,
color: Colors.red,
),
),
),
);
},
),
],
),
),
OutlinedButton.icon(
onPressed: () => goTo(SettingsPage.routeName, (_) => SettingsPage()),
icon: Icon(AIcons.settings),
label: Text(context.l10n.settingsPageTitle),
),
],
),
)
],
),
),
@ -198,29 +261,6 @@ class _AppDrawerState extends State<AppDrawer> {
pageBuilder: (_) => TagListPage(),
);
Widget get settingsTile => NavTile(
icon: AIcons.settings,
title: context.l10n.settingsPageTitle,
topLevel: false,
routeName: SettingsPage.routeName,
pageBuilder: (_) => SettingsPage(),
);
Widget get aboutTile => FutureBuilder<bool>(
future: _newVersionLoader,
builder: (context, snapshot) {
final newVersion = snapshot.data == true;
return NavTile(
icon: AIcons.info,
title: context.l10n.aboutPageTitle,
trailing: newVersion ? AboutNewsBadge() : null,
topLevel: false,
routeName: AboutPage.routeName,
pageBuilder: (_) => AboutPage(),
);
},
);
Widget get debugTile => NavTile(
icon: AIcons.debug,
title: 'Debug',

View file

@ -1,5 +1,3 @@
import 'dart:ui';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

View file

@ -1,5 +1,4 @@
import 'dart:math';
import 'dart:ui';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart';

View file

@ -34,6 +34,7 @@ class CollectionSearchDelegate {
MimeFilter.image,
MimeFilter.video,
TypeFilter.animated,
TypeFilter.motionPhoto,
TypeFilter.panorama,
TypeFilter.sphericalVideo,
TypeFilter.geotiff,

View file

@ -5,6 +5,7 @@ import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/services.dart';
@ -168,13 +169,13 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
if (!await checkFreeSpaceForMove(context, {entry}, destinationAlbum, MoveType.export)) return;
final selection = <AvesEntry>{};
if (entry.isMultipage) {
if (entry.isMultiPage) {
final multiPageInfo = await metadataService.getMultiPageInfo(entry);
if (entry.isMotionPhoto) {
await multiPageInfo.extractMotionPhotoVideo();
}
if (multiPageInfo.pageCount > 1) {
for (final page in multiPageInfo.pages) {
final pageEntry = entry.getPageEntry(page, eraseDefaultPageId: false);
selection.add(pageEntry);
}
selection.addAll(multiPageInfo.exportEntries);
}
} else {
selection.add(entry);
@ -183,7 +184,11 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
final selectionCount = selection.length;
showOpReport<ExportOpEvent>(
context: context,
opStream: imageFileService.export(selection, destinationAlbum: destinationAlbum),
opStream: imageFileService.export(
selection,
mimeType: MimeTypes.jpeg,
destinationAlbum: destinationAlbum,
),
itemCount: selectionCount,
onDone: (processed) {
final movedOps = processed.where((e) => e.success);

View file

@ -3,27 +3,21 @@ import 'package:aves/model/multipage.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/common/magnifier/pan/gesture_detector_scope.dart';
import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart';
import 'package:aves/widgets/common/video/controller.dart';
import 'package:aves/widgets/viewer/multipage.dart';
import 'package:aves/widgets/viewer/multipage/conductor.dart';
import 'package:aves/widgets/viewer/visual/entry_page_view.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class MultiEntryScroller extends StatefulWidget {
final CollectionLens collection;
final PageController pageController;
final ValueChanged<int> onPageChanged;
final List<Tuple2<String, AvesVideoController>> videoControllers;
final List<Tuple2<String, MultiPageController>> multiPageControllers;
final void Function(String uri) onViewDisposed;
const MultiEntryScroller({
this.collection,
this.pageController,
this.onPageChanged,
this.videoControllers,
this.multiPageControllers,
this.onViewDisposed,
});
@ -50,17 +44,17 @@ class _MultiEntryScrollerState extends State<MultiEntryScroller> with AutomaticK
final entry = entries[index];
Widget child;
if (entry.isMultipage) {
final multiPageController = _getMultiPageController(entry);
if (entry.isMultiPage) {
final multiPageController = context.read<MultiPageConductor>().getController(entry);
if (multiPageController != null) {
child = FutureBuilder<MultiPageInfo>(
future: multiPageController.info,
child = StreamBuilder<MultiPageInfo>(
stream: multiPageController.infoStream,
builder: (context, snapshot) {
final multiPageInfo = snapshot.data;
final multiPageInfo = multiPageController.info;
return ValueListenableBuilder<int>(
valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) {
return _buildViewer(entry, page: multiPageInfo?.getByIndex(page));
return _buildViewer(entry, pageEntry: multiPageInfo?.getPageEntryByIndex(page));
},
);
},
@ -78,39 +72,30 @@ class _MultiEntryScrollerState extends State<MultiEntryScroller> with AutomaticK
);
}
Widget _buildViewer(AvesEntry entry, {SinglePageInfo page}) {
Widget _buildViewer(AvesEntry mainEntry, {AvesEntry pageEntry}) {
return Selector<MediaQueryData, Size>(
selector: (c, mq) => mq.size,
builder: (c, mqSize, child) {
return EntryPageView(
key: Key('imageview'),
mainEntry: entry,
page: page,
mainEntry: mainEntry,
pageEntry: pageEntry ?? mainEntry,
viewportSize: mqSize,
videoControllers: widget.videoControllers,
onDisposed: () => widget.onViewDisposed?.call(entry.uri),
onDisposed: () => widget.onViewDisposed?.call(mainEntry.uri),
);
},
);
}
MultiPageController _getMultiPageController(AvesEntry entry) {
return widget.multiPageControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
}
@override
bool get wantKeepAlive => true;
}
class SingleEntryScroller extends StatefulWidget {
final AvesEntry entry;
final List<Tuple2<String, AvesVideoController>> videoControllers;
final List<Tuple2<String, MultiPageController>> multiPageControllers;
const SingleEntryScroller({
this.entry,
this.videoControllers,
this.multiPageControllers,
});
@override
@ -118,24 +103,24 @@ class SingleEntryScroller extends StatefulWidget {
}
class _SingleEntryScrollerState extends State<SingleEntryScroller> with AutomaticKeepAliveClientMixin {
AvesEntry get entry => widget.entry;
AvesEntry get mainEntry => widget.entry;
@override
Widget build(BuildContext context) {
super.build(context);
Widget child;
if (entry.isMultipage) {
final multiPageController = _getMultiPageController(entry);
if (mainEntry.isMultiPage) {
final multiPageController = context.read<MultiPageConductor>().getController(mainEntry);
if (multiPageController != null) {
child = FutureBuilder<MultiPageInfo>(
future: multiPageController.info,
child = StreamBuilder<MultiPageInfo>(
stream: multiPageController.infoStream,
builder: (context, snapshot) {
final multiPageInfo = snapshot.data;
final multiPageInfo = multiPageController.info;
return ValueListenableBuilder<int>(
valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) {
return _buildViewer(page: multiPageInfo?.getByIndex(page));
return _buildViewer(pageEntry: multiPageInfo?.getPageEntryByIndex(page));
},
);
},
@ -150,24 +135,19 @@ class _SingleEntryScrollerState extends State<SingleEntryScroller> with Automati
);
}
Widget _buildViewer({SinglePageInfo page}) {
Widget _buildViewer({AvesEntry pageEntry}) {
return Selector<MediaQueryData, Size>(
selector: (c, mq) => mq.size,
builder: (c, mqSize, child) {
return EntryPageView(
mainEntry: entry,
page: page,
mainEntry: mainEntry,
pageEntry: pageEntry ?? mainEntry,
viewportSize: mqSize,
videoControllers: widget.videoControllers,
);
},
);
}
MultiPageController _getMultiPageController(AvesEntry entry) {
return widget.multiPageControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
}
@override
bool get wantKeepAlive => true;
}

View file

@ -3,21 +3,16 @@ import 'dart:math';
import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart';
import 'package:aves/widgets/common/video/controller.dart';
import 'package:aves/widgets/viewer/entry_horizontal_pager.dart';
import 'package:aves/widgets/viewer/info/info_page.dart';
import 'package:aves/widgets/viewer/info/notifications.dart';
import 'package:aves/widgets/viewer/multipage.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:tuple/tuple.dart';
class ViewerVerticalPageView extends StatefulWidget {
final CollectionLens collection;
final ValueNotifier<AvesEntry> entryNotifier;
final List<Tuple2<String, AvesVideoController>> videoControllers;
final List<Tuple2<String, MultiPageController>> multiPageControllers;
final PageController horizontalPager, verticalPager;
final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged;
final VoidCallback onImagePageRequested;
@ -26,8 +21,6 @@ class ViewerVerticalPageView extends StatefulWidget {
const ViewerVerticalPageView({
@required this.collection,
@required this.entryNotifier,
@required this.videoControllers,
@required this.multiPageControllers,
@required this.verticalPager,
@required this.horizontalPager,
@required this.onVerticalPageChanged,
@ -92,14 +85,10 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
collection: collection,
pageController: widget.horizontalPager,
onPageChanged: widget.onHorizontalPageChanged,
videoControllers: widget.videoControllers,
multiPageControllers: widget.multiPageControllers,
onViewDisposed: widget.onViewDisposed,
)
: SingleEntryScroller(
entry: entry,
videoControllers: widget.videoControllers,
multiPageControllers: widget.multiPageControllers,
),
NotificationListener(
onNotification: (notification) {
@ -152,6 +141,9 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
} else {
Navigator.pop(context);
}
// needed to refresh when entry changes but the page does not (e.g. on page deletion)
setState(() {});
}
// when the entry image itself changed (e.g. after rotation)

View file

@ -2,7 +2,10 @@ import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/viewer/entry_viewer_stack.dart';
import 'package:aves/widgets/viewer/multipage/conductor.dart';
import 'package:aves/widgets/viewer/video/conductor.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class EntryViewerPage extends StatelessWidget {
static const routeName = '/viewer';
@ -20,9 +23,17 @@ class EntryViewerPage extends StatelessWidget {
Widget build(BuildContext context) {
return MediaQueryDataProvider(
child: Scaffold(
body: EntryViewerStack(
collection: collection,
initialEntry: initialEntry,
body: Provider<VideoConductor>(
create: (context) => VideoConductor(),
dispose: (context, value) => value.dispose(),
child: Provider<MultiPageConductor>(
create: (context) => MultiPageConductor(),
dispose: (context, value) => value.dispose(),
child: EntryViewerStack(
collection: collection,
initialEntry: initialEntry,
),
),
),
backgroundColor: Navigator.canPop(context) ? Colors.transparent : Colors.black,
resizeToAvoidBottomInset: false,

View file

@ -1,7 +1,9 @@
import 'dart:math';
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
@ -11,18 +13,18 @@ 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/common/basic/insets.dart';
import 'package:aves/widgets/common/video/controller.dart';
import 'package:aves/widgets/common/video/fijkplayer.dart';
import 'package:aves/widgets/viewer/entry_action_delegate.dart';
import 'package:aves/widgets/viewer/entry_vertical_pager.dart';
import 'package:aves/widgets/viewer/hero.dart';
import 'package:aves/widgets/viewer/info/notifications.dart';
import 'package:aves/widgets/viewer/multipage.dart';
import 'package:aves/widgets/viewer/overlay/bottom.dart';
import 'package:aves/widgets/viewer/multipage/conductor.dart';
import 'package:aves/widgets/viewer/overlay/bottom/common.dart';
import 'package:aves/widgets/viewer/overlay/bottom/panorama.dart';
import 'package:aves/widgets/viewer/overlay/bottom/video.dart';
import 'package:aves/widgets/viewer/overlay/notifications.dart';
import 'package:aves/widgets/viewer/overlay/panorama.dart';
import 'package:aves/widgets/viewer/overlay/top.dart';
import 'package:aves/widgets/viewer/overlay/video.dart';
import 'package:aves/widgets/viewer/video/conductor.dart';
import 'package:aves/widgets/viewer/video/controller.dart';
import 'package:aves/widgets/viewer/visual/state.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -57,8 +59,6 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
Animation<Offset> _bottomOverlayOffset;
EdgeInsets _frozenViewInsets, _frozenViewPadding;
EntryActionDelegate _actionDelegate;
final List<Tuple2<String, AvesVideoController>> _videoControllers = [];
final List<Tuple2<String, MultiPageController>> _multiPageControllers = [];
final List<Tuple2<String, ValueNotifier<ViewState>>> _viewStateNotifiers = [];
final ValueNotifier<HeroInfo> _heroInfoNotifier = ValueNotifier(null);
@ -108,7 +108,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
collection: collection,
showInfo: () => _goToVerticalPage(infoPage),
);
_initViewStateControllers();
_initEntryControllers();
_registerWidget(widget);
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) => _initOverlay());
@ -128,10 +128,6 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
void dispose() {
_overlayAnimationController.dispose();
_overlayVisible.removeListener(_onOverlayVisibleChange);
_videoControllers.forEach((kv) => kv.item2.dispose());
_videoControllers.clear();
_multiPageControllers.forEach((kv) => kv.item2.dispose());
_multiPageControllers.clear();
_verticalPager.removeListener(_onVerticalPageControllerChange);
WidgetsBinding.instance.removeObserver(this);
_unregisterWidget(widget);
@ -198,8 +194,6 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
ViewerVerticalPageView(
collection: collection,
entryNotifier: _entryNotifier,
videoControllers: _videoControllers,
multiPageControllers: _multiPageControllers,
verticalPager: _verticalPager,
horizontalPager: _horizontalPager,
onVerticalPageChanged: _onVerticalPageChanged,
@ -226,21 +220,31 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
Widget _buildTopOverlay() {
final child = ValueListenableBuilder<AvesEntry>(
valueListenable: _entryNotifier,
builder: (context, entry, child) {
if (entry == null) return SizedBox.shrink();
builder: (context, mainEntry, child) {
if (mainEntry == null) return SizedBox.shrink();
final multiPageController = _getMultiPageController(entry);
final viewStateNotifier = _viewStateNotifiers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
final viewStateNotifier = _viewStateNotifiers.firstWhere((kv) => kv.item1 == mainEntry.uri, orElse: () => null)?.item2;
return ViewerTopOverlay(
entry: entry,
mainEntry: mainEntry,
scale: _topOverlayScale,
canToggleFavourite: hasCollection,
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
onActionSelected: (action) => _actionDelegate.onActionSelected(context, entry, action),
onActionSelected: (action) {
var targetEntry = mainEntry;
if (mainEntry.isMultiPage && EntryActions.pageActions.contains(action)) {
final multiPageController = context.read<MultiPageConductor>().getController(mainEntry);
if (multiPageController != null) {
final multiPageInfo = multiPageController.info;
final pageEntry = multiPageInfo?.getPageEntryByIndex(multiPageController.page);
if (pageEntry != null) {
targetEntry = pageEntry;
}
}
}
_actionDelegate.onActionSelected(context, targetEntry, action);
},
viewStateNotifier: viewStateNotifier,
multiPageController: multiPageController,
);
},
);
@ -262,25 +266,43 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
builder: (context, entry, child) {
if (entry == null) return SizedBox.shrink();
final multiPageController = _getMultiPageController(entry);
Widget extraBottomOverlay;
if (entry.isVideo) {
final videoController = _videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
if (videoController != null) {
extraBottomOverlay = VideoControlOverlay(
entry: entry,
controller: videoController,
Widget _buildExtraBottomOverlay(AvesEntry pageEntry) {
// a 360 video is both a video and a panorama but only the video controls are displayed
if (pageEntry.isVideo) {
return Selector<VideoConductor, AvesVideoController>(
selector: (context, vc) => vc.getController(pageEntry),
builder: (context, videoController, child) => VideoControlOverlay(
entry: pageEntry,
controller: videoController,
scale: _bottomOverlayScale,
),
);
} else if (pageEntry.is360) {
return PanoramaOverlay(
entry: pageEntry,
scale: _bottomOverlayScale,
);
}
} else if (entry.is360) {
extraBottomOverlay = PanoramaOverlay(
entry: entry,
scale: _bottomOverlayScale,
);
return null;
}
final multiPageController = entry.isMultiPage ? context.read<MultiPageConductor>().getController(entry) : null;
final extraBottomOverlay = multiPageController != null
? StreamBuilder<MultiPageInfo>(
stream: multiPageController.infoStream,
builder: (context, snapshot) {
final multiPageInfo = multiPageController.info;
if (multiPageInfo == null) return SizedBox.shrink();
return ValueListenableBuilder<int>(
valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) {
final pageEntry = multiPageInfo.getPageEntryByIndex(page);
return _buildExtraBottomOverlay(pageEntry) ?? SizedBox();
},
);
})
: _buildExtraBottomOverlay(entry);
final child = Column(
children: [
if (extraBottomOverlay != null)
@ -335,10 +357,6 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
return bottomOverlay;
}
MultiPageController _getMultiPageController(AvesEntry entry) {
return entry.isMultipage ? _multiPageControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2 : null;
}
void _onVerticalPageControllerChange() {
_verticalScrollNotifier.notifyListeners();
}
@ -405,7 +423,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
}
}
void _updateEntry() {
Future<void> _updateEntry() async {
if (_currentHorizontalPage != null && entries.isNotEmpty && _currentHorizontalPage >= entries.length) {
// as of Flutter v1.22.2, `PageView` does not call `onPageChanged` when the last page is deleted
// so we manually track the page change, and let the entry update follow
@ -416,8 +434,8 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
final newEntry = _currentHorizontalPage != null && _currentHorizontalPage < entries.length ? entries[_currentHorizontalPage] : null;
if (_entryNotifier.value == newEntry) return;
_entryNotifier.value = newEntry;
_pauseVideoControllers();
_initViewStateControllers();
await _pauseVideoControllers();
await _initEntryControllers();
}
void _popVisual() {
@ -494,68 +512,92 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
// state controllers/monitors
void _initViewStateControllers() {
Future<void> _initEntryControllers() async {
final entry = _entryNotifier.value;
if (entry == null) return;
final uri = entry.uri;
_initViewSpecificController<ValueNotifier<ViewState>>(
uri,
_viewStateNotifiers,
() => ValueNotifier<ViewState>(ViewState.zero),
(_) => _.dispose(),
);
_initViewStateController(entry);
if (entry.isVideo) {
_initViewSpecificController<AvesVideoController>(
uri,
_videoControllers,
() => IjkPlayerAvesVideoController(entry),
(_) => _.dispose(),
);
if (settings.enableVideoAutoPlay) {
_playVideo();
}
await _initVideoController(entry);
}
if (entry.isMultipage) {
_initViewSpecificController<MultiPageController>(
uri,
_multiPageControllers,
() => MultiPageController(entry),
(_) => _.dispose(),
);
}
setState(() {});
}
Future<void> _playVideo() async {
await Future.delayed(Duration(milliseconds: 300));
final entry = _entryNotifier.value;
if (entry == null) return;
final videoController = _videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
if (videoController != null) {
if (videoController.isPlayable) {
await videoController.play();
} else {
await videoController.setDataSource(entry.uri);
}
if (entry.isMultiPage) {
await _initMultiPageController(entry);
}
}
void _initViewSpecificController<T>(String uri, List<Tuple2<String, T>> controllers, T Function() builder, void Function(T controller) disposer) {
var controller = controllers.firstWhere((kv) => kv.item1 == uri, orElse: () => null);
void _initViewStateController(AvesEntry entry) {
final uri = entry.uri;
var controller = _viewStateNotifiers.firstWhere((kv) => kv.item1 == uri, orElse: () => null);
if (controller != null) {
controllers.remove(controller);
_viewStateNotifiers.remove(controller);
} else {
controller = Tuple2(uri, builder());
controller = Tuple2(uri, ValueNotifier<ViewState>(ViewState.zero));
}
controllers.insert(0, controller);
while (controllers.length > 3) {
disposer?.call(controllers.removeLast().item2);
_viewStateNotifiers.insert(0, controller);
while (_viewStateNotifiers.length > 3) {
_viewStateNotifiers.removeLast().item2.dispose();
}
}
void _pauseVideoControllers() => _videoControllers.forEach((e) => e.item2.pause());
Future<void> _initVideoController(AvesEntry entry) async {
final controller = context.read<VideoConductor>().getOrCreateController(entry);
setState(() {});
if (settings.enableVideoAutoPlay) {
await _playVideo(controller, () => entry == _entryNotifier.value);
}
}
Future<void> _initMultiPageController(AvesEntry entry) async {
final multiPageController = context.read<MultiPageConductor>().getOrCreateController(entry);
setState(() {});
final multiPageInfo = multiPageController.info ?? await multiPageController.infoStream.first;
if (entry.isMotionPhoto) {
await multiPageInfo.extractMotionPhotoVideo();
}
final videoPageEntries = multiPageInfo.videoPageEntries;
if (videoPageEntries.isNotEmpty) {
// init video controllers for all pages that could need it
final videoConductor = context.read<VideoConductor>();
videoPageEntries.forEach(videoConductor.getOrCreateController);
// auto play/pause when changing page
Future<void> _onPageChange() async {
await _pauseVideoControllers();
if (settings.enableVideoAutoPlay) {
final page = multiPageController.page;
final pageInfo = multiPageInfo.getByIndex(page);
if (pageInfo.isVideo) {
final pageEntry = multiPageInfo.getPageEntryByIndex(page);
final pageVideoController = videoConductor.getController(pageEntry);
assert(pageVideoController != null);
await _playVideo(pageVideoController, () => entry == _entryNotifier.value && page == multiPageController.page);
}
}
}
multiPageController.pageNotifier.addListener(_onPageChange);
await _onPageChange();
}
}
Future<void> _playVideo(AvesVideoController videoController, bool Function() isCurrent) async {
// video decoding may fail or have initial artifacts when the player initializes
// during this widget initialization (because of the page transition and hero animation?)
// so we play after a delay for increased stability
await Future.delayed(Duration(milliseconds: 300) * timeDilation);
await videoController.play();
// playing controllers are paused when the entry changes,
// but the controller may still be preparing (not yet playing) when this happens
// so we make sure the current entry is still the same to keep playing
if (!isCurrent()) {
await videoController.pause();
}
}
Future<void> _pauseVideoControllers() => context.read<VideoConductor>().pauseAll();
}

View file

@ -79,6 +79,7 @@ class BasicSection extends StatelessWidget {
MimeFilter(entry.mimeType),
if (entry.isAnimated) TypeFilter.animated,
if (entry.isGeotiff) TypeFilter.geotiff,
if (entry.isMotionPhoto) TypeFilter.motionPhoto,
if (entry.isImage && entry.is360) TypeFilter.panorama,
if (entry.isVideo && entry.is360) TypeFilter.sphericalVideo,
if (entry.isVideo && !entry.is360) MimeFilter.video,

View file

@ -7,7 +7,7 @@ import 'package:aves/widgets/viewer/info/maps/scale_layer.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:latlong/latlong.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
@ -71,7 +71,7 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with AutomaticKeepAli
options: MapOptions(
center: widget.latLng,
zoom: widget.initialZoom,
interactive: false,
interactiveFlags: InteractiveFlag.none,
),
mapController: _mapController,
children: [

View file

@ -31,8 +31,8 @@ class ScaleLayerWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final mapState = MapState.of(context);
return ScaleLayer(options, mapState, mapState.onMoved);
final mapState = MapState.maybeOf(context);
return mapState != null ? ScaleLayer(options, mapState, mapState.onMoved) : SizedBox();
}
}

View file

@ -1,7 +1,7 @@
import 'dart:math';
import 'package:aves/utils/math_utils.dart';
import 'package:latlong/latlong.dart';
import 'package:latlong2/latlong.dart';
LatLng calculateEndingGlobalCoordinates(LatLng start, double startBearing, double distance) {
var mSemiMajorAxis = 6378137.0; //WGS84 major axis

View file

@ -121,11 +121,14 @@ class MetadataDirTile extends StatelessWidget with FeedbackMixin {
Future<void> _openEmbeddedData(BuildContext context, OpenEmbeddedDataNotification notification) async {
Map fields;
switch (notification.source) {
case EmbeddedDataSource.motionPhotoVideo:
fields = await embeddedDataService.extractMotionPhotoVideo(entry);
break;
case EmbeddedDataSource.videoCover:
fields = await metadataService.extractVideoEmbeddedPicture(entry.uri);
fields = await embeddedDataService.extractVideoEmbeddedPicture(entry);
break;
case EmbeddedDataSource.xmp:
fields = await metadataService.extractXmpDataProp(entry, notification.propPath, notification.mimeType);
fields = await embeddedDataService.extractXmpDataProp(entry, notification.propPath, notification.mimeType);
break;
}
if (fields == null || !fields.containsKey('mimeType') || !fields.containsKey('uri')) {

View file

@ -158,7 +158,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
return MetadataDirectory(directoryName, parent, _toSortedTags(rawTags));
}).toList();
if (entry.isVideo || (entry.mimeType == MimeTypes.heif && entry.isMultipage)) {
if (entry.isVideo || (entry.mimeType == MimeTypes.heif && entry.isMultiPage)) {
directories.addAll(await _getStreamDirectories());
}
@ -193,6 +193,8 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
String getTypeText(Map stream) {
final type = stream[Keys.streamType] ?? StreamTypes.unknown;
switch (type) {
case StreamTypes.attachment:
return 'Attachment';
case StreamTypes.audio:
return 'Audio';
case StreamTypes.metadata:
@ -209,8 +211,8 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
}
final allStreams = (mediaInfo[Keys.streams] as List).cast<Map>();
final unknownStreams = allStreams.where((stream) => stream[Keys.streamType] == StreamTypes.unknown).toList();
final knownStreams = allStreams.whereNot(unknownStreams.contains);
final attachmentStreams = allStreams.where((stream) => stream[Keys.streamType] == StreamTypes.attachment).toList();
final knownStreams = allStreams.whereNot(attachmentStreams.contains);
// display known streams as separate directories (e.g. video, audio, subs)
if (knownStreams.isNotEmpty) {
@ -228,18 +230,18 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
}
}
// display unknown streams as attachments (e.g. fonts)
if (unknownStreams.isNotEmpty) {
final unknownCodecCount = <String, List<String>>{};
for (final stream in unknownStreams) {
// group attachments by format (e.g. TTF fonts)
if (attachmentStreams.isNotEmpty) {
final formatCount = <String, List<String>>{};
for (final stream in attachmentStreams) {
final codec = (stream[Keys.codecName] as String ?? 'unknown').toUpperCase();
if (!unknownCodecCount.containsKey(codec)) {
unknownCodecCount[codec] = [];
if (!formatCount.containsKey(codec)) {
formatCount[codec] = [];
}
unknownCodecCount[codec].add(stream[Keys.filename]);
formatCount[codec].add(stream[Keys.filename]);
}
if (unknownCodecCount.isNotEmpty) {
final rawTags = unknownCodecCount.map((key, value) {
if (formatCount.isNotEmpty) {
final rawTags = formatCount.map((key, value) {
final count = value.length;
// remove duplicate names, so number of displayed names may not match displayed count
final names = value.where((v) => v != null).toSet().toList()..sort(compareAsciiUpperCase);

View file

@ -28,7 +28,7 @@ class _MetadataThumbnailsState extends State<MetadataThumbnails> {
@override
void initState() {
super.initState();
_loader = metadataService.getExifThumbnails(entry);
_loader = embeddedDataService.getExifThumbnails(entry);
}
@override

View file

@ -4,21 +4,61 @@ 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/viewer/info/common.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/exif.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/google.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/iptc.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/mwg.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/photoshop.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/tiff.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/xmp.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class XmpNamespace {
final String namespace;
final Map<String, String> rawProps;
const XmpNamespace(this.namespace);
const XmpNamespace(this.namespace, this.rawProps);
factory XmpNamespace.create(String namespace, Map<String, String> rawProps) {
switch (namespace) {
case XmpBasicNamespace.ns:
return XmpBasicNamespace(rawProps);
case XmpExifNamespace.ns:
return XmpExifNamespace(rawProps);
case XmpGAudioNamespace.ns:
return XmpGAudioNamespace(rawProps);
case XmpGCameraNamespace.ns:
return XmpGCameraNamespace(rawProps);
case XmpGDepthNamespace.ns:
return XmpGDepthNamespace(rawProps);
case XmpGImageNamespace.ns:
return XmpGImageNamespace(rawProps);
case XmpIptcCoreNamespace.ns:
return XmpIptcCoreNamespace(rawProps);
case XmpMgwRegionsNamespace.ns:
return XmpMgwRegionsNamespace(rawProps);
case XmpMMNamespace.ns:
return XmpMMNamespace(rawProps);
case XmpNoteNamespace.ns:
return XmpNoteNamespace(rawProps);
case XmpPhotoshopNamespace.ns:
return XmpPhotoshopNamespace(rawProps);
case XmpTiffNamespace.ns:
return XmpTiffNamespace(rawProps);
default:
return XmpNamespace(namespace, rawProps);
}
}
String get displayTitle => XMP.namespaces[namespace] ?? namespace;
List<Widget> buildNamespaceSection({
@required List<MapEntry<String, String>> rawProps,
}) {
final props = rawProps
Map<String, String> get buildProps => rawProps;
List<Widget> buildNamespaceSection() {
final props = buildProps
.entries
.map((kv) {
final prop = XmpProp(kv.key, kv.value);
return extractData(prop) ? null : prop;

View file

@ -5,7 +5,7 @@ import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
class XmpExifNamespace extends XmpNamespace {
static const ns = 'exif';
XmpExifNamespace() : super(ns);
XmpExifNamespace(Map<String, String> rawProps) : super(ns, rawProps);
@override
String get displayTitle => 'Exif';

View file

@ -5,7 +5,7 @@ import 'package:aves/widgets/viewer/info/notifications.dart';
import 'package:tuple/tuple.dart';
abstract class XmpGoogleNamespace extends XmpNamespace {
XmpGoogleNamespace(String ns) : super(ns);
XmpGoogleNamespace(String ns, Map<String, String> rawProps) : super(ns, rawProps);
List<Tuple2<String, String>> get dataProps;
@ -34,7 +34,7 @@ abstract class XmpGoogleNamespace extends XmpNamespace {
class XmpGAudioNamespace extends XmpGoogleNamespace {
static const ns = 'GAudio';
XmpGAudioNamespace() : super(ns);
XmpGAudioNamespace(Map<String, String> rawProps) : super(ns, rawProps);
@override
List<Tuple2<String, String>> get dataProps => [Tuple2('$ns:Data', '$ns:Mime')];
@ -46,7 +46,7 @@ class XmpGAudioNamespace extends XmpGoogleNamespace {
class XmpGDepthNamespace extends XmpGoogleNamespace {
static const ns = 'GDepth';
XmpGDepthNamespace() : super(ns);
XmpGDepthNamespace(Map<String, String> rawProps) : super(ns, rawProps);
@override
List<Tuple2<String, String>> get dataProps => [
@ -61,7 +61,7 @@ class XmpGDepthNamespace extends XmpGoogleNamespace {
class XmpGImageNamespace extends XmpGoogleNamespace {
static const ns = 'GImage';
XmpGImageNamespace() : super(ns);
XmpGImageNamespace(Map<String, String> rawProps) : super(ns, rawProps);
@override
List<Tuple2<String, String>> get dataProps => [Tuple2('$ns:Data', '$ns:Mime')];
@ -69,3 +69,35 @@ class XmpGImageNamespace extends XmpGoogleNamespace {
@override
String get displayTitle => 'Google Image';
}
class XmpGCameraNamespace extends XmpNamespace {
static const ns = 'GCamera';
static const videoOffsetKey = 'GCamera:MicroVideoOffset';
static const videoDataKey = 'Data';
bool _isMotionPhoto;
XmpGCameraNamespace(Map<String, String> rawProps) : super(ns, rawProps) {
_isMotionPhoto = rawProps.keys.any((key) => key == videoOffsetKey);
}
@override
Map<String, String> get buildProps {
return _isMotionPhoto
? Map.fromEntries({
MapEntry(videoDataKey, '[skipped]'),
...rawProps.entries,
})
: rawProps;
}
@override
Map<String, InfoLinkHandler> linkifyValues(List<XmpProp> props) {
return {
videoDataKey: InfoLinkHandler(
linkText: (context) => context.l10n.viewerInfoOpenLinkText,
onTap: (context) => OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context),
),
};
}
}

View file

@ -9,7 +9,7 @@ class XmpIptcCoreNamespace extends XmpNamespace {
final creatorContactInfo = <String, String>{};
XmpIptcCoreNamespace() : super(ns);
XmpIptcCoreNamespace(Map<String, String> rawProps) : super(ns, rawProps);
@override
String get displayTitle => 'IPTC Core';

View file

@ -12,7 +12,7 @@ class XmpMgwRegionsNamespace extends XmpNamespace {
final dimensions = <String, String>{};
final regionList = <int, Map<String, String>>{};
XmpMgwRegionsNamespace() : super(ns);
XmpMgwRegionsNamespace(Map<String, String> rawProps) : super(ns, rawProps);
@override
String get displayTitle => 'Regions';

View file

@ -5,7 +5,7 @@ import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
class XmpPhotoshopNamespace extends XmpNamespace {
static const ns = 'photoshop';
XmpPhotoshopNamespace() : super(ns);
XmpPhotoshopNamespace(Map<String, String> rawProps) : super(ns, rawProps);
@override
String get displayTitle => 'Photoshop';

View file

@ -8,7 +8,7 @@ class XmpTiffNamespace extends XmpNamespace {
@override
String get displayTitle => 'TIFF';
XmpTiffNamespace() : super(ns);
XmpTiffNamespace(Map<String, String> rawProps) : super(ns, rawProps);
@override
String formatValue(XmpProp prop) {

View file

@ -14,7 +14,7 @@ class XmpBasicNamespace extends XmpNamespace {
final thumbnails = <int, Map<String, String>>{};
XmpBasicNamespace() : super(ns);
XmpBasicNamespace(Map<String, String> rawProps) : super(ns, rawProps);
@override
String get displayTitle => 'Basic';
@ -61,7 +61,7 @@ class XmpMMNamespace extends XmpNamespace {
final ingredients = <int, Map<String, String>>{};
final pantry = <int, Map<String, String>>{};
XmpMMNamespace() : super(ns);
XmpMMNamespace(Map<String, String> rawProps) : super(ns, rawProps);
@override
String get displayTitle => 'Media Management';
@ -114,7 +114,7 @@ class XmpNoteNamespace extends XmpNamespace {
// `xmpNote:HasExtendedXMP` is structural and should not be displayed to users
static const hasExtendedXmp = '$ns:HasExtendedXMP';
XmpNoteNamespace() : super(ns);
XmpNoteNamespace(Map<String, String> rawProps) : super(ns, rawProps);
@override
bool extractData(XmpProp prop) {

View file

@ -4,13 +4,6 @@ import 'package:aves/model/entry.dart';
import 'package:aves/ref/xmp.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/exif.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/google.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/iptc.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/mwg.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/photoshop.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/tiff.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/xmp.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
@ -36,40 +29,13 @@ class _XmpDirTileState extends State<XmpDirTile> {
@override
Widget build(BuildContext context) {
final sections = SplayTreeMap<XmpNamespace, List<MapEntry<String, String>>>.of(
groupBy(widget.tags.entries, (kv) {
final fullKey = kv.key;
final i = fullKey.indexOf(XMP.propNamespaceSeparator);
final namespace = i == -1 ? '' : fullKey.substring(0, i);
switch (namespace) {
case XmpBasicNamespace.ns:
return XmpBasicNamespace();
case XmpExifNamespace.ns:
return XmpExifNamespace();
case XmpGAudioNamespace.ns:
return XmpGAudioNamespace();
case XmpGDepthNamespace.ns:
return XmpGDepthNamespace();
case XmpGImageNamespace.ns:
return XmpGImageNamespace();
case XmpIptcCoreNamespace.ns:
return XmpIptcCoreNamespace();
case XmpMgwRegionsNamespace.ns:
return XmpMgwRegionsNamespace();
case XmpMMNamespace.ns:
return XmpMMNamespace();
case XmpNoteNamespace.ns:
return XmpNoteNamespace();
case XmpPhotoshopNamespace.ns:
return XmpPhotoshopNamespace();
case XmpTiffNamespace.ns:
return XmpTiffNamespace();
default:
return XmpNamespace(namespace);
}
}),
(a, b) => compareAsciiUpperCase(a.displayTitle, b.displayTitle),
);
final sections = groupBy(widget.tags.entries, (kv) {
final fullKey = kv.key;
final i = fullKey.indexOf(XMP.propNamespaceSeparator);
final namespace = i == -1 ? '' : fullKey.substring(0, i);
return namespace;
}).entries.map((kv) => XmpNamespace.create(kv.key, Map.fromEntries(kv.value))).toList()
..sort((a, b) => compareAsciiUpperCase(a.displayTitle, b.displayTitle));
return AvesExpansionTile(
title: 'XMP',
expandedNotifier: widget.expandedNotifier,
@ -79,11 +45,7 @@ class _XmpDirTileState extends State<XmpDirTile> {
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: sections.entries
.expand((kv) => kv.key.buildNamespaceSection(
rawProps: kv.value,
))
.toList(),
children: sections.expand((section) => section.buildNamespaceSection()).toList(),
),
),
],

View file

@ -28,7 +28,7 @@ class OpenTempEntryNotification extends Notification {
String toString() => '$runtimeType#${shortHash(this)}{entry=$entry}';
}
enum EmbeddedDataSource { videoCover, xmp }
enum EmbeddedDataSource { motionPhotoVideo, videoCover, xmp }
class OpenEmbeddedDataNotification extends Notification {
final EmbeddedDataSource source;
@ -41,6 +41,10 @@ class OpenEmbeddedDataNotification extends Notification {
this.mimeType,
});
factory OpenEmbeddedDataNotification.motionPhotoVideo() => OpenEmbeddedDataNotification._private(
source: EmbeddedDataSource.motionPhotoVideo,
);
factory OpenEmbeddedDataNotification.videoCover() => OpenEmbeddedDataNotification._private(
source: EmbeddedDataSource.videoCover,
);

View file

@ -1,29 +0,0 @@
import 'dart:async';
import 'package:aves/model/entry.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class MultiPageController extends ChangeNotifier {
Future<MultiPageInfo> info;
final ValueNotifier<int> pageNotifier = ValueNotifier(null);
MultiPageController(AvesEntry entry) {
info = metadataService.getMultiPageInfo(entry).then((value) {
pageNotifier.value = value.defaultPage.index;
return value;
});
}
int get page => pageNotifier.value;
set page(int page) => pageNotifier.value = page;
@override
void dispose() {
pageNotifier.dispose();
super.dispose();
}
}

View file

@ -0,0 +1,31 @@
import 'package:aves/model/entry.dart';
import 'package:aves/widgets/viewer/multipage/controller.dart';
class MultiPageConductor {
final List<MultiPageController> _controllers = [];
static const maxControllerCount = 3;
Future<void> dispose() async {
await Future.forEach(_controllers, (controller) => controller.dispose());
_controllers.clear();
}
MultiPageController getOrCreateController(AvesEntry entry) {
var controller = getController(entry);
if (controller != null) {
_controllers.remove(controller);
} else {
controller = MultiPageController(entry);
}
_controllers.insert(0, controller);
while (_controllers.length > maxControllerCount) {
_controllers.removeLast().dispose();
}
return controller;
}
MultiPageController getController(AvesEntry entry) {
return _controllers.firstWhere((c) => c.entry.uri == entry.uri && c.entry.pageId == entry.pageId, orElse: () => null);
}
}

View file

@ -0,0 +1,39 @@
import 'dart:async';
import 'package:aves/model/entry.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class MultiPageController {
final AvesEntry entry;
final ValueNotifier<int> pageNotifier = ValueNotifier(null);
MultiPageInfo _info;
final StreamController<MultiPageInfo> _infoStreamController = StreamController.broadcast();
Stream<MultiPageInfo> get infoStream => _infoStreamController.stream;
MultiPageInfo get info => _info;
int get page => pageNotifier.value;
set page(int page) => pageNotifier.value = page;
MultiPageController(this.entry) {
metadataService.getMultiPageInfo(entry).then((value) {
pageNotifier.value = value.defaultPage.index;
_info = value;
_infoStreamController.add(_info);
});
}
void dispose() {
pageNotifier.dispose();
}
@override
String toString() => '$runtimeType#${shortHash(this)}{entry=$entry, page=$page, info=$info}';
}

View file

@ -11,9 +11,9 @@ import 'package:aves/theme/icons.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/blurred.dart';
import 'package:aves/widgets/viewer/multipage.dart';
import 'package:aves/widgets/viewer/multipage/controller.dart';
import 'package:aves/widgets/viewer/overlay/bottom/multipage.dart';
import 'package:aves/widgets/viewer/overlay/common.dart';
import 'package:aves/widgets/viewer/overlay/multipage.dart';
import 'package:decorated_icon/decorated_icon.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
@ -102,7 +102,7 @@ class _ViewerBottomOverlayState extends State<ViewerBottomOverlay> {
Widget _buildContent({MultiPageInfo multiPageInfo, int page}) => _BottomOverlayContent(
mainEntry: _lastEntry,
page: multiPageInfo?.getByIndex(page),
pageEntry: multiPageInfo?.getPageEntryByIndex(page) ?? _lastEntry,
details: _lastDetails,
position: widget.showPosition ? '${widget.index + 1}/${widget.entries.length}' : null,
availableWidth: availableWidth,
@ -111,10 +111,10 @@ class _ViewerBottomOverlayState extends State<ViewerBottomOverlay> {
if (multiPageController == null) return _buildContent();
return FutureBuilder<MultiPageInfo>(
future: multiPageController.info,
return StreamBuilder<MultiPageInfo>(
stream: multiPageController.infoStream,
builder: (context, snapshot) {
final multiPageInfo = snapshot.data;
final multiPageInfo = multiPageController.info;
return ValueListenableBuilder<int>(
valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) {
@ -138,8 +138,7 @@ const double _interRowPadding = 2.0;
const double _subRowMinWidth = 300.0;
class _BottomOverlayContent extends AnimatedWidget {
final AvesEntry mainEntry, entry;
final SinglePageInfo page;
final AvesEntry mainEntry, pageEntry;
final OverlayMetadata details;
final String position;
final double availableWidth;
@ -150,13 +149,18 @@ class _BottomOverlayContent extends AnimatedWidget {
_BottomOverlayContent({
Key key,
this.mainEntry,
this.page,
this.pageEntry,
this.details,
this.position,
this.availableWidth,
this.multiPageController,
}) : entry = mainEntry.getPageEntry(page),
super(key: key, listenable: mainEntry.metadataChangeNotifier);
}) : super(
key: key,
listenable: Listenable.merge([
mainEntry.metadataChangeNotifier,
pageEntry.metadataChangeNotifier,
]),
);
@override
Widget build(BuildContext context) {
@ -178,13 +182,12 @@ class _BottomOverlayContent extends AnimatedWidget {
infoColumn = _buildInfoColumn(orientation);
}
if (mainEntry.isMultipage && multiPageController != null) {
if (mainEntry.isMultiPage && multiPageController != null) {
infoColumn = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MultiPageOverlay(
mainEntry: mainEntry,
controller: multiPageController,
availableWidth: availableWidth,
),
@ -204,7 +207,7 @@ class _BottomOverlayContent extends AnimatedWidget {
final infoMaxWidth = availableWidth - infoPadding.horizontal;
final twoColumns = orientation == Orientation.landscape && infoMaxWidth / 2 > _subRowMinWidth;
final subRowWidth = twoColumns ? min(_subRowMinWidth, infoMaxWidth / 2) : infoMaxWidth;
final positionTitle = _PositionTitleRow(entry: entry, collectionPosition: position, multiPageController: multiPageController);
final positionTitle = _PositionTitleRow(entry: pageEntry, collectionPosition: position, multiPageController: multiPageController);
final hasShootingDetails = details != null && !details.isEmpty && settings.showOverlayShootingDetails;
return Padding(
@ -223,7 +226,7 @@ class _BottomOverlayContent extends AnimatedWidget {
Container(
width: subRowWidth,
child: _DateRow(
entry: entry,
entry: pageEntry,
multiPageController: multiPageController,
)),
_buildDuoShootingRow(subRowWidth, hasShootingDetails),
@ -235,7 +238,7 @@ class _BottomOverlayContent extends AnimatedWidget {
padding: EdgeInsets.only(top: _interRowPadding),
width: subRowWidth,
child: _DateRow(
entry: entry,
entry: pageEntry,
multiPageController: multiPageController,
),
),
@ -251,10 +254,10 @@ class _BottomOverlayContent extends AnimatedWidget {
switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: _soloTransition,
child: entry.hasGps
child: pageEntry.hasGps
? Container(
padding: EdgeInsets.only(top: _interRowPadding),
child: _LocationRow(entry: entry),
child: _LocationRow(entry: pageEntry),
)
: SizedBox.shrink(),
);
@ -354,10 +357,10 @@ class _PositionTitleRow extends StatelessWidget {
if (multiPageController == null) return toText();
return FutureBuilder<MultiPageInfo>(
future: multiPageController.info,
return StreamBuilder<MultiPageInfo>(
stream: multiPageController.infoStream,
builder: (context, snapshot) {
final multiPageInfo = snapshot.data;
final multiPageInfo = multiPageController.info;
String pagePosition;
if (multiPageInfo != null) {
// page count may be 0 when we know an entry to have multiple pages

View file

@ -1,26 +1,22 @@
import 'dart:math';
import 'package:aves/model/entry.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
import 'package:aves/widgets/collection/thumbnail/theme.dart';
import 'package:aves/widgets/viewer/multipage.dart';
import 'package:aves/widgets/viewer/multipage/controller.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class MultiPageOverlay extends StatefulWidget {
final AvesEntry mainEntry;
final MultiPageController controller;
final double availableWidth;
MultiPageOverlay({
const MultiPageOverlay({
Key key,
@required this.mainEntry,
@required this.controller,
@required this.availableWidth,
}) : assert(mainEntry.isMultipage),
assert(controller != null),
}) : assert(controller != null),
super(key: key);
@override
@ -31,12 +27,11 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
final _cancellableNotifier = ValueNotifier(true);
ScrollController _scrollController;
bool _syncScroll = true;
int _initControllerPage;
static const double extent = 48;
static const double separatorWidth = 2;
AvesEntry get mainEntry => widget.mainEntry;
MultiPageController get controller => widget.controller;
double get availableWidth => widget.availableWidth;
@ -64,10 +59,26 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
}
void _registerWidget() {
final page = controller.page ?? 0;
final scrollOffset = pageToScrollOffset(page);
_initControllerPage = controller.page;
final scrollOffset = pageToScrollOffset(_initControllerPage ?? 0);
_scrollController = ScrollController(initialScrollOffset: scrollOffset);
_scrollController.addListener(_onScrollChange);
if (_initControllerPage == null) {
_correctDefaultPageScroll();
}
}
// correct scroll offset to match default page
// if default page was unknown when the scroll controller was created
void _correctDefaultPageScroll() async {
await controller.infoStream.first;
if (_initControllerPage == null) {
_initControllerPage = controller.page;
if (_initControllerPage != 0) {
WidgetsBinding.instance.addPostFrameCallback((_) => _goToPage(_initControllerPage));
}
}
}
void _unregisterWidget() {
@ -83,39 +94,30 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
return ThumbnailTheme(
extent: extent,
child: FutureBuilder<MultiPageInfo>(
future: controller.info,
showLocation: false,
child: StreamBuilder<MultiPageInfo>(
stream: controller.infoStream,
builder: (context, snapshot) {
final multiPageInfo = snapshot.data;
if ((multiPageInfo?.pageCount ?? 0) <= 1) return SizedBox();
if (multiPageInfo.uri != mainEntry.uri) return SizedBox();
final multiPageInfo = controller.info;
final pageCount = multiPageInfo?.pageCount ?? 0;
return SizedBox(
height: extent,
child: ListView.separated(
key: ValueKey(mainEntry),
key: ValueKey(multiPageInfo),
scrollDirection: Axis.horizontal,
controller: _scrollController,
// default padding in scroll direction matches `MediaQuery.viewPadding`,
// but we already accommodate for it, so make sure horizontal padding is 0
padding: EdgeInsets.zero,
itemBuilder: (context, index) {
if (index == 0 || index == multiPageInfo.pageCount + 1) return horizontalMargin;
if (index == 0 || index == pageCount + 1) return horizontalMargin;
final page = index - 1;
final pageEntry = mainEntry.getPageEntry(multiPageInfo.getByIndex(page));
final pageEntry = multiPageInfo.getPageEntryByIndex(page);
return Stack(
children: [
GestureDetector(
onTap: () async {
_syncScroll = false;
controller.page = page;
await _scrollController.animateTo(
pageToScrollOffset(page),
duration: Durations.viewerOverlayPageScrollAnimation,
curve: Curves.easeOutCubic,
);
_syncScroll = true;
},
onTap: () => _goToPage(page),
child: DecoratedThumbnail(
entry: pageEntry,
extent: extent,
@ -139,7 +141,7 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
);
},
separatorBuilder: (context, index) => separator,
itemCount: multiPageInfo.pageCount + 2,
itemCount: pageCount + 2,
),
);
},
@ -147,6 +149,17 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
);
}
Future<void> _goToPage(int page) async {
_syncScroll = false;
controller.page = page;
await _scrollController.animateTo(
pageToScrollOffset(page),
duration: Durations.viewerOverlayPageScrollAnimation,
curve: Curves.easeOutCubic,
);
_syncScroll = true;
}
void _onScrollChange() {
if (_syncScroll) {
controller.page = scrollOffsetToPage(_scrollController.offset);

View file

@ -8,9 +8,9 @@ import 'package:aves/utils/time_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/blurred.dart';
import 'package:aves/widgets/common/fx/borders.dart';
import 'package:aves/widgets/common/video/controller.dart';
import 'package:aves/widgets/viewer/overlay/common.dart';
import 'package:aves/widgets/viewer/overlay/notifications.dart';
import 'package:aves/widgets/viewer/video/controller.dart';
import 'package:flutter/material.dart';
class VideoControlOverlay extends StatefulWidget {
@ -34,7 +34,6 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
bool _playingOnDragStart = false;
AnimationController _playPauseAnimation;
final List<StreamSubscription> _subscriptions = [];
double _seekTargetPercent;
AvesEntry get entry => widget.entry;
@ -42,9 +41,11 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
AvesVideoController get controller => widget.controller;
bool get isPlayable => controller.isPlayable;
Stream<VideoStatus> get statusStream => controller?.statusStream ?? Stream.value(VideoStatus.idle);
bool get isPlaying => controller.isPlaying;
Stream<int> get positionStream => controller?.positionStream ?? Stream.value(0);
bool get isPlaying => controller?.isPlaying ?? false;
@override
void initState() {
@ -71,8 +72,10 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
}
void _registerWidget(VideoControlOverlay widget) {
_subscriptions.add(widget.controller.statusStream.listen(_onStatusChange));
_onStatusChange(widget.controller.status);
if (widget.controller != null) {
_subscriptions.add(widget.controller.statusStream.listen(_onStatusChange));
_onStatusChange(widget.controller.status);
}
}
void _unregisterWidget(VideoControlOverlay widget) {
@ -84,10 +87,10 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
@override
Widget build(BuildContext context) {
return StreamBuilder<VideoStatus>(
stream: controller.statusStream,
stream: statusStream,
builder: (context, snapshot) {
// do not use stream snapshot because it is obsolete when switching between videos
final status = controller.status;
final status = controller?.status ?? VideoStatus.idle;
return TooltipTheme(
data: TooltipTheme.of(context).copyWith(
preferBelow: false,
@ -160,10 +163,10 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
Row(
children: [
StreamBuilder<int>(
stream: controller.positionStream,
stream: positionStream,
builder: (context, snapshot) {
// do not use stream snapshot because it is obsolete when switching between videos
final position = controller.currentPosition?.floor() ?? 0;
final position = controller?.currentPosition?.floor() ?? 0;
return Text(formatFriendlyDuration(Duration(milliseconds: position)));
}),
Spacer(),
@ -173,12 +176,15 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: StreamBuilder<int>(
stream: controller.positionStream,
stream: positionStream,
builder: (context, snapshot) {
// do not use stream snapshot because it is obsolete when switching between videos
var progress = controller.progress;
var progress = controller?.progress ?? 0.0;
if (!progress.isFinite) progress = 0.0;
return LinearProgressIndicator(value: progress);
return LinearProgressIndicator(
value: progress,
backgroundColor: Colors.grey[700],
);
}),
),
],
@ -190,33 +196,6 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
}
void _onStatusChange(VideoStatus status) {
if (status == VideoStatus.playing && _seekTargetPercent != null) {
_seekFromTarget();
}
_updatePlayPauseIcon();
}
Future<void> _togglePlayPause() async {
if (isPlaying) {
await controller.pause();
} else {
await _play();
}
}
Future<void> _play() async {
if (isPlayable) {
await controller.play();
} else {
await controller.setDataSource(entry.uri);
}
// hide overlay
await Future.delayed(Durations.iconAnimation);
ToggleOverlayNotification().dispatch(context);
}
void _updatePlayPauseIcon() {
final status = _playPauseAnimation.status;
if (isPlaying && status != AnimationStatus.forward && status != AnimationStatus.completed) {
_playPauseAnimation.forward();
@ -225,28 +204,23 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
}
}
Future<void> _togglePlayPause() async {
if (controller == null) return;
if (isPlaying) {
await controller.pause();
} else {
await controller.play();
// hide overlay
await Future.delayed(Durations.iconAnimation);
ToggleOverlayNotification().dispatch(context);
}
}
void _seekFromTap(Offset globalPosition) async {
if (controller == null) return;
final keyContext = _progressBarKey.currentContext;
final RenderBox box = keyContext.findRenderObject();
final localPosition = box.globalToLocal(globalPosition);
_seekTargetPercent = (localPosition.dx / box.size.width);
if (isPlayable) {
await _seekFromTarget();
} else {
// controller duration is not set yet, so we use the expected duration instead
final seekTargetMillis = (entry.durationMillis * _seekTargetPercent).toInt();
await controller.setDataSource(entry.uri, startMillis: seekTargetMillis);
_seekTargetPercent = null;
}
}
Future _seekFromTarget() async {
// `seekToProgress` is not safe as it can be called when the `duration` is not set yet
// so we make sure the video info is up to date first
if (controller.duration != null) {
await controller.seekToProgress(_seekTargetPercent);
_seekTargetPercent = null;
}
await controller.seekToProgress(localPosition.dx / box.size.width);
}
}

View file

@ -1,68 +1,46 @@
import 'dart:math';
import 'package:aves/model/entry.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/widgets/viewer/multipage.dart';
import 'package:aves/widgets/viewer/visual/state.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class Minimap extends StatelessWidget {
final AvesEntry mainEntry;
final AvesEntry entry;
final ValueNotifier<ViewState> viewStateNotifier;
final MultiPageController multiPageController;
final Size size;
static const defaultSize = Size(96, 96);
const Minimap({
@required this.mainEntry,
@required this.entry,
@required this.viewStateNotifier,
@required this.multiPageController,
this.size = defaultSize,
});
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: multiPageController != null
? FutureBuilder<MultiPageInfo>(
future: multiPageController.info,
builder: (context, snapshot) {
final multiPageInfo = snapshot.data;
if (multiPageInfo == null) return SizedBox.shrink();
return ValueListenableBuilder<int>(
valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) {
final pageEntry = mainEntry.getPageEntry(multiPageInfo?.getByIndex(page));
return _buildForEntrySize(pageEntry);
},
);
})
: _buildForEntrySize(mainEntry),
);
}
Widget _buildForEntrySize(AvesEntry entry) {
return ValueListenableBuilder<ViewState>(
valueListenable: viewStateNotifier,
builder: (context, viewState, child) {
final viewportSize = viewState.viewportSize;
if (viewportSize == null) return SizedBox.shrink();
return AnimatedBuilder(
animation: entry.imageChangeNotifier,
builder: (context, child) => CustomPaint(
painter: MinimapPainter(
viewportSize: viewportSize,
entrySize: entry.displaySize,
viewCenterOffset: viewState.position,
viewScale: viewState.scale,
minimapBorderColor: Colors.white30,
child: ValueListenableBuilder<ViewState>(
valueListenable: viewStateNotifier,
builder: (context, viewState, child) {
final viewportSize = viewState.viewportSize;
if (viewportSize == null) return SizedBox.shrink();
return AnimatedBuilder(
animation: entry.imageChangeNotifier,
builder: (context, child) => CustomPaint(
painter: MinimapPainter(
viewportSize: viewportSize,
entrySize: entry.displaySize,
viewCenterOffset: viewState.position,
viewScale: viewState.scale,
minimapBorderColor: Colors.white30,
),
size: size,
),
size: size,
),
);
});
);
}),
);
}
}

View file

@ -1,13 +1,14 @@
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/basic/menu_row.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/sweeper.dart';
import 'package:aves/widgets/viewer/multipage.dart';
import 'package:aves/widgets/viewer/multipage/conductor.dart';
import 'package:aves/widgets/viewer/overlay/common.dart';
import 'package:aves/widgets/viewer/overlay/minimap.dart';
import 'package:aves/widgets/viewer/visual/state.dart';
@ -17,26 +18,24 @@ import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
class ViewerTopOverlay extends StatelessWidget {
final AvesEntry entry;
final AvesEntry mainEntry;
final Animation<double> scale;
final EdgeInsets viewInsets, viewPadding;
final Function(EntryAction value) onActionSelected;
final bool canToggleFavourite;
final ValueNotifier<ViewState> viewStateNotifier;
final MultiPageController multiPageController;
static const double padding = 8;
const ViewerTopOverlay({
Key key,
@required this.entry,
@required this.mainEntry,
@required this.scale,
@required this.canToggleFavourite,
@required this.viewInsets,
@required this.viewPadding,
@required this.onActionSelected,
@required this.viewStateNotifier,
@required this.multiPageController,
}) : super(key: key);
@override
@ -49,78 +48,103 @@ class ViewerTopOverlay extends StatelessWidget {
selector: (c, mq) => mq.size.width - mq.padding.horizontal,
builder: (c, mqWidth, child) {
final availableCount = (mqWidth / (OverlayButton.getSize(context) + padding)).floor() - 2;
final quickActions = settings.viewerQuickActions.where(_canDo).take(availableCount).toList();
final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_canDo).toList();
final externalAppActions = EntryActions.externalApp.where(_canDo).toList();
final buttonRow = _TopOverlayRow(
quickActions: quickActions,
inAppActions: inAppActions,
externalAppActions: externalAppActions,
scale: scale,
entry: entry,
onActionSelected: onActionSelected,
);
return settings.showOverlayMinimap && viewStateNotifier != null
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buttonRow,
SizedBox(height: 8),
FadeTransition(
opacity: scale,
child: Minimap(
mainEntry: entry,
viewStateNotifier: viewStateNotifier,
multiPageController: multiPageController,
),
)
],
)
: buttonRow;
Widget child;
if (mainEntry.isMultiPage) {
final multiPageController = context.read<MultiPageConductor>().getController(mainEntry);
if (multiPageController != null) {
child = StreamBuilder<MultiPageInfo>(
stream: multiPageController.infoStream,
builder: (context, snapshot) {
final multiPageInfo = multiPageController.info;
return ValueListenableBuilder<int>(
valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) {
return _buildOverlay(availableCount, mainEntry, pageEntry: multiPageInfo?.getPageEntryByIndex(page));
},
);
},
);
}
}
return child ??= _buildOverlay(availableCount, mainEntry);
},
),
),
);
}
bool _canDo(EntryAction action) {
switch (action) {
case EntryAction.toggleFavourite:
return canToggleFavourite;
case EntryAction.delete:
case EntryAction.rename:
return entry.canEdit;
case EntryAction.rotateCCW:
case EntryAction.rotateCW:
case EntryAction.flip:
return entry.canRotateAndFlip;
case EntryAction.export:
case EntryAction.print:
return !entry.isVideo;
case EntryAction.openMap:
return entry.hasGps;
case EntryAction.viewSource:
return entry.isSvg;
case EntryAction.share:
case EntryAction.info:
case EntryAction.open:
case EntryAction.edit:
case EntryAction.setAs:
return true;
case EntryAction.debug:
return kDebugMode;
Widget _buildOverlay(int availableCount, AvesEntry mainEntry, {AvesEntry pageEntry}) {
pageEntry ??= mainEntry;
bool _canDo(EntryAction action) {
final targetEntry = EntryActions.pageActions.contains(action) ? pageEntry : mainEntry;
switch (action) {
case EntryAction.toggleFavourite:
return canToggleFavourite;
case EntryAction.delete:
case EntryAction.rename:
return targetEntry.canEdit;
case EntryAction.rotateCCW:
case EntryAction.rotateCW:
case EntryAction.flip:
return targetEntry.canRotateAndFlip;
case EntryAction.export:
case EntryAction.print:
return !targetEntry.isVideo;
case EntryAction.openMap:
return targetEntry.hasGps;
case EntryAction.viewSource:
return targetEntry.isSvg;
case EntryAction.share:
case EntryAction.info:
case EntryAction.open:
case EntryAction.edit:
case EntryAction.setAs:
return true;
case EntryAction.debug:
return kDebugMode;
}
return false;
}
return false;
final quickActions = settings.viewerQuickActions.where(_canDo).take(availableCount).toList();
final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_canDo).toList();
final externalAppActions = EntryActions.externalApp.where(_canDo).toList();
final buttonRow = _TopOverlayRow(
quickActions: quickActions,
inAppActions: inAppActions,
externalAppActions: externalAppActions,
scale: scale,
mainEntry: mainEntry,
pageEntry: pageEntry,
onActionSelected: onActionSelected,
);
return settings.showOverlayMinimap && viewStateNotifier != null
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buttonRow,
SizedBox(height: 8),
FadeTransition(
opacity: scale,
child: Minimap(
entry: pageEntry,
viewStateNotifier: viewStateNotifier,
),
)
],
)
: buttonRow;
}
}
class _TopOverlayRow extends StatelessWidget {
final List<EntryAction> quickActions;
final List<EntryAction> inAppActions;
final List<EntryAction> externalAppActions;
final List<EntryAction> quickActions, inAppActions, externalAppActions;
final Animation<double> scale;
final AvesEntry entry;
final AvesEntry mainEntry, pageEntry;
final Function(EntryAction value) onActionSelected;
const _TopOverlayRow({
@ -129,7 +153,8 @@ class _TopOverlayRow extends StatelessWidget {
@required this.inAppActions,
@required this.externalAppActions,
@required this.scale,
@required this.entry,
@required this.mainEntry,
@required this.pageEntry,
@required this.onActionSelected,
}) : super(key: key);
@ -151,7 +176,7 @@ class _TopOverlayRow extends StatelessWidget {
key: Key('entry-menu-button'),
itemBuilder: (context) => [
...inAppActions.map((action) => _buildPopupMenuItem(context, action)),
if (entry.canRotateAndFlip) _buildRotateAndFlipMenuItems(context),
if (pageEntry.canRotateAndFlip) _buildRotateAndFlipMenuItems(context),
PopupMenuDivider(),
...externalAppActions.map((action) => _buildPopupMenuItem(context, action)),
if (kDebugMode) ...[
@ -175,7 +200,7 @@ class _TopOverlayRow extends StatelessWidget {
switch (action) {
case EntryAction.toggleFavourite:
child = _FavouriteToggler(
entry: entry,
entry: mainEntry,
onPressed: onPressed,
);
break;
@ -219,7 +244,7 @@ class _TopOverlayRow extends StatelessWidget {
// in app actions
case EntryAction.toggleFavourite:
child = _FavouriteToggler(
entry: entry,
entry: mainEntry,
isMenuItem: true,
);
break;

Some files were not shown because too many files have changed in this diff Show more