export: to jpeg, no metadata
This commit is contained in:
parent
b0cccd7d2d
commit
c4fdd38850
29 changed files with 592 additions and 273 deletions
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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? {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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:
|
||||
|
|
1
lib/model/actions/move_type.dart
Normal file
1
lib/model/actions/move_type.dart
Normal file
|
@ -0,0 +1 @@
|
|||
enum MoveType { copy, move, export }
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
85
lib/services/image_op_events.dart
Normal file
85
lib/services/image_op_events.dart
Normal 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}';
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}',
|
||||
}),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}),
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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',
|
||||
),
|
||||
|
|
Loading…
Reference in a new issue