build: compile release APK and update dependencies (expo-image-manipulator/loader)

This commit is contained in:
2026-05-31 14:24:26 +02:00
parent 69637c4203
commit 2b5a259fe5
1260 changed files with 3804 additions and 166620 deletions
+19
View File
@@ -0,0 +1,19 @@
plugins {
id 'com.android.library'
id 'expo-module-gradle-plugin'
}
group = 'host.exp.exponent'
version = '14.0.8'
android {
namespace "expo.modules.imagemanipulator"
defaultConfig {
versionCode 23
versionName "14.0.8"
}
}
dependencies {
api "androidx.annotation:annotation:1.0.0"
}
@@ -0,0 +1,2 @@
<manifest>
</manifest>
@@ -0,0 +1,23 @@
package expo.modules.imagemanipulator
import android.content.Context
import java.io.File
import java.io.IOException
import java.util.*
internal object FileUtils {
@Throws(IOException::class)
fun generateRandomOutputPath(context: Context, imageFormat: ImageFormat): String {
val directory = File("${context.cacheDir}${File.separator}ImageManipulator")
ensureDirExists(directory)
return "${directory}${File.separator}${UUID.randomUUID()}${imageFormat.fileExtension}"
}
@Throws(IOException::class)
private fun ensureDirExists(dir: File): File {
if (!(dir.isDirectory || dir.mkdirs())) {
throw ImageWriteFailedException(dir.path)
}
return dir
}
}
@@ -0,0 +1,80 @@
package expo.modules.imagemanipulator
import android.graphics.Bitmap
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
import expo.modules.kotlin.types.Enumerable
/**
* Options provided for resize action.
*/
class ResizeOptions : Record {
@Field
val width: Int? = null
@Field
val height: Int? = null
}
/**
* Cropping rect for crop action.
*/
class CropRect : Record {
@Field
val originX: Double = 0.0
@Field
val originY: Double = 0.0
@Field
val width: Double = 0.0
@Field
val height: Double = 0.0
}
/**
* Options to use when saving the resulted image.
*/
class ManipulateOptions : Record {
@Field
val base64: Boolean = false
@Field
val compress: Double = 1.0
@Field
val format: ImageFormat = ImageFormat.JPEG
}
/**
* Possible options for flip action.
*/
enum class FlipType(val value: String) : Enumerable {
VERTICAL("vertical"),
HORIZONTAL("horizontal")
}
/**
* Enum with supported image formats.
*/
enum class ImageFormat(val value: String) : Enumerable {
JPEG("jpeg"),
JPG("jpg"),
PNG("png"),
WEBP("webp");
val fileExtension: String
get() = when (this) {
JPEG, JPG -> ".jpg"
PNG -> ".png"
WEBP -> ".webp"
}
val compressFormat: Bitmap.CompressFormat
get() = when (this) {
JPEG, JPG -> Bitmap.CompressFormat.JPEG
PNG -> Bitmap.CompressFormat.PNG
WEBP -> Bitmap.CompressFormat.WEBP
}
}
@@ -0,0 +1,91 @@
package expo.modules.imagemanipulator
import android.graphics.Bitmap
import expo.modules.imagemanipulator.transformers.ImageTransformer
import expo.modules.kotlin.RuntimeContext
import expo.modules.kotlin.exception.CodedException
import expo.modules.kotlin.exception.toCodedException
import expo.modules.kotlin.sharedobjects.SharedObject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
data class ManipulatorResult(
private val value: Bitmap?,
private val error: CodedException?
) {
fun map(transformer: ImageTransformer): ManipulatorResult {
if (error != null) {
return ManipulatorResult(null, error)
}
return try {
ManipulatorResult(
transformer.transform(
requireNotNull(value) { "The result doesn't have a value or error" }
),
null
)
} catch (e: Throwable) {
ManipulatorResult(null, e.toCodedException())
}
}
fun get(): Bitmap {
if (error != null) {
throw error
}
return requireNotNull(value) { "The result doesn't have a value or error" }
}
}
class ManipulatorTask(
private val coroutineScope: CoroutineScope,
private val loader: suspend () -> Bitmap
) {
private var task: Deferred<ManipulatorResult> = launchLoader()
private fun launchLoader(): Deferred<ManipulatorResult> = coroutineScope.async {
try {
ManipulatorResult(loader(), null)
} catch (e: Throwable) {
ManipulatorResult(null, e.toCodedException())
}
}
fun addTransformer(transformer: ImageTransformer) {
val oldTask = task
task = coroutineScope.async {
val currentValue = oldTask.await()
return@async currentValue.map(transformer)
}
}
suspend fun render(): Bitmap {
return task.await().get()
}
fun reset() {
task.cancel()
task = launchLoader()
}
fun cancel() {
task.cancel()
}
}
class ImageManipulatorContext(
runtimeContext: RuntimeContext,
private val task: ManipulatorTask
) : SharedObject(runtimeContext) {
fun addTransformer(transformer: ImageTransformer) = apply { task.addTransformer(transformer) }
fun reset() = apply { task.reset() }
suspend fun render() = task.render()
override fun sharedObjectDidRelease() {
task.cancel()
}
}
@@ -0,0 +1,16 @@
package expo.modules.imagemanipulator
import expo.modules.kotlin.exception.CodedException
import expo.modules.kotlin.exception.DecoratedException
internal class ImageInvalidCropException :
CodedException("Invalid crop options has been passed. Please make sure the requested crop rectangle is inside source image")
internal class ImageLoaderNotFoundException :
CodedException(message = "ImageLoader module not found, make sure 'expo-image-loader' is linked correctly")
internal class ImageLoadingFailedException(image: String, cause: CodedException) :
DecoratedException(message = "Could not load the image: $image", cause)
internal class ImageWriteFailedException(file: String) :
CodedException(message = "Writing image data to the file has failed: $file")
@@ -0,0 +1,148 @@
@file:OptIn(EitherType::class)
package expo.modules.imagemanipulator
import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.net.Uri
import android.util.Base64
import expo.modules.imagemanipulator.transformers.CropTransformer
import expo.modules.imagemanipulator.transformers.FlipTransformer
import expo.modules.imagemanipulator.transformers.ResizeTransformer
import expo.modules.imagemanipulator.transformers.RotateTransformer
import expo.modules.interfaces.imageloader.ImageLoaderInterface.ResultListener
import expo.modules.kotlin.apifeatures.EitherType
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.exception.toCodedException
import expo.modules.kotlin.functions.Coroutine
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.sharedobjects.SharedRef
import expo.modules.kotlin.types.EitherOfThree
import expo.modules.kotlin.types.toKClass
import kotlinx.coroutines.async
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
class ImageManipulatorModule : Module() {
private val context: Context
get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
private fun createManipulatorContext(url: Uri): ImageManipulatorContext {
val loader = suspend {
val imageLoader = appContext.imageLoader
?: throw ImageLoaderNotFoundException()
suspendCancellableCoroutine { continuation ->
imageLoader.loadImageForManipulationFromURL(
url.toString(),
object : ResultListener {
override fun onSuccess(bitmap: Bitmap) {
continuation.resume(bitmap)
}
override fun onFailure(cause: Throwable?) {
continuation.resumeWithException(ImageLoadingFailedException(url.toString(), cause.toCodedException()))
}
}
)
}
}
val task = ManipulatorTask(appContext.backgroundCoroutineScope, loader)
return ImageManipulatorContext(runtimeContext, task)
}
private fun createManipulatorContext(bitmap: Bitmap): ImageManipulatorContext {
val task = ManipulatorTask(appContext.backgroundCoroutineScope) { bitmap }
return ImageManipulatorContext(runtimeContext, task)
}
override fun definition() = ModuleDefinition {
Name("ExpoImageManipulator")
Function("manipulate") { url: EitherOfThree<Uri, SharedRef<Bitmap>, SharedRef<Drawable>> ->
return@Function if (url.`is`(Uri::class)) {
createManipulatorContext(url.get(Uri::class))
} else if (url.`is`(toKClass<SharedRef<Bitmap>>())) {
val bitmap = url.get(toKClass<SharedRef<Bitmap>>()).ref
createManipulatorContext(bitmap)
} else {
val drawable = url.get(toKClass<SharedRef<Drawable>>()).ref
val bitmap = (drawable as? BitmapDrawable)?.bitmap
?: throw Exceptions.IllegalArgument("The drawable cannot be converted to a bitmap")
createManipulatorContext(bitmap)
}
}
Class<ImageManipulatorContext>("Context") {
Constructor { url: Uri ->
createManipulatorContext(url)
}
Function("resize") { context: ImageManipulatorContext, options: ResizeOptions ->
context.addTransformer(ResizeTransformer(options))
}
Function("rotate") { context: ImageManipulatorContext, rotation: Float ->
context.addTransformer(RotateTransformer(rotation))
}
Function("flip") { context: ImageManipulatorContext, flipType: FlipType ->
context.addTransformer(FlipTransformer(flipType))
}
Function("crop") { context: ImageManipulatorContext, rect: CropRect ->
context.addTransformer(CropTransformer(rect))
}
Function("reset") { context: ImageManipulatorContext ->
context.reset()
}
AsyncFunction("renderAsync") Coroutine { context: ImageManipulatorContext ->
val image = context.render()
ImageRef(image, runtimeContext)
}
}
Class<ImageRef>("Image") {
Property("width") { image: ImageRef -> image.ref.width }
Property("height") { image: ImageRef -> image.ref.height }
AsyncFunction("saveAsync") Coroutine { image: ImageRef, options: ManipulateOptions? ->
val options = options ?: ManipulateOptions()
val path = FileUtils.generateRandomOutputPath(context, options.format)
val compression = (options.compress * 100).toInt()
val resultBitmap = image.ref
var base64String: String? = null
appContext.backgroundCoroutineScope.async {
FileOutputStream(path).use { fileOut ->
val compressFormat = options.format.compressFormat
resultBitmap.compress(compressFormat, compression, fileOut)
if (options.base64) {
ByteArrayOutputStream().use { byteOut ->
resultBitmap.compress(compressFormat, compression, byteOut)
base64String = Base64.encodeToString(byteOut.toByteArray(), Base64.NO_WRAP)
}
}
}
}.await()
mapOf(
"uri" to Uri.fromFile(File(path)).toString(),
"width" to resultBitmap.width,
"height" to resultBitmap.height,
"base64" to base64String
)
}
}
}
}
@@ -0,0 +1,13 @@
package expo.modules.imagemanipulator
import android.graphics.Bitmap
import expo.modules.kotlin.RuntimeContext
import expo.modules.kotlin.sharedobjects.SharedRef
class ImageRef(bitmap: Bitmap, runtimeContext: RuntimeContext) : SharedRef<Bitmap>(bitmap, runtimeContext) {
override val nativeRefType: String = "image"
override fun getAdditionalMemoryPressure(): Int {
return ref.allocationByteCount
}
}
@@ -0,0 +1,21 @@
package expo.modules.imagemanipulator.transformers
import android.graphics.Bitmap
import expo.modules.imagemanipulator.CropRect
import expo.modules.imagemanipulator.ImageInvalidCropException
class CropTransformer(
private val rect: CropRect
) : ImageTransformer {
override fun transform(bitmap: Bitmap): Bitmap {
val isInBounds = rect.originX <= bitmap.width &&
rect.originY <= bitmap.height &&
rect.width <= bitmap.width &&
rect.height <= bitmap.height
if (!isInBounds) {
throw ImageInvalidCropException()
}
return Bitmap.createBitmap(bitmap, rect.originX.toInt(), rect.originY.toInt(), rect.width.toInt(), rect.height.toInt())
}
}
@@ -0,0 +1,21 @@
package expo.modules.imagemanipulator.transformers
import android.graphics.Bitmap
import android.graphics.Matrix
import expo.modules.imagemanipulator.FlipType
class FlipTransformer(
private val flipType: FlipType
) : ImageTransformer {
override fun transform(bitmap: Bitmap): Bitmap {
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, rotationMatrix, true)
}
private val rotationMatrix: Matrix
get() = Matrix().apply {
when (flipType) {
FlipType.VERTICAL -> postScale(1f, -1f)
FlipType.HORIZONTAL -> postScale(-1f, 1f)
}
}
}
@@ -0,0 +1,8 @@
package expo.modules.imagemanipulator.transformers
import android.graphics.Bitmap
@FunctionalInterface
interface ImageTransformer {
fun transform(bitmap: Bitmap): Bitmap
}
@@ -0,0 +1,27 @@
package expo.modules.imagemanipulator.transformers
import android.graphics.Bitmap
import expo.modules.imagemanipulator.ResizeOptions
class ResizeTransformer(
private val resizeOptions: ResizeOptions
) : ImageTransformer {
override fun transform(bitmap: Bitmap): Bitmap {
var targetWidth = 0
var targetHeight = 0
val imageRatio = bitmap.width.toDouble() / bitmap.height.toDouble()
if (resizeOptions.width != null) {
targetWidth = resizeOptions.width
targetHeight = (resizeOptions.width / imageRatio).toInt()
}
if (resizeOptions.height != null) {
targetHeight = resizeOptions.height
targetWidth = if (targetWidth == 0) (resizeOptions.height * imageRatio).toInt() else targetWidth
}
return Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, true)
}
}
@@ -0,0 +1,13 @@
package expo.modules.imagemanipulator.transformers
import android.graphics.Bitmap
import android.graphics.Matrix
class RotateTransformer(private val rotation: Float) : ImageTransformer {
override fun transform(bitmap: Bitmap): Bitmap {
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, rotationMatrix, true)
}
private val rotationMatrix: Matrix
get() = Matrix().apply { postRotate(rotation) }
}