diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt index 70118a26e..5ed49e3d1 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt @@ -3,7 +3,6 @@ package deckers.thibault.aves.channel.calls import android.app.Activity import android.graphics.Rect import android.net.Uri -import android.util.Size import com.bumptech.glide.Glide import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.channel.calls.Coresult.Companion.safesus diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index 3855f5732..539c47dd6 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -20,6 +20,7 @@ import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.ExifOrientationOp import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.utils.BitmapUtils +import deckers.thibault.aves.utils.BmpWriter import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.StorageUtils.copyFileToTemp @@ -112,6 +113,7 @@ abstract class ImageProvider { desiredNameWithoutExtension += "_${page.toString().padStart(3, '0')}" } val desiredFileName = desiredNameWithoutExtension + when (exportMimeType) { + MimeTypes.BMP -> ".bmp" MimeTypes.JPEG -> ".jpg" MimeTypes.PNG -> ".png" MimeTypes.WEBP -> ".webp" @@ -157,26 +159,29 @@ abstract class ImageProvider { } 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) + if (exportMimeType == MimeTypes.BMP) { + BmpWriter.writeRGB24(bitmap, it) + } else { + val quality = 100 + val format = when (exportMimeType) { + MimeTypes.JPEG -> Bitmap.CompressFormat.JPEG + MimeTypes.PNG -> Bitmap.CompressFormat.PNG + MimeTypes.WEBP -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + if (quality == 100) { + Bitmap.CompressFormat.WEBP_LOSSLESS + } else { + Bitmap.CompressFormat.WEBP_LOSSY + } + } else { + @Suppress("DEPRECATION") + Bitmap.CompressFormat.WEBP + } + else -> throw Exception("unsupported export MIME type=$exportMimeType") + } + bitmap.compress(format, quality, it) + } } } finally { Glide.with(context).clear(target) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/BitmapUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/BitmapUtils.kt index a84cce1ef..8a07860de 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/BitmapUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/BitmapUtils.kt @@ -29,9 +29,11 @@ object BitmapUtils { } } try { - // we compress the bitmap because Flutter cannot decode the raw bytes + // the Bitmap raw bytes are not decodable by Flutter + // we need to format them (compress, or add a BMP header) before sending them // `Bitmap.CompressFormat.PNG` is slower than `JPEG`, but it allows transparency - if (canHaveAlpha) { + // the BMP format allows an alpha channel, but Android decoding seems to ignore it + if (canHaveAlpha && hasAlpha()) { this.compress(Bitmap.CompressFormat.PNG, quality, stream) } else { this.compress(Bitmap.CompressFormat.JPEG, quality, stream) @@ -43,7 +45,7 @@ object BitmapUtils { freeBaos.add(stream) } return byteArray - } catch (e: IllegalStateException) { + } catch (e: Exception) { Log.e(LOG_TAG, "failed to get bytes from bitmap", e) } return null diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/BmpWriter.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/BmpWriter.kt new file mode 100644 index 000000000..1409eecfd --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/BmpWriter.kt @@ -0,0 +1,111 @@ +package deckers.thibault.aves.utils + +import android.graphics.Bitmap +import java.io.OutputStream +import java.nio.ByteBuffer + +object BmpWriter { + private const val FILE_HEADER_SIZE = 14 + private const val INFO_HEADER_SIZE = 40 + private const val BYTE_PER_PIXEL = 3 + private val pad = ByteArray(3) + + // file header + private val bfType = byteArrayOf('B'.toByte(), 'M'.toByte()) + private val bfReserved1 = intToWord(0) + private val bfReserved2 = intToWord(0) + private val bfOffBits = intToDWord(FILE_HEADER_SIZE + INFO_HEADER_SIZE) + + // info header + private val biSize = intToDWord(INFO_HEADER_SIZE) + private val biPlanes = intToWord(1) + private val biBitCount = intToWord(BYTE_PER_PIXEL * 8) + private val biCompression = intToDWord(0) + private val biXPelsPerMeter = intToDWord(0) + private val biYPelsPerMeter = intToDWord(0) + private val biClrUsed = intToDWord(0) + private val biClrImportant = intToDWord(0) + + // converts an int to a word (2-byte array) + private fun intToWord(v: Int): ByteArray { + val retValue = ByteArray(2) + retValue[0] = (v and 0xFF).toByte() + retValue[1] = (v shr 8 and 0xFF).toByte() + return retValue + } + + // converts an int to a double word (4-byte array) + private fun intToDWord(v: Int): ByteArray { + val retValue = ByteArray(4) + retValue[0] = (v and 0xFF).toByte() + retValue[1] = (v shr 8 and 0xFF).toByte() + retValue[2] = (v shr 16 and 0xFF).toByte() + retValue[3] = (v shr 24 and 0xFF).toByte() + return retValue + } + + fun writeRGB24( + bitmap: Bitmap, + outputStream: OutputStream + ) { + // init + val biWidth = bitmap.width + val biHeight = bitmap.height + val padPerRow = (4 - (biWidth * BYTE_PER_PIXEL) % 4) % 4 + val biSizeImage = (biWidth * BYTE_PER_PIXEL + padPerRow) * biHeight + val bfSize = FILE_HEADER_SIZE + INFO_HEADER_SIZE + biSizeImage + val buffer = ByteBuffer.allocate(bfSize) + val pixels = IntArray(biWidth * biHeight) + bitmap.getPixels(pixels, 0, biWidth, 0, 0, biWidth, biHeight) + + // file header + buffer.put(bfType) + buffer.put(intToDWord(bfSize)) + buffer.put(bfReserved1) + buffer.put(bfReserved2) + buffer.put(bfOffBits) + + // info header + buffer.put(biSize) + buffer.put(intToDWord(biWidth)) + buffer.put(intToDWord(biHeight)) + buffer.put(biPlanes) + buffer.put(biBitCount) + buffer.put(biCompression) + buffer.put(intToDWord(biSizeImage)) + buffer.put(biXPelsPerMeter) + buffer.put(biYPelsPerMeter) + buffer.put(biClrUsed) + buffer.put(biClrImportant) + + // pixels + val rgb = ByteArray(BYTE_PER_PIXEL) + var value: Int + var row = biHeight - 1 + while (row >= 0) { + var column = 0 + while (column < biWidth) { + /* + alpha: (value shr 24 and 0xFF).toByte() + red: (value shr 16 and 0xFF).toByte() + green: (value shr 8 and 0xFF).toByte() + blue: (value and 0xFF).toByte() + */ + value = pixels[row * biWidth + column] + // blue: [0], green: [1], red: [2] + rgb[0] = (value and 0xFF).toByte() + rgb[1] = (value shr 8 and 0xFF).toByte() + rgb[2] = (value shr 16 and 0xFF).toByte() + buffer.put(rgb) + column++ + } + if (padPerRow > 0) { + buffer.put(pad, 0, padPerRow) + } + row-- + } + + // write to output stream + outputStream.write(buffer.array()) + } +} diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt index 59661cf8b..daef138a8 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt @@ -6,7 +6,7 @@ object MimeTypes { private const val IMAGE = "image" // generic raster - private const val BMP = "image/bmp" + const val BMP = "image/bmp" private const val DJVU = "image/vnd.djvu" const val GIF = "image/gif" const val HEIC = "image/heic" diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index e5d60f95e..2ac874501 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -75,7 +75,7 @@ abstract class ImageFileService { Stream export( Iterable entries, { - String mimeType = MimeTypes.jpeg, + @required String mimeType, @required String destinationAlbum, }); @@ -316,7 +316,7 @@ class PlatformImageFileService implements ImageFileService { @override Stream export( Iterable entries, { - String mimeType = MimeTypes.jpeg, + @required String mimeType, @required String destinationAlbum, }) { try { diff --git a/lib/widgets/viewer/entry_action_delegate.dart b/lib/widgets/viewer/entry_action_delegate.dart index 232740c53..b445dc0bc 100644 --- a/lib/widgets/viewer/entry_action_delegate.dart +++ b/lib/widgets/viewer/entry_action_delegate.dart @@ -5,6 +5,7 @@ import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/ref/mime_types.dart'; import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/image_op_events.dart'; import 'package:aves/services/services.dart'; @@ -183,7 +184,11 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix final selectionCount = selection.length; showOpReport( context: context, - opStream: imageFileService.export(selection, destinationAlbum: destinationAlbum), + opStream: imageFileService.export( + selection, + mimeType: MimeTypes.jpeg, + destinationAlbum: destinationAlbum, + ), itemCount: selectionCount, onDone: (processed) { final movedOps = processed.where((e) => e.success);