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.request.RequestOptions
|
||||||
import com.bumptech.glide.signature.ObjectKey
|
import com.bumptech.glide.signature.ObjectKey
|
||||||
import deckers.thibault.aves.decoder.MultiTrackImage
|
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.decoder.VideoThumbnail
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||||
|
@ -126,7 +126,7 @@ class ThumbnailFetcher internal constructor(
|
||||||
.submit(width, height)
|
.submit(width, height)
|
||||||
} else {
|
} else {
|
||||||
val model: Any = when {
|
val model: Any = when {
|
||||||
tiffFetch -> TiffThumbnail(context, uri, pageId ?: 0)
|
tiffFetch -> TiffImage(context, uri, pageId)
|
||||||
multiTrackFetch -> MultiTrackImage(context, uri, pageId)
|
multiTrackFetch -> MultiTrackImage(context, uri, pageId)
|
||||||
else -> uri
|
else -> uri
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ import com.bumptech.glide.load.DecodeFormat
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||||
import com.bumptech.glide.request.RequestOptions
|
import com.bumptech.glide.request.RequestOptions
|
||||||
import deckers.thibault.aves.decoder.MultiTrackImage
|
import deckers.thibault.aves.decoder.MultiTrackImage
|
||||||
|
import deckers.thibault.aves.decoder.TiffImage
|
||||||
import deckers.thibault.aves.decoder.VideoThumbnail
|
import deckers.thibault.aves.decoder.VideoThumbnail
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||||
|
@ -25,7 +26,6 @@ import io.flutter.plugin.common.EventChannel.EventSink
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
|
@ -96,8 +96,6 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
||||||
|
|
||||||
if (isVideo(mimeType)) {
|
if (isVideo(mimeType)) {
|
||||||
streamVideoByGlide(uri)
|
streamVideoByGlide(uri)
|
||||||
} else if (mimeType == MimeTypes.TIFF) {
|
|
||||||
streamTiffImage(uri, pageId)
|
|
||||||
} else if (!isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) {
|
} else if (!isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) {
|
||||||
// decode exotic format on platform side, then encode it in portable format for Flutter
|
// decode exotic format on platform side, then encode it in portable format for Flutter
|
||||||
streamImageByGlide(uri, pageId, mimeType, rotationDegrees, isFlipped)
|
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) {
|
private fun streamImageByGlide(uri: Uri, pageId: Int?, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) {
|
||||||
val model: Any = if (isHeifLike(mimeType) && pageId != null) {
|
val model: Any = if (isHeifLike(mimeType) && pageId != null) {
|
||||||
MultiTrackImage(activity, uri, pageId)
|
MultiTrackImage(activity, uri, pageId)
|
||||||
|
} else if (mimeType == MimeTypes.TIFF) {
|
||||||
|
TiffImage(activity, uri, pageId)
|
||||||
} else {
|
} else {
|
||||||
uri
|
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? {
|
private fun toErrorDetails(e: Exception): String? {
|
||||||
val errorDetails = e.message
|
val errorDetails = e.message
|
||||||
return if (errorDetails?.isNotEmpty() == true) {
|
return if (errorDetails?.isNotEmpty() == true) {
|
||||||
|
|
|
@ -42,6 +42,7 @@ class ImageOpStreamHandler(private val context: Context, private val arguments:
|
||||||
|
|
||||||
when (op) {
|
when (op) {
|
||||||
"delete" -> GlobalScope.launch(Dispatchers.IO) { delete() }
|
"delete" -> GlobalScope.launch(Dispatchers.IO) { delete() }
|
||||||
|
"export" -> GlobalScope.launch(Dispatchers.IO) { export() }
|
||||||
"move" -> GlobalScope.launch(Dispatchers.IO) { move() }
|
"move" -> GlobalScope.launch(Dispatchers.IO) { move() }
|
||||||
else -> endOfStream()
|
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() {
|
private suspend fun delete() {
|
||||||
if (entryMapList.isEmpty()) {
|
if (entryMapList.isEmpty()) {
|
||||||
endOfStream()
|
endOfStream()
|
||||||
|
@ -144,6 +115,66 @@ class ImageOpStreamHandler(private val context: Context, private val arguments:
|
||||||
endOfStream()
|
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 {
|
companion object {
|
||||||
private val LOG_TAG = LogUtils.createTag(ImageOpStreamHandler::class.java)
|
private val LOG_TAG = LogUtils.createTag(ImageOpStreamHandler::class.java)
|
||||||
const val CHANNEL = "deckers.thibault/aves/imageopstream"
|
const val CHANNEL = "deckers.thibault/aves/imageopstream"
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package deckers.thibault.aves.decoder
|
package deckers.thibault.aves.decoder
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import com.bumptech.glide.Glide
|
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.module.LibraryGlideModule
|
||||||
import com.bumptech.glide.signature.ObjectKey
|
import com.bumptech.glide.signature.ObjectKey
|
||||||
import deckers.thibault.aves.metadata.MultiTrackMedia
|
import deckers.thibault.aves.metadata.MultiTrackMedia
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
|
||||||
import java.io.InputStream
|
|
||||||
|
|
||||||
|
|
||||||
@GlideModule
|
@GlideModule
|
||||||
class MultiTrackImageGlideModule : LibraryGlideModule() {
|
class MultiTrackImageGlideModule : LibraryGlideModule() {
|
||||||
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
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?)
|
class MultiTrackImage(val context: Context, val uri: Uri, val trackId: Int?)
|
||||||
|
|
||||||
internal class MultiTrackThumbnailLoader : ModelLoader<MultiTrackImage, InputStream> {
|
internal class MultiTrackThumbnailLoader : ModelLoader<MultiTrackImage, Bitmap> {
|
||||||
override fun buildLoadData(model: MultiTrackImage, width: Int, height: Int, options: Options): ModelLoader.LoadData<InputStream> {
|
override fun buildLoadData(model: MultiTrackImage, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> {
|
||||||
return ModelLoader.LoadData(ObjectKey(model.uri), MultiTrackImageFetcher(model, width, height))
|
return ModelLoader.LoadData(ObjectKey(model.uri), MultiTrackImageFetcher(model, width, height))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handles(model: MultiTrackImage): Boolean = true
|
override fun handles(model: MultiTrackImage): Boolean = true
|
||||||
|
|
||||||
internal class Factory : ModelLoaderFactory<MultiTrackImage, InputStream> {
|
internal class Factory : ModelLoaderFactory<MultiTrackImage, Bitmap> {
|
||||||
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<MultiTrackImage, InputStream> = MultiTrackThumbnailLoader()
|
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<MultiTrackImage, Bitmap> = MultiTrackThumbnailLoader()
|
||||||
|
|
||||||
override fun teardown() {}
|
override fun teardown() {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class MultiTrackImageFetcher(val model: MultiTrackImage, val width: Int, val height: Int) : DataFetcher<InputStream> {
|
internal class MultiTrackImageFetcher(val model: MultiTrackImage, val width: Int, val height: Int) : DataFetcher<Bitmap> {
|
||||||
override fun loadData(priority: Priority, callback: DataCallback<in InputStream>) {
|
override fun loadData(priority: Priority, callback: DataCallback<in Bitmap>) {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
||||||
callback.onLoadFailed(Exception("unsupported Android version"))
|
callback.onLoadFailed(Exception("unsupported Android version"))
|
||||||
return
|
return
|
||||||
|
@ -59,17 +58,16 @@ internal class MultiTrackImageFetcher(val model: MultiTrackImage, val width: Int
|
||||||
if (bitmap == null) {
|
if (bitmap == null) {
|
||||||
callback.onLoadFailed(Exception("null bitmap"))
|
callback.onLoadFailed(Exception("null bitmap"))
|
||||||
} else {
|
} else {
|
||||||
callback.onDataReady(bitmap.getBytes()?.inputStream())
|
callback.onDataReady(bitmap)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// already cleaned up in loadData and ByteArrayInputStream will be GC'd
|
|
||||||
override fun cleanup() {}
|
override fun cleanup() {}
|
||||||
|
|
||||||
// cannot cancel
|
// cannot cancel
|
||||||
override fun 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
|
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) {
|
class AvesEntry(map: FieldMap) {
|
||||||
val uri: Uri = Uri.parse(map["uri"] as String) // content or file URI
|
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 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 mimeType = map["mimeType"] as String
|
||||||
val width = map["width"] as Int
|
val width = map["width"] as Int
|
||||||
val height = map["height"] as Int
|
val height = map["height"] as Int
|
||||||
val rotationDegrees = map["rotationDegrees"] as Int
|
val rotationDegrees = map["rotationDegrees"] as Int
|
||||||
|
val isFlipped = map["isFlipped"] as Boolean
|
||||||
}
|
}
|
|
@ -36,6 +36,10 @@ abstract class ImageProvider {
|
||||||
callback.onFailure(UnsupportedOperationException())
|
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) {
|
suspend fun rename(context: Context, oldPath: String, oldMediaUri: Uri, mimeType: String, newFilename: String, callback: ImageOpCallback) {
|
||||||
val oldFile = File(oldPath)
|
val oldFile = File(oldPath)
|
||||||
val newFile = File(oldFile.parent, newFilename)
|
val newFile = File(oldFile.parent, newFilename)
|
||||||
|
|
|
@ -3,13 +3,21 @@ package deckers.thibault.aves.model.provider
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Log
|
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 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.AvesEntry
|
||||||
import deckers.thibault.aves.model.SourceEntry
|
import deckers.thibault.aves.model.SourceEntry
|
||||||
|
import deckers.thibault.aves.utils.BitmapUtils
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
import deckers.thibault.aves.utils.MimeTypes.isImage
|
||||||
|
@ -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 {
|
companion object {
|
||||||
private val LOG_TAG = LogUtils.createTag(MediaStoreImageProvider::class.java)
|
private val LOG_TAG = LogUtils.createTag(MediaStoreImageProvider::class.java)
|
||||||
|
|
||||||
|
|
|
@ -26,7 +26,7 @@ object BitmapUtils {
|
||||||
} catch (e: IllegalStateException) {
|
} catch (e: IllegalStateException) {
|
||||||
Log.e(LOG_TAG, "failed to get bytes from bitmap", e)
|
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? {
|
fun applyExifOrientation(context: Context, bitmap: Bitmap?, rotationDegrees: Int?, isFlipped: Boolean?): Bitmap? {
|
||||||
|
|
|
@ -11,8 +11,8 @@ object MimeTypes {
|
||||||
const val HEIC = "image/heic"
|
const val HEIC = "image/heic"
|
||||||
private const val HEIF = "image/heif"
|
private const val HEIF = "image/heif"
|
||||||
private const val ICO = "image/x-icon"
|
private const val ICO = "image/x-icon"
|
||||||
private const val JPEG = "image/jpeg"
|
const val JPEG = "image/jpeg"
|
||||||
private const val PNG = "image/png"
|
const val PNG = "image/png"
|
||||||
const val TIFF = "image/tiff"
|
const val TIFF = "image/tiff"
|
||||||
private const val WBMP = "image/vnd.wap.wbmp"
|
private const val WBMP = "image/vnd.wap.wbmp"
|
||||||
const val WEBP = "image/webp"
|
const val WEBP = "image/webp"
|
||||||
|
|
|
@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart';
|
||||||
enum EntryAction {
|
enum EntryAction {
|
||||||
delete,
|
delete,
|
||||||
edit,
|
edit,
|
||||||
|
export,
|
||||||
flip,
|
flip,
|
||||||
info,
|
info,
|
||||||
open,
|
open,
|
||||||
|
@ -31,6 +32,7 @@ class EntryActions {
|
||||||
EntryAction.share,
|
EntryAction.share,
|
||||||
EntryAction.delete,
|
EntryAction.delete,
|
||||||
EntryAction.rename,
|
EntryAction.rename,
|
||||||
|
EntryAction.export,
|
||||||
EntryAction.print,
|
EntryAction.print,
|
||||||
EntryAction.viewSource,
|
EntryAction.viewSource,
|
||||||
];
|
];
|
||||||
|
@ -52,6 +54,8 @@ extension ExtraEntryAction on EntryAction {
|
||||||
return null;
|
return null;
|
||||||
case EntryAction.delete:
|
case EntryAction.delete:
|
||||||
return 'Delete';
|
return 'Delete';
|
||||||
|
case EntryAction.export:
|
||||||
|
return 'Export';
|
||||||
case EntryAction.info:
|
case EntryAction.info:
|
||||||
return 'Info';
|
return 'Info';
|
||||||
case EntryAction.rename:
|
case EntryAction.rename:
|
||||||
|
@ -91,6 +95,8 @@ extension ExtraEntryAction on EntryAction {
|
||||||
return null;
|
return null;
|
||||||
case EntryAction.delete:
|
case EntryAction.delete:
|
||||||
return AIcons.delete;
|
return AIcons.delete;
|
||||||
|
case EntryAction.export:
|
||||||
|
return AIcons.export;
|
||||||
case EntryAction.info:
|
case EntryAction.info:
|
||||||
return AIcons.info;
|
return AIcons.info;
|
||||||
case EntryAction.rename:
|
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;
|
return copied;
|
||||||
}
|
}
|
||||||
|
|
||||||
AvesEntry getPageEntry(SinglePageInfo pageInfo) {
|
AvesEntry getPageEntry(SinglePageInfo pageInfo, {bool eraseDefaultPageId = true}) {
|
||||||
if (pageInfo == null) return this;
|
if (pageInfo == null) return this;
|
||||||
|
|
||||||
// do not provide the page ID for the default page,
|
// do not provide the page ID for the default page,
|
||||||
// so that we can treat this page like the main entry
|
// so that we can treat this page like the main entry
|
||||||
// and retrieve cached images for it
|
// and retrieve cached images for it
|
||||||
final pageId = pageInfo.isDefault ? null : pageInfo.pageId;
|
final pageId = eraseDefaultPageId && pageInfo.isDefault ? null : pageInfo.pageId;
|
||||||
|
|
||||||
return AvesEntry(
|
return AvesEntry(
|
||||||
uri: uri,
|
uri: uri,
|
||||||
|
@ -254,8 +254,6 @@ class AvesEntry {
|
||||||
|
|
||||||
bool get canEdit => path != null;
|
bool get canEdit => path != null;
|
||||||
|
|
||||||
bool get canPrint => !isVideo;
|
|
||||||
|
|
||||||
bool get canRotateAndFlip => canEdit && canEditExif;
|
bool get canRotateAndFlip => canEdit && canEditExif;
|
||||||
|
|
||||||
// support for writing EXIF
|
// support for writing EXIF
|
||||||
|
@ -637,9 +635,9 @@ class AvesEntry {
|
||||||
|
|
||||||
// compare by:
|
// compare by:
|
||||||
// 1) date descending
|
// 1) date descending
|
||||||
// 2) name ascending
|
// 2) name descending
|
||||||
static int compareByDate(AvesEntry a, AvesEntry b) {
|
static int compareByDate(AvesEntry a, AvesEntry b) {
|
||||||
final c = (b.bestDate ?? _epoch).compareTo(a.bestDate ?? _epoch);
|
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 'dart:async';
|
||||||
|
|
||||||
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/favourite_repo.dart';
|
import 'package:aves/model/favourite_repo.dart';
|
||||||
import 'package:aves/model/filters/filters.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.dart';
|
||||||
import 'package:aves/model/metadata_db.dart';
|
import 'package:aves/model/metadata_db.dart';
|
||||||
import 'package:aves/model/source/album.dart';
|
import 'package:aves/model/source/album.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/model/source/location.dart';
|
import 'package:aves/model/source/location.dart';
|
||||||
import 'package:aves/model/source/tag.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:event_bus/event_bus.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
|
|
@ -5,10 +5,10 @@ import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/ref/mime_types.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:aves/services/service_policy.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:streams_channel/streams_channel.dart';
|
import 'package:streams_channel/streams_channel.dart';
|
||||||
|
|
||||||
class ImageFileService {
|
class ImageFileService {
|
||||||
|
@ -22,6 +22,7 @@ class ImageFileService {
|
||||||
return {
|
return {
|
||||||
'uri': entry.uri,
|
'uri': entry.uri,
|
||||||
'path': entry.path,
|
'path': entry.path,
|
||||||
|
'pageId': entry.pageId,
|
||||||
'mimeType': entry.mimeType,
|
'mimeType': entry.mimeType,
|
||||||
'width': entry.width,
|
'width': entry.width,
|
||||||
'height': entry.height,
|
'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 {
|
try {
|
||||||
return opChannel.receiveBroadcastStream(<String, dynamic>{
|
return opChannel.receiveBroadcastStream(<String, dynamic>{
|
||||||
'op': 'move',
|
'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 {
|
static Future<Map> rename(AvesEntry entry, String newName) async {
|
||||||
try {
|
try {
|
||||||
// return map with: 'contentId' 'path' 'title' 'uri' (all optional)
|
// 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`
|
// cf flutter/foundation `consolidateHttpClientResponseBytes`
|
||||||
typedef BytesReceivedCallback = void Function(int cumulative, int total);
|
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 createAlbum = Icons.add_circle_outline;
|
||||||
static const IconData debug = Icons.whatshot_outlined;
|
static const IconData debug = Icons.whatshot_outlined;
|
||||||
static const IconData delete = Icons.delete_outlined;
|
static const IconData delete = Icons.delete_outlined;
|
||||||
|
static const IconData export = Icons.save_alt_outlined;
|
||||||
static const IconData flip = Icons.flip_outlined;
|
static const IconData flip = Icons.flip_outlined;
|
||||||
static const IconData favourite = Icons.favorite_border;
|
static const IconData favourite = Icons.favorite_border;
|
||||||
static const IconData favouriteActive = Icons.favorite;
|
static const IconData favouriteActive = Icons.favorite;
|
||||||
|
@ -38,7 +39,7 @@ class AIcons {
|
||||||
static const IconData group = Icons.group_work_outlined;
|
static const IconData group = Icons.group_work_outlined;
|
||||||
static const IconData info = Icons.info_outlined;
|
static const IconData info = Icons.info_outlined;
|
||||||
static const IconData layers = Icons.layers_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 pin = Icons.push_pin_outlined;
|
||||||
static const IconData print = Icons.print_outlined;
|
static const IconData print = Icons.print_outlined;
|
||||||
static const IconData refresh = Icons.refresh_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/collection_actions.dart';
|
||||||
import 'package:aves/model/actions/entry_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/entry.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:aves/services/android_app_service.dart';
|
import 'package:aves/services/android_app_service.dart';
|
||||||
import 'package:aves/services/image_file_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/feedback.dart';
|
||||||
import 'package:aves/widgets/common/action_mixins/permission_aware.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/common/action_mixins/size_aware.dart';
|
||||||
|
@ -46,10 +48,10 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
||||||
void onCollectionActionSelected(BuildContext context, CollectionAction action) {
|
void onCollectionActionSelected(BuildContext context, CollectionAction action) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case CollectionAction.copy:
|
case CollectionAction.copy:
|
||||||
_moveSelection(context, copy: true);
|
_moveSelection(context, moveType: MoveType.copy);
|
||||||
break;
|
break;
|
||||||
case CollectionAction.move:
|
case CollectionAction.move:
|
||||||
_moveSelection(context, copy: false);
|
_moveSelection(context, moveType: MoveType.move);
|
||||||
break;
|
break;
|
||||||
case CollectionAction.refreshMetadata:
|
case CollectionAction.refreshMetadata:
|
||||||
source.refreshMetadata(selection);
|
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(
|
final destinationAlbum = await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute<String>(
|
MaterialPageRoute<String>(
|
||||||
settings: RouteSettings(name: AlbumPickPage.routeName),
|
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;
|
if (destinationAlbum == null || destinationAlbum.isEmpty) return;
|
||||||
|
@ -74,8 +76,9 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
||||||
|
|
||||||
if (!await checkStoragePermission(context, selection)) return;
|
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>(
|
showOpReport<MoveOpEvent>(
|
||||||
context: context,
|
context: context,
|
||||||
selection: selection,
|
selection: selection,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import 'package:aves/model/entry.dart';
|
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:aves/theme/durations.dart';
|
||||||
import 'package:flushbar/flushbar.dart';
|
import 'package:flushbar/flushbar.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:aves/model/actions/move_type.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/services/android_file_service.dart';
|
import 'package:aves/services/android_file_service.dart';
|
||||||
import 'package:aves/utils/android_file_utils.dart';
|
import 'package:aves/utils/android_file_utils.dart';
|
||||||
|
@ -11,21 +12,30 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
mixin SizeAwareMixin {
|
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 destinationVolume = androidFileUtils.getStorageVolume(destinationAlbum);
|
||||||
final free = await AndroidFileService.getFreeSpace(destinationVolume);
|
final free = await AndroidFileService.getFreeSpace(destinationVolume);
|
||||||
int needed;
|
int needed;
|
||||||
int sumSize(sum, entry) => sum + entry.sizeBytes;
|
int sumSize(sum, entry) => sum + entry.sizeBytes;
|
||||||
if (copy) {
|
switch (moveType) {
|
||||||
needed = selection.fold(0, sumSize);
|
case MoveType.copy:
|
||||||
} else {
|
case MoveType.export:
|
||||||
// when moving, we only need space for the entries that are not already on the destination volume
|
needed = selection.fold(0, sumSize);
|
||||||
final byVolume = groupBy<AvesEntry, StorageVolume>(selection, (entry) => androidFileUtils.getStorageVolume(entry.path));
|
break;
|
||||||
final otherVolumes = byVolume.keys.where((volume) => volume != destinationVolume);
|
case MoveType.move:
|
||||||
final fromOtherVolumes = otherVolumes.fold<int>(0, (sum, volume) => sum + byVolume[volume].fold(0, sumSize));
|
// when moving, we only need space for the entries that are not already on the destination volume
|
||||||
// and we need at least as much space as the largest entry because individual entries are copied then deleted
|
final byVolume = groupBy<AvesEntry, StorageVolume>(selection, (entry) => androidFileUtils.getStorageVolume(entry.path));
|
||||||
final largestSingle = selection.fold<int>(0, (largest, entry) => max(largest, entry.sizeBytes));
|
final otherVolumes = byVolume.keys.where((volume) => volume != destinationVolume);
|
||||||
needed = max(fromOtherVolumes, largestSingle);
|
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;
|
final hasEnoughSpace = needed < free;
|
||||||
|
|
|
@ -44,7 +44,7 @@ class LinkChip extends StatelessWidget {
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
Builder(
|
Builder(
|
||||||
builder: (context) => Icon(
|
builder: (context) => Icon(
|
||||||
AIcons.openInNew,
|
AIcons.openOutside,
|
||||||
size: DefaultTextStyle.of(context).style.fontSize,
|
size: DefaultTextStyle.of(context).style.fontSize,
|
||||||
color: color,
|
color: color,
|
||||||
),
|
),
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:aves/model/actions/chip_actions.dart';
|
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/album.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
|
@ -19,11 +20,11 @@ class AlbumPickPage extends StatefulWidget {
|
||||||
static const routeName = '/album_pick';
|
static const routeName = '/album_pick';
|
||||||
|
|
||||||
final CollectionSource source;
|
final CollectionSource source;
|
||||||
final bool copy;
|
final MoveType moveType;
|
||||||
|
|
||||||
const AlbumPickPage({
|
const AlbumPickPage({
|
||||||
@required this.source,
|
@required this.source,
|
||||||
@required this.copy,
|
@required this.moveType,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -38,7 +39,7 @@ class _AlbumPickPageState extends State<AlbumPickPage> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
Widget appBar = AlbumPickAppBar(
|
Widget appBar = AlbumPickAppBar(
|
||||||
copy: widget.copy,
|
moveType: widget.moveType,
|
||||||
actionDelegate: AlbumChipSetActionDelegate(source: source),
|
actionDelegate: AlbumChipSetActionDelegate(source: source),
|
||||||
queryNotifier: _queryNotifier,
|
queryNotifier: _queryNotifier,
|
||||||
);
|
);
|
||||||
|
@ -71,23 +72,36 @@ class _AlbumPickPageState extends State<AlbumPickPage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
class AlbumPickAppBar extends StatelessWidget {
|
class AlbumPickAppBar extends StatelessWidget {
|
||||||
final bool copy;
|
final MoveType moveType;
|
||||||
final AlbumChipSetActionDelegate actionDelegate;
|
final AlbumChipSetActionDelegate actionDelegate;
|
||||||
final ValueNotifier<String> queryNotifier;
|
final ValueNotifier<String> queryNotifier;
|
||||||
|
|
||||||
static const preferredHeight = kToolbarHeight + AlbumFilterBar.preferredHeight;
|
static const preferredHeight = kToolbarHeight + AlbumFilterBar.preferredHeight;
|
||||||
|
|
||||||
const AlbumPickAppBar({
|
const AlbumPickAppBar({
|
||||||
@required this.copy,
|
@required this.moveType,
|
||||||
@required this.actionDelegate,
|
@required this.actionDelegate,
|
||||||
@required this.queryNotifier,
|
@required this.queryNotifier,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return SliverAppBar(
|
||||||
leading: BackButton(),
|
leading: BackButton(),
|
||||||
title: Text(copy ? 'Copy to Album' : 'Move to Album'),
|
title: Text(title()),
|
||||||
bottom: AlbumFilterBar(
|
bottom: AlbumFilterBar(
|
||||||
filterNotifier: queryNotifier,
|
filterNotifier: queryNotifier,
|
||||||
),
|
),
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import 'package:aves/model/actions/chip_actions.dart';
|
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/album.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:aves/services/image_file_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/feedback.dart';
|
||||||
import 'package:aves/widgets/common/action_mixins/permission_aware.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/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 selection = source.rawEntries.where(filter.filter).toSet();
|
||||||
final destinationAlbum = path.join(path.dirname(album), newName);
|
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>(
|
showOpReport<MoveOpEvent>(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
|
@ -100,7 +100,6 @@ class ViewerDebugPage extends StatelessWidget {
|
||||||
'is360': '${entry.is360}',
|
'is360': '${entry.is360}',
|
||||||
'canEdit': '${entry.canEdit}',
|
'canEdit': '${entry.canEdit}',
|
||||||
'canEditExif': '${entry.canEditExif}',
|
'canEditExif': '${entry.canEditExif}',
|
||||||
'canPrint': '${entry.canPrint}',
|
|
||||||
'canRotateAndFlip': '${entry.canRotateAndFlip}',
|
'canRotateAndFlip': '${entry.canRotateAndFlip}',
|
||||||
'xmpSubjects': '${entry.xmpSubjects}',
|
'xmpSubjects': '${entry.xmpSubjects}',
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -1,23 +1,29 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:aves/model/actions/entry_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/entry.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/services/android_app_service.dart';
|
import 'package:aves/services/android_app_service.dart';
|
||||||
import 'package:aves/services/image_file_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/feedback.dart';
|
||||||
import 'package:aves/widgets/common/action_mixins/permission_aware.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/aves_dialog.dart';
|
||||||
import 'package:aves/widgets/dialogs/rename_entry_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/debug_page.dart';
|
||||||
import 'package:aves/widgets/viewer/printer.dart';
|
import 'package:aves/widgets/viewer/printer.dart';
|
||||||
import 'package:aves/widgets/viewer/source_viewer_page.dart';
|
import 'package:aves/widgets/viewer/source_viewer_page.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
import 'package:pedantic/pedantic.dart';
|
import 'package:pedantic/pedantic.dart';
|
||||||
|
|
||||||
class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
||||||
final CollectionLens collection;
|
final CollectionLens collection;
|
||||||
final VoidCallback showInfo;
|
final VoidCallback showInfo;
|
||||||
|
|
||||||
|
@ -36,6 +42,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
||||||
case EntryAction.delete:
|
case EntryAction.delete:
|
||||||
_showDeleteDialog(context, entry);
|
_showDeleteDialog(context, entry);
|
||||||
break;
|
break;
|
||||||
|
case EntryAction.export:
|
||||||
|
_showExportDialog(context, entry);
|
||||||
|
break;
|
||||||
case EntryAction.info:
|
case EntryAction.info:
|
||||||
showInfo();
|
showInfo();
|
||||||
break;
|
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 {
|
Future<void> _showRenameDialog(BuildContext context, AvesEntry entry) async {
|
||||||
final newName = await showDialog<String>(
|
final newName = await showDialog<String>(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
|
@ -63,7 +63,7 @@ class MapButtonPanel extends StatelessWidget {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
MapOverlayButton(
|
MapOverlayButton(
|
||||||
icon: AIcons.openInNew,
|
icon: AIcons.openOutside,
|
||||||
onPressed: () => AndroidAppService.openMap(geoUri).then((success) {
|
onPressed: () => AndroidAppService.openMap(geoUri).then((success) {
|
||||||
if (!success) showNoMatchingAppDialog(context);
|
if (!success) showNoMatchingAppDialog(context);
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:aves/model/actions/entry_actions.dart';
|
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/entry.dart';
|
||||||
|
import 'package:aves/model/favourite_repo.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
|
@ -111,8 +111,9 @@ class ViewerTopOverlay extends StatelessWidget {
|
||||||
case EntryAction.rotateCW:
|
case EntryAction.rotateCW:
|
||||||
case EntryAction.flip:
|
case EntryAction.flip:
|
||||||
return entry.canRotateAndFlip;
|
return entry.canRotateAndFlip;
|
||||||
|
case EntryAction.export:
|
||||||
case EntryAction.print:
|
case EntryAction.print:
|
||||||
return entry.canPrint;
|
return !entry.isVideo;
|
||||||
case EntryAction.openMap:
|
case EntryAction.openMap:
|
||||||
return entry.hasGps;
|
return entry.hasGps;
|
||||||
case EntryAction.viewSource:
|
case EntryAction.viewSource:
|
||||||
|
@ -194,14 +195,15 @@ class _TopOverlayRow extends StatelessWidget {
|
||||||
onPressed: onPressed,
|
onPressed: onPressed,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case EntryAction.info:
|
|
||||||
case EntryAction.share:
|
|
||||||
case EntryAction.delete:
|
case EntryAction.delete:
|
||||||
|
case EntryAction.export:
|
||||||
|
case EntryAction.flip:
|
||||||
|
case EntryAction.info:
|
||||||
|
case EntryAction.print:
|
||||||
case EntryAction.rename:
|
case EntryAction.rename:
|
||||||
case EntryAction.rotateCCW:
|
case EntryAction.rotateCCW:
|
||||||
case EntryAction.rotateCW:
|
case EntryAction.rotateCW:
|
||||||
case EntryAction.flip:
|
case EntryAction.share:
|
||||||
case EntryAction.print:
|
|
||||||
case EntryAction.viewSource:
|
case EntryAction.viewSource:
|
||||||
child = IconButton(
|
child = IconButton(
|
||||||
icon: Icon(action.getIcon()),
|
icon: Icon(action.getIcon()),
|
||||||
|
@ -237,14 +239,15 @@ class _TopOverlayRow extends StatelessWidget {
|
||||||
isMenuItem: true,
|
isMenuItem: true,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case EntryAction.info:
|
|
||||||
case EntryAction.share:
|
|
||||||
case EntryAction.delete:
|
case EntryAction.delete:
|
||||||
|
case EntryAction.export:
|
||||||
|
case EntryAction.flip:
|
||||||
|
case EntryAction.info:
|
||||||
|
case EntryAction.print:
|
||||||
case EntryAction.rename:
|
case EntryAction.rename:
|
||||||
case EntryAction.rotateCCW:
|
case EntryAction.rotateCCW:
|
||||||
case EntryAction.rotateCW:
|
case EntryAction.rotateCW:
|
||||||
case EntryAction.flip:
|
case EntryAction.share:
|
||||||
case EntryAction.print:
|
|
||||||
case EntryAction.viewSource:
|
case EntryAction.viewSource:
|
||||||
case EntryAction.debug:
|
case EntryAction.debug:
|
||||||
child = MenuRow(text: action.getText(), icon: action.getIcon());
|
child = MenuRow(text: action.getText(), icon: action.getIcon());
|
||||||
|
|
|
@ -110,7 +110,7 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
|
||||||
OverlayButton(
|
OverlayButton(
|
||||||
scale: scale,
|
scale: scale,
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: Icon(AIcons.openInNew),
|
icon: Icon(AIcons.openOutside),
|
||||||
onPressed: () => AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype),
|
onPressed: () => AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype),
|
||||||
tooltip: 'Open',
|
tooltip: 'Open',
|
||||||
),
|
),
|
||||||
|
|
Loading…
Reference in a new issue