export: to jpeg, no metadata

This commit is contained in:
Thibault Deckers 2021-01-24 14:15:46 +09:00
parent b0cccd7d2d
commit c4fdd38850
29 changed files with 592 additions and 273 deletions

View file

@ -14,7 +14,7 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.decoder.MultiTrackImage
import deckers.thibault.aves.decoder.TiffThumbnail
import deckers.thibault.aves.decoder.TiffImage
import deckers.thibault.aves.decoder.VideoThumbnail
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.BitmapUtils.getBytes
@ -126,7 +126,7 @@ class ThumbnailFetcher internal constructor(
.submit(width, height)
} else {
val model: Any = when {
tiffFetch -> TiffThumbnail(context, uri, pageId ?: 0)
tiffFetch -> TiffImage(context, uri, pageId)
multiTrackFetch -> MultiTrackImage(context, uri, pageId)
else -> uri
}

View file

@ -10,6 +10,7 @@ import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.decoder.MultiTrackImage
import deckers.thibault.aves.decoder.TiffImage
import deckers.thibault.aves.decoder.VideoThumbnail
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.BitmapUtils.getBytes
@ -25,7 +26,6 @@ import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import java.io.IOException
import java.io.InputStream
@ -96,8 +96,6 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
if (isVideo(mimeType)) {
streamVideoByGlide(uri)
} else if (mimeType == MimeTypes.TIFF) {
streamTiffImage(uri, pageId)
} else if (!isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) {
// decode exotic format on platform side, then encode it in portable format for Flutter
streamImageByGlide(uri, pageId, mimeType, rotationDegrees, isFlipped)
@ -119,6 +117,8 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
private fun streamImageByGlide(uri: Uri, pageId: Int?, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) {
val model: Any = if (isHeifLike(mimeType) && pageId != null) {
MultiTrackImage(activity, uri, pageId)
} else if (mimeType == MimeTypes.TIFF) {
TiffImage(activity, uri, pageId)
} else {
uri
}
@ -165,28 +165,6 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
}
}
private fun streamTiffImage(uri: Uri, page: Int?) {
val resolver = activity.contentResolver
try {
val fd = resolver.openFileDescriptor(uri, "r")?.detachFd()
if (fd == null) {
error("streamImage-tiff-fd", "failed to get file descriptor for uri=$uri", null)
return
}
val options = TiffBitmapFactory.Options().apply {
inDirectoryNumber = page ?: 0
}
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
if (bitmap != null) {
success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
} else {
error("streamImage-tiff-null", "failed to get tiff image (dir=$page) from uri=$uri", null)
}
} catch (e: Exception) {
error("streamImage-tiff-exception", "failed to get image from uri=$uri", toErrorDetails(e))
}
}
private fun toErrorDetails(e: Exception): String? {
val errorDetails = e.message
return if (errorDetails?.isNotEmpty() == true) {

View file

@ -42,6 +42,7 @@ class ImageOpStreamHandler(private val context: Context, private val arguments:
when (op) {
"delete" -> GlobalScope.launch(Dispatchers.IO) { delete() }
"export" -> GlobalScope.launch(Dispatchers.IO) { export() }
"move" -> GlobalScope.launch(Dispatchers.IO) { move() }
else -> endOfStream()
}
@ -80,36 +81,6 @@ class ImageOpStreamHandler(private val context: Context, private val arguments:
}
}
private suspend fun move() {
if (arguments !is Map<*, *> || entryMapList.isEmpty()) {
endOfStream()
return
}
// assume same provider for all entries
val firstEntry = entryMapList.first()
val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(it) }
if (provider == null) {
error("move-provider", "failed to find provider for entry=$firstEntry", null)
return
}
val copy = arguments["copy"] as Boolean?
var destinationDir = arguments["destinationPath"] as String?
if (copy == null || destinationDir == null) {
error("move-args", "failed because of missing arguments", null)
return
}
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
val entries = entryMapList.map(::AvesEntry)
provider.moveMultiple(context, 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)
})
endOfStream()
}
private suspend fun delete() {
if (entryMapList.isEmpty()) {
endOfStream()
@ -144,6 +115,66 @@ class ImageOpStreamHandler(private val context: Context, private val arguments:
endOfStream()
}
private suspend fun export() {
if (arguments !is Map<*, *> || entryMapList.isEmpty()) {
endOfStream()
return
}
var destinationDir = arguments["destinationPath"] as String?
val mimeType = arguments["mimeType"] as String?
if (destinationDir == null || mimeType == null) {
error("export-args", "failed because of missing arguments", null)
return
}
// assume same provider for all entries
val firstEntry = entryMapList.first()
val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(it) }
if (provider == null) {
error("export-provider", "failed to find provider for entry=$firstEntry", null)
return
}
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
val entries = entryMapList.map(::AvesEntry)
provider.exportMultiple(context, 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)
})
endOfStream()
}
private suspend fun move() {
if (arguments !is Map<*, *> || entryMapList.isEmpty()) {
endOfStream()
return
}
val copy = arguments["copy"] as Boolean?
var destinationDir = arguments["destinationPath"] as String?
if (copy == null || destinationDir == null) {
error("move-args", "failed because of missing arguments", null)
return
}
// assume same provider for all entries
val firstEntry = entryMapList.first()
val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(it) }
if (provider == null) {
error("move-provider", "failed to find provider for entry=$firstEntry", null)
return
}
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
val entries = entryMapList.map(::AvesEntry)
provider.moveMultiple(context, 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)
})
endOfStream()
}
companion object {
private val LOG_TAG = LogUtils.createTag(ImageOpStreamHandler::class.java)
const val CHANNEL = "deckers.thibault/aves/imageopstream"

View file

@ -1,6 +1,7 @@
package deckers.thibault.aves.decoder
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import com.bumptech.glide.Glide
@ -17,35 +18,33 @@ import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.bumptech.glide.module.LibraryGlideModule
import com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.metadata.MultiTrackMedia
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import java.io.InputStream
@GlideModule
class MultiTrackImageGlideModule : LibraryGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
registry.append(MultiTrackImage::class.java, InputStream::class.java, MultiTrackThumbnailLoader.Factory())
registry.append(MultiTrackImage::class.java, Bitmap::class.java, MultiTrackThumbnailLoader.Factory())
}
}
class MultiTrackImage(val context: Context, val uri: Uri, val trackId: Int?)
internal class MultiTrackThumbnailLoader : ModelLoader<MultiTrackImage, InputStream> {
override fun buildLoadData(model: MultiTrackImage, width: Int, height: Int, options: Options): ModelLoader.LoadData<InputStream> {
internal class MultiTrackThumbnailLoader : ModelLoader<MultiTrackImage, Bitmap> {
override fun buildLoadData(model: MultiTrackImage, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> {
return ModelLoader.LoadData(ObjectKey(model.uri), MultiTrackImageFetcher(model, width, height))
}
override fun handles(model: MultiTrackImage): Boolean = true
internal class Factory : ModelLoaderFactory<MultiTrackImage, InputStream> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<MultiTrackImage, InputStream> = MultiTrackThumbnailLoader()
internal class Factory : ModelLoaderFactory<MultiTrackImage, Bitmap> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<MultiTrackImage, Bitmap> = MultiTrackThumbnailLoader()
override fun teardown() {}
}
}
internal class MultiTrackImageFetcher(val model: MultiTrackImage, val width: Int, val height: Int) : DataFetcher<InputStream> {
override fun loadData(priority: Priority, callback: DataCallback<in InputStream>) {
internal class MultiTrackImageFetcher(val model: MultiTrackImage, val width: Int, val height: Int) : DataFetcher<Bitmap> {
override fun loadData(priority: Priority, callback: DataCallback<in Bitmap>) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
callback.onLoadFailed(Exception("unsupported Android version"))
return
@ -59,17 +58,16 @@ internal class MultiTrackImageFetcher(val model: MultiTrackImage, val width: Int
if (bitmap == null) {
callback.onLoadFailed(Exception("null bitmap"))
} else {
callback.onDataReady(bitmap.getBytes()?.inputStream())
callback.onDataReady(bitmap)
}
}
// already cleaned up in loadData and ByteArrayInputStream will be GC'd
override fun cleanup() {}
// cannot cancel
override fun cancel() {}
override fun getDataClass(): Class<InputStream> = InputStream::class.java
override fun getDataClass(): Class<Bitmap> = Bitmap::class.java
override fun getDataSource(): DataSource = DataSource.LOCAL
}

View file

@ -0,0 +1,99 @@
package deckers.thibault.aves.decoder
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import com.bumptech.glide.Glide
import com.bumptech.glide.Priority
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.data.DataFetcher
import com.bumptech.glide.load.data.DataFetcher.DataCallback
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.bumptech.glide.module.LibraryGlideModule
import com.bumptech.glide.signature.ObjectKey
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
@GlideModule
class TiffGlideModule : LibraryGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
registry.append(TiffImage::class.java, Bitmap::class.java, TiffLoader.Factory())
}
}
class TiffImage(val context: Context, val uri: Uri, val page: Int?)
internal class TiffLoader : ModelLoader<TiffImage, Bitmap> {
override fun buildLoadData(model: TiffImage, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> {
return ModelLoader.LoadData(ObjectKey(model.uri), TiffFetcher(model, width, height))
}
override fun handles(model: TiffImage): Boolean = true
internal class Factory : ModelLoaderFactory<TiffImage, Bitmap> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<TiffImage, Bitmap> = TiffLoader()
override fun teardown() {}
}
}
internal class TiffFetcher(val model: TiffImage, val width: Int, val height: Int) : DataFetcher<Bitmap> {
override fun loadData(priority: Priority, callback: DataCallback<in Bitmap>) {
val context = model.context
val uri = model.uri
val page = model.page ?: 0
var sampleSize = 1
if (width > 0 && height > 0) {
// determine sample size
val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
if (fd == null) {
callback.onLoadFailed(Exception("null file descriptor"))
return
}
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true
inDirectoryNumber = page
}
TiffBitmapFactory.decodeFileDescriptor(fd, options)
val imageWidth = options.outWidth
val imageHeight = options.outHeight
if (imageHeight > height || imageWidth > width) {
while (imageHeight / (sampleSize * 2) > height && imageWidth / (sampleSize * 2) > width) {
sampleSize *= 2
}
}
}
// decode
val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
if (fd == null) {
callback.onLoadFailed(Exception("null file descriptor"))
return
}
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = false
inDirectoryNumber = page
inSampleSize = sampleSize
}
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
if (bitmap == null) {
callback.onLoadFailed(Exception("null bitmap"))
} else {
callback.onDataReady(bitmap)
}
}
override fun cleanup() {}
// cannot cancel
override fun cancel() {}
override fun getDataClass(): Class<Bitmap> = Bitmap::class.java
override fun getDataSource(): DataSource = DataSource.LOCAL
}

View file

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

View file

@ -6,8 +6,10 @@ import deckers.thibault.aves.model.provider.FieldMap
class AvesEntry(map: FieldMap) {
val uri: Uri = Uri.parse(map["uri"] as String) // content or file URI
val path = map["path"] as String? // best effort to get local path
val pageId = map["pageId"] as Int? // null means the main entry
val mimeType = map["mimeType"] as String
val width = map["width"] as Int
val height = map["height"] as Int
val rotationDegrees = map["rotationDegrees"] as Int
val isFlipped = map["isFlipped"] as Boolean
}

View file

@ -36,6 +36,10 @@ abstract class ImageProvider {
callback.onFailure(UnsupportedOperationException())
}
open suspend fun exportMultiple(context: Context, mimeType: String, destinationDir: String, entries: List<AvesEntry>, callback: ImageOpCallback) {
callback.onFailure(UnsupportedOperationException())
}
suspend fun rename(context: Context, oldPath: String, oldMediaUri: Uri, mimeType: String, newFilename: String, callback: ImageOpCallback) {
val oldFile = File(oldPath)
val newFile = File(oldFile.parent, newFilename)

View file

@ -3,13 +3,21 @@ package deckers.thibault.aves.model.provider
import android.annotation.SuppressLint
import android.content.ContentUris
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.Log
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy
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.model.AvesEntry
import deckers.thibault.aves.model.SourceEntry
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isImage
@ -311,6 +319,145 @@ class MediaStoreImageProvider : ImageProvider() {
}
}
override suspend fun exportMultiple(
context: Context,
mimeType: String,
destinationDir: String,
entries: List<AvesEntry>,
callback: ImageOpCallback,
) {
val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir)
if (destinationDirDocFile == null) {
callback.onFailure(Exception("failed to create directory at path=$destinationDir"))
return
}
for (entry in entries) {
val sourceUri = entry.uri
val sourcePath = entry.path
val pageId = entry.pageId
val result = hashMapOf<String, Any?>(
"uri" to sourceUri.toString(),
"pageId" to pageId,
"success" to false,
)
if (sourcePath != null) {
try {
val newFields = exportSingleByTreeDocAndScan(
context = context,
sourceEntry = entry,
destinationDir = destinationDir,
destinationDirDocFile = destinationDirDocFile,
exportMimeType = mimeType,
)
result["newFields"] = newFields
result["success"] = true
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to export to destinationDir=$destinationDir entry with sourcePath=$sourcePath pageId=$pageId", e)
}
}
callback.onSuccess(result)
}
}
private suspend fun exportSingleByTreeDocAndScan(
context: Context,
sourceEntry: AvesEntry,
destinationDir: String,
destinationDirDocFile: DocumentFileCompat,
exportMimeType: String,
): FieldMap {
val sourceMimeType = sourceEntry.mimeType
val sourcePath = sourceEntry.path ?: throw Exception("source path is missing")
val sourceFile = File(sourcePath)
val pageId = sourceEntry.pageId
val sourceFileName = sourceFile.name
var desiredNameWithoutExtension = sourceFileName.replaceFirst("[.][^.]+$".toRegex(), "")
if (pageId != null) {
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")
}
if (File(destinationDir, desiredFileName).exists()) {
throw Exception("file with name=$desiredFileName already exists in destination directory")
}
// the file created from a `TreeDocumentFile` is also a `TreeDocumentFile`
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
// through a document URI, not a tree URI
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
@Suppress("BlockingMethodInNonBlockingContext")
val destinationTreeFile = destinationDirDocFile.createFile(exportMimeType, desiredNameWithoutExtension)
val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri)
val sourceUri = sourceEntry.uri
val model: Any = if (MimeTypes.isHeifLike(sourceMimeType) && pageId != null) {
MultiTrackImage(context, sourceUri, pageId)
} else if (sourceMimeType == MimeTypes.TIFF) {
TiffImage(context, sourceUri, pageId)
} 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)
}
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
}
else -> throw Exception("unsupported export MIME type=$exportMimeType")
}
@Suppress("BlockingMethodInNonBlockingContext")
destinationDocFile.openOutputStream().use {
bitmap.compress(format, quality, it)
}
} finally {
Glide.with(context).clear(target)
}
val fileName = destinationDocFile.name
val destinationFullPath = destinationDir + fileName
return scanNewPath(context, destinationFullPath, exportMimeType)
}
companion object {
private val LOG_TAG = LogUtils.createTag(MediaStoreImageProvider::class.java)

View file

@ -26,7 +26,7 @@ object BitmapUtils {
} catch (e: IllegalStateException) {
Log.e(LOG_TAG, "failed to get bytes from bitmap", e)
}
return null;
return null
}
fun applyExifOrientation(context: Context, bitmap: Bitmap?, rotationDegrees: Int?, isFlipped: Boolean?): Bitmap? {

View file

@ -11,8 +11,8 @@ object MimeTypes {
const val HEIC = "image/heic"
private const val HEIF = "image/heif"
private const val ICO = "image/x-icon"
private const val JPEG = "image/jpeg"
private const val PNG = "image/png"
const val JPEG = "image/jpeg"
const val PNG = "image/png"
const val TIFF = "image/tiff"
private const val WBMP = "image/vnd.wap.wbmp"
const val WEBP = "image/webp"

View file

@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart';
enum EntryAction {
delete,
edit,
export,
flip,
info,
open,
@ -31,6 +32,7 @@ class EntryActions {
EntryAction.share,
EntryAction.delete,
EntryAction.rename,
EntryAction.export,
EntryAction.print,
EntryAction.viewSource,
];
@ -52,6 +54,8 @@ extension ExtraEntryAction on EntryAction {
return null;
case EntryAction.delete:
return 'Delete';
case EntryAction.export:
return 'Export';
case EntryAction.info:
return 'Info';
case EntryAction.rename:
@ -91,6 +95,8 @@ extension ExtraEntryAction on EntryAction {
return null;
case EntryAction.delete:
return AIcons.delete;
case EntryAction.export:
return AIcons.export;
case EntryAction.info:
return AIcons.info;
case EntryAction.rename:

View file

@ -0,0 +1 @@
enum MoveType { copy, move, export }

View file

@ -96,13 +96,13 @@ class AvesEntry {
return copied;
}
AvesEntry getPageEntry(SinglePageInfo pageInfo) {
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 = pageInfo.isDefault ? null : pageInfo.pageId;
final pageId = eraseDefaultPageId && pageInfo.isDefault ? null : pageInfo.pageId;
return AvesEntry(
uri: uri,
@ -254,8 +254,6 @@ class AvesEntry {
bool get canEdit => path != null;
bool get canPrint => !isVideo;
bool get canRotateAndFlip => canEdit && canEditExif;
// support for writing EXIF
@ -637,9 +635,9 @@ class AvesEntry {
// compare by:
// 1) date descending
// 2) name ascending
// 2) name descending
static int compareByDate(AvesEntry a, AvesEntry b) {
final c = (b.bestDate ?? _epoch).compareTo(a.bestDate ?? _epoch);
return c != 0 ? c : compareByName(a, b);
return c != 0 ? c : -compareByName(a, b);
}
}

View file

@ -1,15 +1,15 @@
import 'dart:async';
import 'package:aves/model/entry.dart';
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/tag.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/image_op_events.dart';
import 'package:event_bus/event_bus.dart';
import 'package:flutter/foundation.dart';

View file

@ -5,10 +5,10 @@ import 'dart:typed_data';
import 'package:aves/model/entry.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/service_policy.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:streams_channel/streams_channel.dart';
class ImageFileService {
@ -22,6 +22,7 @@ class ImageFileService {
return {
'uri': entry.uri,
'path': entry.path,
'pageId': entry.pageId,
'mimeType': entry.mimeType,
'width': entry.width,
'height': entry.height,
@ -236,7 +237,11 @@ class ImageFileService {
}
}
static Stream<MoveOpEvent> move(Iterable<AvesEntry> entries, {@required bool copy, @required String destinationAlbum}) {
static Stream<MoveOpEvent> move(
Iterable<AvesEntry> entries, {
@required bool copy,
@required String destinationAlbum,
}) {
try {
return opChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'move',
@ -250,6 +255,24 @@ class ImageFileService {
}
}
static Stream<ExportOpEvent> export(
Iterable<AvesEntry> entries, {
String mimeType = MimeTypes.jpeg,
@required String destinationAlbum,
}) {
try {
return opChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'export',
'entries': entries.map(_toPlatformEntryMap).toList(),
'mimeType': mimeType,
'destinationPath': destinationAlbum,
}).map((event) => ExportOpEvent.fromMap(event));
} on PlatformException catch (e) {
debugPrint('export failed with code=${e.code}, exception=${e.message}, details=${e.details}');
return Stream.error(e);
}
}
static Future<Map> rename(AvesEntry entry, String newName) async {
try {
// return map with: 'contentId' 'path' 'title' 'uri' (all optional)
@ -292,57 +315,6 @@ class ImageFileService {
}
}
@immutable
class ImageOpEvent {
final bool success;
final String uri;
const ImageOpEvent({
this.success,
this.uri,
});
factory ImageOpEvent.fromMap(Map map) {
return ImageOpEvent(
success: map['success'] ?? false,
uri: map['uri'],
);
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is ImageOpEvent && other.success == success && other.uri == uri;
}
@override
int get hashCode => hashValues(success, uri);
@override
String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri}';
}
class MoveOpEvent extends ImageOpEvent {
final Map newFields;
const MoveOpEvent({bool success, String uri, this.newFields})
: super(
success: success,
uri: uri,
);
factory MoveOpEvent.fromMap(Map map) {
return MoveOpEvent(
success: map['success'] ?? false,
uri: map['uri'],
newFields: map['newFields'],
);
}
@override
String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri, newFields=$newFields}';
}
// cf flutter/foundation `consolidateHttpClientResponseBytes`
typedef BytesReceivedCallback = void Function(int cumulative, int total);

View file

@ -0,0 +1,85 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
@immutable
class ImageOpEvent {
final bool success;
final String uri;
const ImageOpEvent({
this.success,
this.uri,
});
factory ImageOpEvent.fromMap(Map map) {
return ImageOpEvent(
success: map['success'] ?? false,
uri: map['uri'],
);
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is ImageOpEvent && other.success == success && other.uri == uri;
}
@override
int get hashCode => hashValues(success, uri);
@override
String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri}';
}
class MoveOpEvent extends ImageOpEvent {
final Map newFields;
const MoveOpEvent({bool success, String uri, this.newFields})
: super(
success: success,
uri: uri,
);
factory MoveOpEvent.fromMap(Map map) {
return MoveOpEvent(
success: map['success'] ?? false,
uri: map['uri'],
newFields: map['newFields'],
);
}
@override
String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri, newFields=$newFields}';
}
class ExportOpEvent extends MoveOpEvent {
final int pageId;
const ExportOpEvent({bool success, String uri, this.pageId, Map newFields})
: super(
success: success,
uri: uri,
newFields: newFields,
);
factory ExportOpEvent.fromMap(Map map) {
return ExportOpEvent(
success: map['success'] ?? false,
uri: map['uri'],
pageId: map['pageId'],
newFields: map['newFields'],
);
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is ExportOpEvent && other.success == success && other.uri == uri && other.pageId == pageId;
}
@override
int get hashCode => hashValues(success, uri, pageId);
@override
String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri, pageId=$pageId, newFields=$newFields}';
}

View file

@ -31,6 +31,7 @@ class AIcons {
static const IconData createAlbum = Icons.add_circle_outline;
static const IconData debug = Icons.whatshot_outlined;
static const IconData delete = Icons.delete_outlined;
static const IconData export = Icons.save_alt_outlined;
static const IconData flip = Icons.flip_outlined;
static const IconData favourite = Icons.favorite_border;
static const IconData favouriteActive = Icons.favorite;
@ -38,7 +39,7 @@ class AIcons {
static const IconData group = Icons.group_work_outlined;
static const IconData info = Icons.info_outlined;
static const IconData layers = Icons.layers_outlined;
static const IconData openInNew = Icons.open_in_new_outlined;
static const IconData openOutside = Icons.open_in_new_outlined;
static const IconData pin = Icons.push_pin_outlined;
static const IconData print = Icons.print_outlined;
static const IconData refresh = Icons.refresh_outlined;

View file

@ -2,11 +2,13 @@ import 'dart:async';
import 'package:aves/model/actions/collection_actions.dart';
import 'package:aves/model/actions/entry_actions.dart';
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/services/android_app_service.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/image_op_events.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
import 'package:aves/widgets/common/action_mixins/size_aware.dart';
@ -46,10 +48,10 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
void onCollectionActionSelected(BuildContext context, CollectionAction action) {
switch (action) {
case CollectionAction.copy:
_moveSelection(context, copy: true);
_moveSelection(context, moveType: MoveType.copy);
break;
case CollectionAction.move:
_moveSelection(context, copy: false);
_moveSelection(context, moveType: MoveType.move);
break;
case CollectionAction.refreshMetadata:
source.refreshMetadata(selection);
@ -61,12 +63,12 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
}
}
Future<void> _moveSelection(BuildContext context, {@required bool copy}) async {
Future<void> _moveSelection(BuildContext context, {@required MoveType moveType}) async {
final destinationAlbum = await Navigator.push(
context,
MaterialPageRoute<String>(
settings: RouteSettings(name: AlbumPickPage.routeName),
builder: (context) => AlbumPickPage(source: source, copy: copy),
builder: (context) => AlbumPickPage(source: source, moveType: moveType),
),
);
if (destinationAlbum == null || destinationAlbum.isEmpty) return;
@ -74,8 +76,9 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
if (!await checkStoragePermission(context, selection)) return;
if (!await checkFreeSpaceForMove(context, selection, destinationAlbum, copy)) return;
if (!await checkFreeSpaceForMove(context, selection, destinationAlbum, moveType)) return;
final copy = moveType == MoveType.copy;
showOpReport<MoveOpEvent>(
context: context,
selection: selection,

View file

@ -1,5 +1,5 @@
import 'package:aves/model/entry.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/image_op_events.dart';
import 'package:aves/theme/durations.dart';
import 'package:flushbar/flushbar.dart';
import 'package:flutter/material.dart';

View file

@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:math';
import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/services/android_file_service.dart';
import 'package:aves/utils/android_file_utils.dart';
@ -11,21 +12,30 @@ import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
mixin SizeAwareMixin {
Future<bool> checkFreeSpaceForMove(BuildContext context, Set<AvesEntry> selection, String destinationAlbum, bool copy) async {
Future<bool> checkFreeSpaceForMove(
BuildContext context,
Set<AvesEntry> selection,
String destinationAlbum,
MoveType moveType,
) async {
final destinationVolume = androidFileUtils.getStorageVolume(destinationAlbum);
final free = await AndroidFileService.getFreeSpace(destinationVolume);
int needed;
int sumSize(sum, entry) => sum + entry.sizeBytes;
if (copy) {
needed = selection.fold(0, sumSize);
} else {
// when moving, we only need space for the entries that are not already on the destination volume
final byVolume = groupBy<AvesEntry, StorageVolume>(selection, (entry) => androidFileUtils.getStorageVolume(entry.path));
final otherVolumes = byVolume.keys.where((volume) => volume != destinationVolume);
final fromOtherVolumes = otherVolumes.fold<int>(0, (sum, volume) => sum + byVolume[volume].fold(0, sumSize));
// and we need at least as much space as the largest entry because individual entries are copied then deleted
final largestSingle = selection.fold<int>(0, (largest, entry) => max(largest, entry.sizeBytes));
needed = max(fromOtherVolumes, largestSingle);
switch (moveType) {
case MoveType.copy:
case MoveType.export:
needed = selection.fold(0, sumSize);
break;
case MoveType.move:
// when moving, we only need space for the entries that are not already on the destination volume
final byVolume = groupBy<AvesEntry, StorageVolume>(selection, (entry) => androidFileUtils.getStorageVolume(entry.path));
final otherVolumes = byVolume.keys.where((volume) => volume != destinationVolume);
final fromOtherVolumes = otherVolumes.fold<int>(0, (sum, volume) => sum + byVolume[volume].fold(0, sumSize));
// and we need at least as much space as the largest entry because individual entries are copied then deleted
final largestSingle = selection.fold<int>(0, (largest, entry) => max(largest, entry.sizeBytes));
needed = max(fromOtherVolumes, largestSingle);
break;
}
final hasEnoughSpace = needed < free;

View file

@ -44,7 +44,7 @@ class LinkChip extends StatelessWidget {
SizedBox(width: 8),
Builder(
builder: (context) => Icon(
AIcons.openInNew,
AIcons.openOutside,
size: DefaultTextStyle.of(context).style.fontSize,
color: color,
),

View file

@ -1,4 +1,5 @@
import 'package:aves/model/actions/chip_actions.dart';
import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
@ -19,11 +20,11 @@ class AlbumPickPage extends StatefulWidget {
static const routeName = '/album_pick';
final CollectionSource source;
final bool copy;
final MoveType moveType;
const AlbumPickPage({
@required this.source,
@required this.copy,
@required this.moveType,
});
@override
@ -38,7 +39,7 @@ class _AlbumPickPageState extends State<AlbumPickPage> {
@override
Widget build(BuildContext context) {
Widget appBar = AlbumPickAppBar(
copy: widget.copy,
moveType: widget.moveType,
actionDelegate: AlbumChipSetActionDelegate(source: source),
queryNotifier: _queryNotifier,
);
@ -71,23 +72,36 @@ class _AlbumPickPageState extends State<AlbumPickPage> {
}
class AlbumPickAppBar extends StatelessWidget {
final bool copy;
final MoveType moveType;
final AlbumChipSetActionDelegate actionDelegate;
final ValueNotifier<String> queryNotifier;
static const preferredHeight = kToolbarHeight + AlbumFilterBar.preferredHeight;
const AlbumPickAppBar({
@required this.copy,
@required this.moveType,
@required this.actionDelegate,
@required this.queryNotifier,
});
@override
Widget build(BuildContext context) {
String title() {
switch (moveType) {
case MoveType.copy:
return 'Copy to Album';
case MoveType.export:
return 'Export to Album';
case MoveType.move:
return 'Move to Album';
default:
return null;
}
}
return SliverAppBar(
leading: BackButton(),
title: Text(copy ? 'Copy to Album' : 'Move to Album'),
title: Text(title()),
bottom: AlbumFilterBar(
filterNotifier: queryNotifier,
),

View file

@ -1,9 +1,11 @@
import 'package:aves/model/actions/chip_actions.dart';
import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/image_op_events.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
import 'package:aves/widgets/common/action_mixins/size_aware.dart';
@ -109,7 +111,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
final selection = source.rawEntries.where(filter.filter).toSet();
final destinationAlbum = path.join(path.dirname(album), newName);
if (!await checkFreeSpaceForMove(context, selection, destinationAlbum, false)) return;
if (!await checkFreeSpaceForMove(context, selection, destinationAlbum, MoveType.move)) return;
showOpReport<MoveOpEvent>(
context: context,

View file

@ -100,7 +100,6 @@ class ViewerDebugPage extends StatelessWidget {
'is360': '${entry.is360}',
'canEdit': '${entry.canEdit}',
'canEditExif': '${entry.canEditExif}',
'canPrint': '${entry.canPrint}',
'canRotateAndFlip': '${entry.canRotateAndFlip}',
'xmpSubjects': '${entry.xmpSubjects}',
}),

View file

@ -1,23 +1,29 @@
import 'dart:convert';
import 'package:aves/model/actions/entry_actions.dart';
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/services/android_app_service.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
import 'package:aves/widgets/common/action_mixins/size_aware.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/rename_entry_dialog.dart';
import 'package:aves/widgets/filter_grids/album_pick.dart';
import 'package:aves/widgets/viewer/debug_page.dart';
import 'package:aves/widgets/viewer/printer.dart';
import 'package:aves/widgets/viewer/source_viewer_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:pedantic/pedantic.dart';
class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
final CollectionLens collection;
final VoidCallback showInfo;
@ -36,6 +42,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
case EntryAction.delete:
_showDeleteDialog(context, entry);
break;
case EntryAction.export:
_showExportDialog(context, entry);
break;
case EntryAction.info:
showInfo();
break;
@ -140,6 +149,62 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
}
}
Future<void> _showExportDialog(BuildContext context, AvesEntry entry) async {
String destinationAlbum;
if (hasCollection) {
final source = collection.source;
destinationAlbum = await Navigator.push(
context,
MaterialPageRoute<String>(
settings: RouteSettings(name: AlbumPickPage.routeName),
builder: (context) => AlbumPickPage(source: source, moveType: MoveType.export),
),
);
} else {
destinationAlbum = entry.directory;
}
if (destinationAlbum == null || destinationAlbum.isEmpty) return;
if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return;
if (!await checkStoragePermission(context, {entry})) return;
if (!await checkFreeSpaceForMove(context, {entry}, destinationAlbum, MoveType.export)) return;
final selection = <AvesEntry>{};
if (entry.isMultipage) {
final multiPageInfo = await MetadataService.getMultiPageInfo(entry);
if (multiPageInfo.pageCount > 1) {
for (final page in multiPageInfo.pages) {
final pageEntry = entry.getPageEntry(page, eraseDefaultPageId: false);
selection.add(pageEntry);
}
}
} else {
selection.add(entry);
}
showOpReport<ExportOpEvent>(
context: context,
selection: selection,
opStream: ImageFileService.export(selection, destinationAlbum: destinationAlbum),
onDone: (processed) {
final movedOps = processed.where((e) => e.success);
final movedCount = movedOps.length;
final selectionCount = selection.length;
if (movedCount < selectionCount) {
final count = selectionCount - movedCount;
showFeedback(context, 'Failed to export ${Intl.plural(count, one: '$count page', other: '$count pages')}');
} else {
showFeedback(context, 'Done!');
}
if (hasCollection) {
collection.source.refresh();
}
},
);
}
Future<void> _showRenameDialog(BuildContext context, AvesEntry entry) async {
final newName = await showDialog<String>(
context: context,

View file

@ -63,7 +63,7 @@ class MapButtonPanel extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
MapOverlayButton(
icon: AIcons.openInNew,
icon: AIcons.openOutside,
onPressed: () => AndroidAppService.openMap(geoUri).then((success) {
if (!success) showNoMatchingAppDialog(context);
}),

View file

@ -1,8 +1,8 @@
import 'dart:math';
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
@ -111,8 +111,9 @@ class ViewerTopOverlay extends StatelessWidget {
case EntryAction.rotateCW:
case EntryAction.flip:
return entry.canRotateAndFlip;
case EntryAction.export:
case EntryAction.print:
return entry.canPrint;
return !entry.isVideo;
case EntryAction.openMap:
return entry.hasGps;
case EntryAction.viewSource:
@ -194,14 +195,15 @@ class _TopOverlayRow extends StatelessWidget {
onPressed: onPressed,
);
break;
case EntryAction.info:
case EntryAction.share:
case EntryAction.delete:
case EntryAction.export:
case EntryAction.flip:
case EntryAction.info:
case EntryAction.print:
case EntryAction.rename:
case EntryAction.rotateCCW:
case EntryAction.rotateCW:
case EntryAction.flip:
case EntryAction.print:
case EntryAction.share:
case EntryAction.viewSource:
child = IconButton(
icon: Icon(action.getIcon()),
@ -237,14 +239,15 @@ class _TopOverlayRow extends StatelessWidget {
isMenuItem: true,
);
break;
case EntryAction.info:
case EntryAction.share:
case EntryAction.delete:
case EntryAction.export:
case EntryAction.flip:
case EntryAction.info:
case EntryAction.print:
case EntryAction.rename:
case EntryAction.rotateCCW:
case EntryAction.rotateCW:
case EntryAction.flip:
case EntryAction.print:
case EntryAction.share:
case EntryAction.viewSource:
case EntryAction.debug:
child = MenuRow(text: action.getText(), icon: action.getIcon());

View file

@ -110,7 +110,7 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
OverlayButton(
scale: scale,
child: IconButton(
icon: Icon(AIcons.openInNew),
icon: Icon(AIcons.openOutside),
onPressed: () => AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype),
tooltip: 'Open',
),