From c4fdd38850e931bca8d00974a423894855fc775b Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 24 Jan 2021 14:15:46 +0900 Subject: [PATCH] export: to jpeg, no metadata --- .../aves/channel/calls/ThumbnailFetcher.kt | 4 +- .../channel/streams/ImageByteStreamHandler.kt | 28 +--- .../channel/streams/ImageOpStreamHandler.kt | 91 +++++++---- .../decoder/MultiTrackImageGlideModule.kt | 22 ++- .../thibault/aves/decoder/TiffGlideModule.kt | 99 ++++++++++++ .../aves/decoder/TiffThumbnailGlideModule.kt | 99 ------------ .../deckers/thibault/aves/model/AvesEntry.kt | 2 + .../aves/model/provider/ImageProvider.kt | 4 + .../model/provider/MediaStoreImageProvider.kt | 147 ++++++++++++++++++ .../thibault/aves/utils/BitmapUtils.kt | 2 +- .../deckers/thibault/aves/utils/MimeTypes.kt | 4 +- lib/model/actions/entry_actions.dart | 6 + lib/model/actions/move_type.dart | 1 + lib/model/entry.dart | 10 +- lib/model/source/collection_source.dart | 4 +- lib/services/image_file_service.dart | 80 ++++------ lib/services/image_op_events.dart | 85 ++++++++++ lib/theme/icons.dart | 3 +- .../collection/entry_set_action_delegate.dart | 13 +- .../common/action_mixins/feedback.dart | 2 +- .../common/action_mixins/size_aware.dart | 32 ++-- lib/widgets/common/basic/link_chip.dart | 2 +- lib/widgets/filter_grids/album_pick.dart | 26 +++- .../common/chip_action_delegate.dart | 4 +- lib/widgets/viewer/debug_page.dart | 1 - lib/widgets/viewer/entry_action_delegate.dart | 67 +++++++- lib/widgets/viewer/info/maps/common.dart | 2 +- lib/widgets/viewer/overlay/top.dart | 23 +-- lib/widgets/viewer/overlay/video.dart | 2 +- 29 files changed, 592 insertions(+), 273 deletions(-) create mode 100644 android/app/src/main/kotlin/deckers/thibault/aves/decoder/TiffGlideModule.kt delete mode 100644 android/app/src/main/kotlin/deckers/thibault/aves/decoder/TiffThumbnailGlideModule.kt create mode 100644 lib/model/actions/move_type.dart create mode 100644 lib/services/image_op_events.dart diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ThumbnailFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ThumbnailFetcher.kt index f5d027d32..5d9f83388 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ThumbnailFetcher.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ThumbnailFetcher.kt @@ -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 } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt index 28022a627..b0e464483 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt @@ -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) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt index de45c8817..b70de372a 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt @@ -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" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/MultiTrackImageGlideModule.kt b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/MultiTrackImageGlideModule.kt index 6a53d5709..290b7badd 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/MultiTrackImageGlideModule.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/MultiTrackImageGlideModule.kt @@ -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 { - override fun buildLoadData(model: MultiTrackImage, width: Int, height: Int, options: Options): ModelLoader.LoadData { +internal class MultiTrackThumbnailLoader : ModelLoader { + override fun buildLoadData(model: MultiTrackImage, width: Int, height: Int, options: Options): ModelLoader.LoadData { return ModelLoader.LoadData(ObjectKey(model.uri), MultiTrackImageFetcher(model, width, height)) } override fun handles(model: MultiTrackImage): Boolean = true - internal class Factory : ModelLoaderFactory { - override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader = MultiTrackThumbnailLoader() + internal class Factory : ModelLoaderFactory { + override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader = MultiTrackThumbnailLoader() override fun teardown() {} } } -internal class MultiTrackImageFetcher(val model: MultiTrackImage, val width: Int, val height: Int) : DataFetcher { - override fun loadData(priority: Priority, callback: DataCallback) { +internal class MultiTrackImageFetcher(val model: MultiTrackImage, val width: Int, val height: Int) : DataFetcher { + override fun loadData(priority: Priority, callback: DataCallback) { 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::class.java + override fun getDataClass(): Class = Bitmap::class.java override fun getDataSource(): DataSource = DataSource.LOCAL } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/TiffGlideModule.kt b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/TiffGlideModule.kt new file mode 100644 index 000000000..074f06332 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/TiffGlideModule.kt @@ -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 { + override fun buildLoadData(model: TiffImage, width: Int, height: Int, options: Options): ModelLoader.LoadData { + return ModelLoader.LoadData(ObjectKey(model.uri), TiffFetcher(model, width, height)) + } + + override fun handles(model: TiffImage): Boolean = true + + internal class Factory : ModelLoaderFactory { + override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader = TiffLoader() + + override fun teardown() {} + } +} + +internal class TiffFetcher(val model: TiffImage, val width: Int, val height: Int) : DataFetcher { + override fun loadData(priority: Priority, callback: DataCallback) { + 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::class.java + + override fun getDataSource(): DataSource = DataSource.LOCAL +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/TiffThumbnailGlideModule.kt b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/TiffThumbnailGlideModule.kt deleted file mode 100644 index 529547d1e..000000000 --- a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/TiffThumbnailGlideModule.kt +++ /dev/null @@ -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 { - override fun buildLoadData(model: TiffThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData { - return ModelLoader.LoadData(ObjectKey(model.uri), TiffThumbnailFetcher(model, width, height)) - } - - override fun handles(model: TiffThumbnail): Boolean = true - - internal class Factory : ModelLoaderFactory { - override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader = TiffThumbnailLoader() - - override fun teardown() {} - } -} - -internal class TiffThumbnailFetcher(val model: TiffThumbnail, val width: Int, val height: Int) : DataFetcher { - override fun loadData(priority: Priority, callback: DataCallback) { - 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::class.java - - override fun getDataSource(): DataSource = DataSource.LOCAL -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/AvesEntry.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/AvesEntry.kt index e942c2722..dfa0cbd32 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/AvesEntry.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/AvesEntry.kt @@ -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 } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index fd8421607..49d944b59 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -36,6 +36,10 @@ abstract class ImageProvider { callback.onFailure(UnsupportedOperationException()) } + open suspend fun exportMultiple(context: Context, mimeType: String, destinationDir: String, entries: List, 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) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index f06b5ea04..fc715a876 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -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, + 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( + "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) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/BitmapUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/BitmapUtils.kt index cb70f3347..2f71186ee 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/BitmapUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/BitmapUtils.kt @@ -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? { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt index cb41fd458..f033dbf22 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt @@ -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" diff --git a/lib/model/actions/entry_actions.dart b/lib/model/actions/entry_actions.dart index 805df42f0..5cd6f62c3 100644 --- a/lib/model/actions/entry_actions.dart +++ b/lib/model/actions/entry_actions.dart @@ -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: diff --git a/lib/model/actions/move_type.dart b/lib/model/actions/move_type.dart new file mode 100644 index 000000000..71b326b70 --- /dev/null +++ b/lib/model/actions/move_type.dart @@ -0,0 +1 @@ +enum MoveType { copy, move, export } diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 668d64ddc..ab7101131 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -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); } } diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index da2e48710..c136ac722 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -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'; diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index 8e94d0d59..31a67829b 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -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 move(Iterable entries, {@required bool copy, @required String destinationAlbum}) { + static Stream move( + Iterable entries, { + @required bool copy, + @required String destinationAlbum, + }) { try { return opChannel.receiveBroadcastStream({ 'op': 'move', @@ -250,6 +255,24 @@ class ImageFileService { } } + static Stream export( + Iterable entries, { + String mimeType = MimeTypes.jpeg, + @required String destinationAlbum, + }) { + try { + return opChannel.receiveBroadcastStream({ + '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 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); diff --git a/lib/services/image_op_events.dart b/lib/services/image_op_events.dart new file mode 100644 index 000000000..2f30d8fe7 --- /dev/null +++ b/lib/services/image_op_events.dart @@ -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}'; +} diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 5cdf7f2b5..2ff083f0e 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -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; diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index b1f83b45a..36d96fcd2 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -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 _moveSelection(BuildContext context, {@required bool copy}) async { + Future _moveSelection(BuildContext context, {@required MoveType moveType}) async { final destinationAlbum = await Navigator.push( context, MaterialPageRoute( 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( context: context, selection: selection, diff --git a/lib/widgets/common/action_mixins/feedback.dart b/lib/widgets/common/action_mixins/feedback.dart index 45e02fbed..1e7c2374e 100644 --- a/lib/widgets/common/action_mixins/feedback.dart +++ b/lib/widgets/common/action_mixins/feedback.dart @@ -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'; diff --git a/lib/widgets/common/action_mixins/size_aware.dart b/lib/widgets/common/action_mixins/size_aware.dart index 73ef17084..bf6e34b44 100644 --- a/lib/widgets/common/action_mixins/size_aware.dart +++ b/lib/widgets/common/action_mixins/size_aware.dart @@ -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 checkFreeSpaceForMove(BuildContext context, Set selection, String destinationAlbum, bool copy) async { + Future checkFreeSpaceForMove( + BuildContext context, + Set 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(selection, (entry) => androidFileUtils.getStorageVolume(entry.path)); - final otherVolumes = byVolume.keys.where((volume) => volume != destinationVolume); - final fromOtherVolumes = otherVolumes.fold(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(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(selection, (entry) => androidFileUtils.getStorageVolume(entry.path)); + final otherVolumes = byVolume.keys.where((volume) => volume != destinationVolume); + final fromOtherVolumes = otherVolumes.fold(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(0, (largest, entry) => max(largest, entry.sizeBytes)); + needed = max(fromOtherVolumes, largestSingle); + break; } final hasEnoughSpace = needed < free; diff --git a/lib/widgets/common/basic/link_chip.dart b/lib/widgets/common/basic/link_chip.dart index c0f54a506..b04c0b2ae 100644 --- a/lib/widgets/common/basic/link_chip.dart +++ b/lib/widgets/common/basic/link_chip.dart @@ -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, ), diff --git a/lib/widgets/filter_grids/album_pick.dart b/lib/widgets/filter_grids/album_pick.dart index 6b33d36d9..a71522cde 100644 --- a/lib/widgets/filter_grids/album_pick.dart +++ b/lib/widgets/filter_grids/album_pick.dart @@ -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 { @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 { } class AlbumPickAppBar extends StatelessWidget { - final bool copy; + final MoveType moveType; final AlbumChipSetActionDelegate actionDelegate; final ValueNotifier 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, ), diff --git a/lib/widgets/filter_grids/common/chip_action_delegate.dart b/lib/widgets/filter_grids/common/chip_action_delegate.dart index 15fec7a8c..5dfeba406 100644 --- a/lib/widgets/filter_grids/common/chip_action_delegate.dart +++ b/lib/widgets/filter_grids/common/chip_action_delegate.dart @@ -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( context: context, diff --git a/lib/widgets/viewer/debug_page.dart b/lib/widgets/viewer/debug_page.dart index c18f3d9a2..d26324622 100644 --- a/lib/widgets/viewer/debug_page.dart +++ b/lib/widgets/viewer/debug_page.dart @@ -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}', }), diff --git a/lib/widgets/viewer/entry_action_delegate.dart b/lib/widgets/viewer/entry_action_delegate.dart index b2f298c8b..faaeec50b 100644 --- a/lib/widgets/viewer/entry_action_delegate.dart +++ b/lib/widgets/viewer/entry_action_delegate.dart @@ -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 _showExportDialog(BuildContext context, AvesEntry entry) async { + String destinationAlbum; + if (hasCollection) { + final source = collection.source; + destinationAlbum = await Navigator.push( + context, + MaterialPageRoute( + 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 = {}; + 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( + 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 _showRenameDialog(BuildContext context, AvesEntry entry) async { final newName = await showDialog( context: context, diff --git a/lib/widgets/viewer/info/maps/common.dart b/lib/widgets/viewer/info/maps/common.dart index d62506e1a..ee28ed748 100644 --- a/lib/widgets/viewer/info/maps/common.dart +++ b/lib/widgets/viewer/info/maps/common.dart @@ -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); }), diff --git a/lib/widgets/viewer/overlay/top.dart b/lib/widgets/viewer/overlay/top.dart index e2a026346..44fc7a3f1 100644 --- a/lib/widgets/viewer/overlay/top.dart +++ b/lib/widgets/viewer/overlay/top.dart @@ -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()); diff --git a/lib/widgets/viewer/overlay/video.dart b/lib/widgets/viewer/overlay/video.dart index 3b0951e9f..59c1cd9d5 100644 --- a/lib/widgets/viewer/overlay/video.dart +++ b/lib/widgets/viewer/overlay/video.dart @@ -110,7 +110,7 @@ class _VideoControlOverlayState extends State with SingleTi OverlayButton( scale: scale, child: IconButton( - icon: Icon(AIcons.openInNew), + icon: Icon(AIcons.openOutside), onPressed: () => AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype), tooltip: 'Open', ),