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
+37
View File
@@ -0,0 +1,37 @@
require 'json'
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
Pod::Spec.new do |s|
s.name = 'ExpoImageManipulator'
s.version = package['version']
s.summary = package['description']
s.description = package['description']
s.license = package['license']
s.author = package['author']
s.homepage = package['homepage']
s.platforms = {
:ios => '15.1',
:tvos => '15.1'
}
s.swift_version = '5.9'
s.source = { git: 'https://github.com/expo/expo.git' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.dependency 'EXImageLoader'
s.dependency 'SDWebImageWebPCoder'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
if !$ExpoUseSources&.include?(package['name']) && ENV['EXPO_USE_SOURCE'].to_i == 0 && File.exist?("#{s.name}.xcframework") && Gem::Version.new(Pod::VERSION) >= Gem::Version.new('1.10.0')
s.source_files = "**/*.h"
s.vendored_frameworks = "#{s.name}.xcframework"
else
s.source_files = "**/*.{h,m,swift}"
end
end
@@ -0,0 +1,79 @@
// Copyright 2021-present 650 Industries. All rights reserved.
import CoreGraphics
import ExpoModulesCore
/**
Options provided for resize action.
*/
internal struct ResizeOptions: Record {
@Field
var width: CGFloat?
@Field
var height: CGFloat?
}
/**
Cropping rect for crop action.
*/
internal struct CropRect: Record {
@Field
var originX: Double = 0.0
@Field
var originY: Double = 0.0
@Field
var width: Double = 0.0
@Field
var height: Double = 0.0
func toRect() -> CGRect {
return CGRect(x: originX, y: originY, width: width, height: height)
}
}
/**
Options to use when saving the resulted image.
*/
internal struct ManipulateOptions: Record {
@Field
var base64: Bool = false
@Field
var compress: Double = 1.0
@Field
var format: ImageFormat = .jpeg
}
/**
Possible options for flip action.
*/
internal enum FlipType: String, Enumerable {
case vertical
case horizontal
}
/**
Enum with supported image formats.
*/
internal enum ImageFormat: String, Enumerable {
case jpeg
case jpg
case png
case webp
var fileExtension: String {
switch self {
case .jpeg, .jpg:
return ".jpg"
case .png:
return ".png"
case .webp:
return ".webp"
}
}
}
+67
View File
@@ -0,0 +1,67 @@
// Copyright 2024-present 650 Industries. All rights reserved.
import ExpoModulesCore
/**
A context of the image manipulation.
*/
public final class ImageManipulatorContext: SharedObject {
internal typealias Loader = () async throws -> UIImage
/**
The last task added to the rendering pipeline.
*/
private var currentTask: Task<UIImage, Error>
/**
A function that was used to load the original image.
Can be used to reset context's state to the original image.
*/
private let loader: Loader
/**
Initializes a manipulation context with the given loader that returns the original image.
*/
init(loader: @escaping Loader) {
self.loader = loader
currentTask = Task(priority: .background) {
return try await loader()
}
super.init()
}
/**
Adds an image transformer to run on the rendering context in the background.
*/
@discardableResult
internal func addTransformer(_ transformer: ImageTransformer) -> Self {
currentTask = Task(priority: .background) { [currentTask] in
// The task can be canceled in the meantime (e.g. by resetting to the original image).
// In this case there is no reason to transform the image any further.
try Task.checkCancellation()
let image = try await currentTask.value
return try await transformer.transform(image: image)
}
return self
}
/**
Awaits for the last processing task to finish and returns its result.
*/
internal func render() async throws -> UIImage {
return try await currentTask.value
}
/**
Resets the manipulator context to the originally loaded image.
*/
internal func reset() {
// Firstly cancel currently running manipulations.
currentTask.cancel()
currentTask = Task(priority: .background) {
return try await loader()
}
}
}
@@ -0,0 +1,82 @@
// Copyright 2021-present 650 Industries. All rights reserved.
import CoreGraphics
import ExpoModulesCore
internal final class ImageContextLostException: Exception {
override var reason: String {
"Image context has been lost"
}
}
internal final class ImageDrawingFailedException: Exception {
override var reason: String {
"Drawing the new image failed"
}
}
internal final class ImageNotFoundException: Exception {
override var reason: String {
"Image cannot be found"
}
}
internal final class ImageColorSpaceNotFoundException: Exception {
override var reason: String {
"The image does not specify any color space"
}
}
internal final class ImageInvalidCropException: Exception {
override var reason: String {
"Invalid crop options has been passed. Please make sure the requested crop rectangle is inside source image"
}
}
internal final class ImageCropFailedException: GenericException<CGRect> {
override var reason: String {
"Cropping the image to rectangle (x: \(param.origin.x), y: \(param.origin.y), width: \(param.width), height: \(param.height)) has failed"
}
}
internal final class NoImageInContextException: Exception {
override var reason: String {
"Could not read the image from the drawing context"
}
}
internal final class ImageLoaderNotFoundException: Exception {
override var reason: String {
"ImageLoader module not found, make sure 'expo-image-loader' is linked correctly"
}
}
internal final class FileSystemNotFoundException: Exception {
override var reason: String {
"FileSystem module not found, make sure 'expo-file-system' is linked correctly"
}
}
internal final class FileSystemReadPermissionException: GenericException<String> {
override var reason: String {
"File '\(param)' is not readable"
}
}
internal final class ImageLoadingFailedException: GenericException<String> {
override var reason: String {
"Could not load the image: \(param)"
}
}
internal final class CorruptedImageDataException: Exception {
override var reason: String {
"Cannot create image data for given image format"
}
}
internal final class ImageWriteFailedException: GenericException<String> {
override var reason: String {
"Writing image data to the file has failed: \(param)"
}
}
+89
View File
@@ -0,0 +1,89 @@
// Copyright 2021-present 650 Industries. All rights reserved.
import CoreGraphics
import Photos
import UIKit
import ExpoModulesCore
import SDWebImageWebPCoder
public class ImageManipulatorModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoImageManipulator")
Function("manipulate") { (source: Either<URL, SharedRef<UIImage>>) -> ImageManipulatorContext in
let context = ImageManipulatorContext { [weak appContext] in
guard let appContext else {
throw Exceptions.AppContextLost()
}
if let url: URL = source.get() {
return try await loadImage(atUrl: url, appContext: appContext)
}
if let image: SharedRef<UIImage> = source.get() {
return image.ref
}
throw Exceptions.RuntimeLost()
}
// Immediately try to fix the orientation once the image is loaded
context.addTransformer(ImageFixOrientationTransformer())
return context
}
Class("Context", ImageManipulatorContext.self) {
Function("resize") { (context: ImageManipulatorContext, options: ResizeOptions) -> ImageManipulatorContext in
return context.addTransformer(ImageResizeTransformer(options: options))
}
Function("rotate") { (context: ImageManipulatorContext, rotate: Double) -> ImageManipulatorContext in
return context.addTransformer(ImageRotateTransformer(rotate: rotate))
}
Function("flip") { (context: ImageManipulatorContext, flipType: FlipType) -> ImageManipulatorContext in
return context.addTransformer(ImageFlipTransformer(flip: flipType))
}
Function("crop") { (context: ImageManipulatorContext, rect: CropRect) -> ImageManipulatorContext in
return context.addTransformer(ImageCropTransformer(options: rect))
}
Function("reset") { (context: ImageManipulatorContext) -> ImageManipulatorContext in
context.reset()
return context
}
AsyncFunction("renderAsync") { (context: ImageManipulatorContext) -> ImageRef in
let image = try await context.render()
return ImageRef(image)
}
}
Class("Image", ImageRef.self) {
Property("width") { (image: ImageRef) -> Int in
return image.ref.cgImage?.width ?? 0
}
Property("height") { (image: ImageRef) -> Int in
return image.ref.cgImage?.height ?? 0
}
AsyncFunction("saveAsync") { (image: ImageRef, options: ManipulateOptions?) -> [String: Any?] in
guard let appContext else {
throw Exceptions.AppContextLost()
}
let options = options ?? ManipulateOptions()
let result = try saveImage(image.ref, options: options, appContext: appContext)
// We're returning a dict instead of a path directly because in the future we'll replace it
// with a shared ref to the file once this feature gets implemented in expo-file-system.
// This should be fully backwards-compatible switch.
return [
"uri": result.url.absoluteString,
"width": image.ref.cgImage?.width ?? 0,
"height": image.ref.cgImage?.height ?? 0,
"base64": options.base64 ? result.data.base64EncodedString() : nil
]
}
}
}
}
+128
View File
@@ -0,0 +1,128 @@
import SDWebImageWebPCoder
import Photos
import ExpoModulesCore
internal typealias SaveImageResult = (url: URL, data: Data)
/**
Loads the image from given URL.
*/
internal func loadImage(atUrl url: URL, appContext: AppContext) async throws -> UIImage {
if url.scheme == "data" {
guard let data = try? Data(contentsOf: url), let image = UIImage(data: data) else {
throw CorruptedImageDataException()
}
return image
}
if url.scheme == "ph" || url.scheme == "assets-library" {
return try await loadImageFromPhotoLibrary(url: url)
}
guard let imageLoader = appContext.imageLoader else {
throw ImageLoaderNotFoundException()
}
guard FileSystemUtilities.permissions(appContext, for: url).contains(.read) && FileManager.default.isReadableFile(atPath: url.path) else {
throw FileSystemReadPermissionException(url.absoluteString)
}
do {
if let result = try await imageLoader.loadImage(for: url) {
return result
}
} catch {
throw ImageLoadingFailedException((error as NSError).debugDescription)
}
// TODO: throw something better
throw ImageLoadingFailedException("")
}
/**
Loads the image from user's photo library.
*/
internal func loadImageFromPhotoLibrary(url: URL) async throws -> UIImage {
guard let asset = retrieveAsset(from: url) else {
throw ImageNotFoundException()
}
let size = CGSize(width: asset.pixelWidth, height: asset.pixelHeight)
let options = PHImageRequestOptions()
options.resizeMode = .exact
options.isNetworkAccessAllowed = true
options.isSynchronous = true
options.deliveryMode = .highQualityFormat
return try await withCheckedThrowingContinuation { continuation in
PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFit, options: options) { image, _ in
if let image {
continuation.resume(returning: image)
} else {
continuation.resume(throwing: ImageNotFoundException())
}
}
}
}
/**
Returns pixel data representation of the image.
*/
func imageData(from image: UIImage, format: ImageFormat, compression: Double) -> Data? {
switch format {
case .jpeg, .jpg:
return image.jpegData(compressionQuality: compression)
case .png:
return image.pngData()
case .webp:
return SDImageWebPCoder.shared.encodedData(with: image, format: .webP, options: [.encodeCompressionQuality: compression])
}
}
/**
Checks if we are dealing with a ph asset URL and uses the correct method to fetch it.
*/
func retrieveAsset(from url: URL) -> PHAsset? {
if url.scheme == "ph" {
let identifier = String(url.absoluteString.dropFirst(5)) // removes ph://
return PHAsset.fetchAssets(withLocalIdentifiers: [identifier], options: nil).firstObject
}
return PHAsset.fetchAssets(withALAssetURLs: [url], options: nil).firstObject
}
/**
Helper function for drawing the image in graphics context.
Throws appropriate exceptions when the context is missing or the image couldn't be rendered.
*/
internal func drawInNewContext(size: CGSize, drawing: (UIGraphicsImageRendererContext) -> Void) -> UIImage {
let format = UIGraphicsImageRendererFormat()
format.scale = 1
let renderer = UIGraphicsImageRenderer(size: size, format: format)
return renderer.image { context in
drawing(context)
}
}
/**
Saves the image as a file.
*/
internal func saveImage(_ image: UIImage, options: ManipulateOptions, appContext: AppContext) throws -> SaveImageResult {
guard let cachesDirectory = appContext.config.cacheDirectory else {
throw FileSystemNotFoundException()
}
let directory = URL(fileURLWithPath: cachesDirectory.path).appendingPathComponent("ImageManipulator")
let filename = UUID().uuidString.appending(options.format.fileExtension)
let fileUrl = directory.appendingPathComponent(filename)
FileSystemUtilities.ensureDirExists(at: directory)
guard let data = imageData(from: image, format: options.format, compression: options.compress) else {
throw CorruptedImageDataException()
}
do {
try data.write(to: fileUrl, options: .atomic)
} catch let error {
throw ImageWriteFailedException(error.localizedDescription)
}
return (url: fileUrl, data: data)
}
+19
View File
@@ -0,0 +1,19 @@
// Copyright 2024-present 650 Industries. All rights reserved.
import ExpoModulesCore
/**
Represents a shared reference to the `UIImage` instance.
*/
internal final class ImageRef: SharedRef<UIImage> {
override var nativeRefType: String {
"image"
}
override func getAdditionalMemoryPressure() -> Int {
guard let cgImage = ref.cgImage else {
return 0
}
return cgImage.bytesPerRow * cgImage.height
}
}
+5
View File
@@ -0,0 +1,5 @@
// Copyright 2024-present 650 Industries. All rights reserved.
internal protocol ImageTransformer {
func transform(image: UIImage) async throws -> UIImage
}
@@ -0,0 +1,24 @@
// Copyright 2024-present 650 Industries. All rights reserved.
/**
Transformer that creates a new image by cropping given image to specified width and height.
*/
internal struct ImageCropTransformer: ImageTransformer {
let options: CropRect
func transform(image: UIImage) async throws -> UIImage {
let rect = options.toRect()
let isOutOfBounds = rect.origin.x > image.size.width
|| rect.origin.y > image.size.height
|| rect.width > image.size.width
|| rect.height > image.size.height
guard !isOutOfBounds else {
throw ImageInvalidCropException()
}
guard let cgImage = image.cgImage?.cropping(to: rect) else {
throw ImageCropFailedException(rect)
}
return UIImage(cgImage: cgImage, scale: image.scale, orientation: image.imageOrientation)
}
}
@@ -0,0 +1,75 @@
// Copyright 2024-present 650 Industries. All rights reserved.
/**
Transformer that makes sure the image is oriented up and not mirrored.
Guarantees that the original pixel data matches the displayed orientation.
*/
internal struct ImageFixOrientationTransformer: ImageTransformer {
func transform(image: UIImage) async throws -> UIImage {
guard let cgImage = image.cgImage else {
throw ImageNotFoundException()
}
guard var colorSpace = cgImage.colorSpace else {
// That should never happen as `colorSpace` is empty only when the image is a mask.
throw ImageColorSpaceNotFoundException()
}
if !colorSpace.supportsOutput {
colorSpace = CGColorSpaceCreateDeviceRGB()
}
var transform = CGAffineTransform.identity
switch image.imageOrientation {
case .down, .downMirrored:
transform = transform.translatedBy(x: image.size.width, y: image.size.height)
transform = transform.rotated(by: Double.pi)
case .left, .leftMirrored:
transform = transform.translatedBy(x: image.size.width, y: 0)
transform = transform.rotated(by: Double.pi / 2)
case .right, .rightMirrored:
transform = transform.translatedBy(x: 0, y: image.size.height)
transform = transform.rotated(by: -Double.pi / 2)
default:
break
}
switch image.imageOrientation {
case .upMirrored, .downMirrored:
transform = transform.translatedBy(x: image.size.width, y: 0)
transform = transform.scaledBy(x: -1, y: 1)
case .leftMirrored, .rightMirrored:
transform = transform.translatedBy(x: image.size.height, y: 0)
transform = transform.scaledBy(x: -1, y: 1)
default:
break
}
let context = CGContext(
data: nil,
width: Int(image.size.width),
height: Int(image.size.height),
bitsPerComponent: cgImage.bitsPerComponent,
bytesPerRow: 0,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
)
guard let context = context else {
throw ImageContextLostException()
}
context.concatenate(transform)
switch image.imageOrientation {
case .left, .leftMirrored, .right, .rightMirrored:
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: image.size.height, height: image.size.width))
default:
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height))
}
guard let newCGImage = context.makeImage() else {
throw ImageDrawingFailedException()
}
return UIImage(cgImage: newCGImage)
}
}
@@ -0,0 +1,25 @@
// Copyright 2024-present 650 Industries. All rights reserved.
/**
Transformer that creates a new image by flipping given image vertically or horizontally.
*/
internal struct ImageFlipTransformer: ImageTransformer {
let flip: FlipType
@MainActor
func transform(image: UIImage) async -> UIImage {
let imageView = UIImageView(image: image)
return drawInNewContext(size: imageView.frame.size) { context in
switch flip {
case .vertical:
let transform = CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: imageView.frame.size.height)
context.cgContext.concatenate(transform)
case .horizontal:
let transform = CGAffineTransform(a: -1, b: 0, c: 0, d: 1, tx: imageView.frame.size.width, ty: 0)
context.cgContext.concatenate(transform)
}
imageView.layer.render(in: context.cgContext)
}
}
}
@@ -0,0 +1,34 @@
// Copyright 2024-present 650 Industries. All rights reserved.
/**
Transformer that draws a new image by resizing given image to specified size.
*/
internal struct ImageResizeTransformer: ImageTransformer {
let options: ResizeOptions
func transform(image: UIImage) async -> UIImage {
let imageWidth = image.size.width
let imageHeight = image.size.height
let imageRatio = imageWidth / imageHeight
var targetSize = CGSize.zero
if let width = options.width {
targetSize.width = width
targetSize.height = width / imageRatio
}
if let height = options.height {
targetSize.height = height
targetSize.width = targetSize.width == 0 ? imageRatio * targetSize.height : targetSize.width
}
let format = UIGraphicsImageRendererFormat()
format.opaque = false
format.scale = 1
let renderer = UIGraphicsImageRenderer(size: targetSize, format: format)
return renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: targetSize))
}
}
}
@@ -0,0 +1,30 @@
// Copyright 2024-present 650 Industries. All rights reserved.
/**
Transformer that creates a new image by rotating given image by the rotate angle.
*/
internal struct ImageRotateTransformer: ImageTransformer {
let rotate: Double
@MainActor
func transform(image: UIImage) async throws -> UIImage {
guard let cgImage = image.cgImage else {
throw ImageNotFoundException()
}
let rads = rotate * .pi / 180
let rotatedView = UIView(frame: CGRect(origin: .zero, size: image.size))
rotatedView.transform = CGAffineTransform(rotationAngle: rads)
let rotatedSize = CGSize(width: rotatedView.frame.size.width.rounded(.down), height: rotatedView.frame.size.height.rounded(.down))
let origin = CGPoint(x: -image.size.width / 2, y: -image.size.height / 2)
return drawInNewContext(size: rotatedSize) { context in
let cgContext = context.cgContext
cgContext.translateBy(x: rotatedSize.width / 2, y: rotatedSize.height / 2)
cgContext.rotate(by: rads)
cgContext.scaleBy(x: 1.0, y: -1.0)
cgContext.draw(cgImage, in: CGRect(origin: origin, size: image.size))
}
}
}