first commit

This commit is contained in:
2026-03-10 16:18:05 +00:00
commit 11f9c069b5
31635 changed files with 3187747 additions and 0 deletions

56
node_modules/expo-image/android/build.gradle generated vendored Normal file
View File

@@ -0,0 +1,56 @@
buildscript {
dependencies {
classpath "com.google.devtools.ksp:symbol-processing-gradle-plugin:${rootProject["kspVersion"]}"
}
}
plugins {
id 'com.android.library'
id 'kotlin-kapt'
id 'expo-module-gradle-plugin'
}
apply plugin: 'com.google.devtools.ksp'
group = 'expo.modules.image'
version = '55.0.6'
android {
namespace "expo.modules.image"
defaultConfig {
versionCode 1
versionName "55.0.6"
consumerProguardFiles("proguard-rules.pro")
buildConfigField("boolean", "ALLOW_GLIDE_LOGS", project.properties.get("EXPO_ALLOW_GLIDE_LOGS", "false"))
}
sourceSets {
main {
java {
if (expoModule.safeExtGet("excludeAppGlideModule", false)) {
exclude("**/ExpoImageAppGlideModule.kt")
}
}
}
}
}
dependencies {
def GLIDE_VERSION = "5.0.5"
implementation 'com.facebook.react:react-android'
api "com.github.bumptech.glide:glide:${GLIDE_VERSION}"
ksp "com.github.bumptech.glide:ksp:${GLIDE_VERSION}"
api 'com.caverock:androidsvg-aar:1.4'
implementation "com.github.penfeizhou.android.animation:glide-plugin:3.0.5"
implementation "com.github.bumptech.glide:avif-integration:${GLIDE_VERSION}"
api "com.github.bumptech.glide:okhttp3-integration:${GLIDE_VERSION}"
api "com.squareup.okhttp3:okhttp:${expoModule.safeExtGet("okHttpVersion", '4.9.2')}"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2'
implementation "jp.wasabeef:glide-transformations:4.3.0"
}

28
node_modules/expo-image/android/proguard-rules.pro generated vendored Normal file
View File

@@ -0,0 +1,28 @@
# https://bumptech.github.io/glide/doc/download-setup.html#proguard
-keep public class * extends com.bumptech.glide.module.LibraryGlideModule
-keep public class * implements com.bumptech.glide.module.GlideModule
-keep class * extends com.bumptech.glide.module.AppGlideModule {
<init>(...);
}
-keep public enum com.bumptech.glide.load.ImageHeaderParser$** {
**[] $VALUES;
public *;
}
-keep class com.bumptech.glide.load.data.ParcelFileDescriptorRewinder$InternalRewinder {
*** rewind();
}
-keep public class com.bumptech.glide.request.ThumbnailRequestCoordinator {
*;
}
-dontwarn com.bumptech.glide.load.resource.bitmap.VideoDecoder
# https://bumptech.github.io/glide/doc/configuration.html#applications
-keep class com.bumptech.glide.GeneratedAppGlideModuleImpl
-keep public class com.bumptech.glide.integration.webp.WebpImage { *; }
-keep public class com.bumptech.glide.integration.webp.WebpFrame { *; }
-keep public class com.bumptech.glide.integration.webp.WebpBitmapFactory { *; }

View File

@@ -0,0 +1,17 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Begin Glide configuration -->
<!-- Internet access (https://bumptech.github.io/glide/doc/download-setup.html#internet) -->
<uses-permission android:name="android.permission.INTERNET" />
<!--
Allows Glide to monitor connectivity status and restart failed requests if users go from a
a disconnected to a connected network state.
-->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Local storage (https://bumptech.github.io/glide/doc/download-setup.html#local-storage) -->
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<!-- End Glide configuration -->
</manifest>

View File

@@ -0,0 +1,125 @@
package com.caverock.androidsvg
import com.caverock.androidsvg.SVG.SPECIFIED_COLOR
import com.caverock.androidsvg.SVG.SPECIFIED_FILL
import com.caverock.androidsvg.SVG.SvgElementBase
internal fun replaceColor(paint: SVG.SvgPaint?, newColor: Int) {
if (paint is SVG.Colour && paint !== SVG.Colour.TRANSPARENT) {
paint.colour = newColor
}
}
internal fun replaceStyles(style: SVG.Style?, newColor: Int) {
if (style == null) {
return
}
replaceColor(style.color, newColor)
replaceColor(style.fill, newColor)
replaceColor(style.stroke, newColor)
replaceColor(style.stopColor, newColor)
replaceColor(style.solidColor, newColor)
replaceColor(style.viewportFill, newColor)
}
internal fun hasStyle(element: SvgElementBase): Boolean {
if (element.style == null && element.baseStyle == null) {
return false
}
val style = element.style
val hasColorInStyle = style != null &&
(
style.color != null || style.fill != null || style.stroke != null ||
style.stroke != null || style.stopColor != null || style.solidColor != null
)
if (hasColorInStyle) {
return true
}
val baseStyle = element.baseStyle ?: return false
return baseStyle.color != null || baseStyle.fill != null || baseStyle.stroke != null ||
baseStyle.viewportFill != null || baseStyle.stopColor != null || baseStyle.solidColor != null
}
internal fun defineStyles(element: SvgElementBase, newColor: Int, hasStyle: Boolean) {
if (hasStyle) {
return
}
val style = if (element.style != null) {
element.style
} else {
SVG.Style().also {
element.style = it
}
}
val color = SVG.Colour(newColor)
when (element) {
is SVG.Path,
is SVG.Circle,
is SVG.Ellipse,
is SVG.Rect,
is SVG.SolidColor,
is SVG.Line,
is SVG.Polygon,
is SVG.PolyLine -> {
style.apply {
fill = color
specifiedFlags = SPECIFIED_FILL
}
}
is SVG.TextPath -> {
style.apply {
this.color = color
specifiedFlags = SPECIFIED_COLOR
}
}
}
}
internal fun applyTintColor(element: SVG.SvgObject, newColor: Int, parentDefinesStyle: Boolean) {
// We want to keep the colors in the mask as they control the visibility of the element to which the mask is applied.
if (element is SVG.Mask) {
return
}
val definesStyle = if (element is SvgElementBase) {
val hasStyle = parentDefinesStyle || hasStyle(element)
replaceStyles(element.baseStyle, newColor)
replaceStyles(element.style, newColor)
defineStyles(element, newColor, hasStyle)
hasStyle
} else {
parentDefinesStyle
}
if (element is SVG.SvgContainer) {
for (child in element.children) {
applyTintColor(child, newColor, definesStyle)
}
}
}
fun applyTintColor(svg: SVG, newColor: Int) {
val root = svg.rootElement
svg.cssRules?.forEach { rule ->
replaceStyles(rule.style, newColor)
}
replaceStyles(root.baseStyle, newColor)
replaceStyles(root.style, newColor)
val hasStyle = hasStyle(root)
for (child in root.children) {
applyTintColor(child, newColor, hasStyle)
}
}

View File

@@ -0,0 +1,226 @@
package expo.modules.image
import android.os.Build
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.request.target.Target
import expo.modules.image.enums.ContentFit
import expo.modules.image.records.DecodeFormat
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sqrt
/**
* Glide uses `hashCode` and `equals` of the `DownsampleStrategy` to calculate the cache key.
* However, we generate this object dynamically, which means that each instance will be different.
* Unfortunately, this behaviour is not correct since Glide will not load
* the image from memory no matter what.
* To fix this issue, we set the `hashCode` to a fixed number and
* override `equals` to only check if objects have the common type.
*/
abstract class CustomDownsampleStrategy : DownsampleStrategy() {
override fun equals(other: Any?): Boolean {
return other is CustomDownsampleStrategy
}
override fun hashCode(): Int {
return 302008237
}
}
object NoopDownsampleStrategy : DownsampleStrategy() {
override fun getScaleFactor(
sourceWidth: Int,
sourceHeight: Int,
requestedWidth: Int,
requestedHeight: Int
): Float = 1f
override fun getSampleSizeRounding(
sourceWidth: Int,
sourceHeight: Int,
requestedWidth: Int,
requestedHeight: Int
): SampleSizeRounding = SampleSizeRounding.QUALITY
}
class PlaceholderDownsampleStrategy(
private val target: ImageViewWrapperTarget
) : CustomDownsampleStrategy() {
private var wasTriggered = false
override fun getScaleFactor(sourceWidth: Int, sourceHeight: Int, requestedWidth: Int, requestedHeight: Int): Float {
if (!wasTriggered) {
target.placeholderWidth = sourceWidth
target.placeholderHeight = sourceHeight
wasTriggered = true
}
return 1f
}
override fun getSampleSizeRounding(sourceWidth: Int, sourceHeight: Int, requestedWidth: Int, requestedHeight: Int): SampleSizeRounding {
return SampleSizeRounding.QUALITY
}
}
class ContentFitDownsampleStrategy(
private val target: ImageViewWrapperTarget,
private val contentFit: ContentFit
) : CustomDownsampleStrategy() {
private var wasTriggered = false
override fun getScaleFactor(
sourceWidth: Int,
sourceHeight: Int,
requestedWidth: Int,
requestedHeight: Int
): Float {
// The method is invoked twice per asset, but we only need to preserve the original dimensions for the first call.
// As Glide uses Android downsampling, it can only adjust dimensions by a factor of two,
// and hence two distinct scaling factors are computed to achieve greater accuracy.
if (!wasTriggered) {
target.sourceWidth = sourceWidth
target.sourceHeight = sourceHeight
wasTriggered = true
}
// The size of the container is unknown, we don't know what to do, so we just run the default scale.
if (requestedWidth == Target.SIZE_ORIGINAL || requestedHeight == Target.SIZE_ORIGINAL) {
return 1f
}
val aspectRation = calculateScaleFactor(
sourceWidth.toFloat(),
sourceHeight.toFloat(),
requestedWidth.toFloat(),
requestedHeight.toFloat()
)
// We don't want to upscale the image
return min(1f, aspectRation)
}
private fun calculateScaleFactor(
sourceWidth: Float,
sourceHeight: Float,
requestedWidth: Float,
requestedHeight: Float
): Float = when (contentFit) {
ContentFit.Contain -> min(
requestedWidth / sourceWidth,
requestedHeight / sourceHeight
)
ContentFit.Cover -> java.lang.Float.max(
requestedWidth / sourceWidth,
requestedHeight / sourceHeight
)
ContentFit.ScaleDown -> if (requestedWidth < sourceWidth || requestedHeight < sourceHeight) {
// The container is smaller than the image — scale it down and behave like `contain`
min(
requestedWidth / sourceWidth,
requestedHeight / sourceHeight
)
} else {
// The container is bigger than the image — don't scale it and behave like `none`
1f
}
else -> 1f
}
override fun getSampleSizeRounding(
sourceWidth: Int,
sourceHeight: Int,
requestedWidth: Int,
requestedHeight: Int
) = SampleSizeRounding.QUALITY
}
/**
* Android has hardware bitmap size limit that can be drown on the canvas.
* To prevents crashes, we need to downsample the image to fit into the maximum bitmap size.
*/
class SafeDownsampleStrategy(
private val decodeFormat: DecodeFormat
) : CustomDownsampleStrategy() {
override fun getScaleFactor(
sourceWidth: Int,
sourceHeight: Int,
requestedWidth: Int,
requestedHeight: Int
): Float {
if (maxBitmapSize <= 0) {
return 1f
}
val sourceSize = sourceWidth * sourceHeight * decodeFormat.toBytes()
if (sourceSize <= maxBitmapSize) {
return 1f
}
// We need to downsample the image to fit into the maximum bitmap size.
// Calculate the aspect ratio of the source image. It's always <= 1.
val srcAspectRatio = min(sourceWidth, sourceHeight).toDouble() / max(sourceWidth, sourceHeight).toDouble()
// Calculate the area of the destination image.
val dstArea = maxBitmapSize / decodeFormat.toBytes()
// Calculate the longer side of the destination image using following formulas:
// dstLongerSide * dstSmallerSide = dstArea
// srcAspectRation * dstLongerSide = dstSmallerSide
// after substitution:
// srcAspectRation * dstLongerSide * dstLongerSide = dstArea
// dstLongerSide = sqrt(dstArea / srcAspectRatio)
val x = floor(sqrt(dstArea.toDouble() / srcAspectRatio)).toInt()
// Calculate the scale factor using longer side of both images.
val scaleFactor = x.toDouble() / max(sourceWidth, sourceHeight).toDouble()
return scaleFactor.toFloat()
}
override fun getSampleSizeRounding(
sourceWidth: Int,
sourceHeight: Int,
requestedWidth: Int,
requestedHeight: Int
): SampleSizeRounding {
return SampleSizeRounding.MEMORY
}
private val maxBitmapSize by lazy {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
return@lazy -1
}
return@lazy try {
val defaultSize = 100 * 1024 * 1024 // 100 MB - from `RecordingCanvas` src
// We're trying to get the value of `ro.hwui.max_texture_allocation_size` property
// which is used by `RecordingCanvas` to determine the maximum bitmap size.
@Suppress("PrivateApi")
val getIntMethod = Class
.forName("android.os.SystemProperties")
.getMethod("getInt", String::class.java, Int::class.java)
getIntMethod.isAccessible = true
(getIntMethod.invoke(null, "ro.hwui.max_texture_allocation_size", defaultSize) as Int)
.coerceAtLeast(defaultSize)
} catch (e: Throwable) {
// If something goes wrong we just return -1 and don't downsample the image.
-1
}
}
override fun equals(other: Any?): Boolean {
if (this === other) {
return true
}
if (other !is SafeDownsampleStrategy) {
return false
}
return decodeFormat == other.decodeFormat
}
override fun hashCode(): Int {
var result = super.hashCode()
result = 31 * result + decodeFormat.hashCode()
return result
}
}

View File

@@ -0,0 +1,8 @@
package expo.modules.image
import com.bumptech.glide.load.Option
object CustomOptions {
// To pass the tint color to the SVG decoder, we need to wrap it in a custom Glide option.
val tintColor = Option.memory<Int>("ExpoTintColor")
}

View File

@@ -0,0 +1,6 @@
package expo.modules.image
import expo.modules.kotlin.exception.CodedException
class ImageLoadFailed(exception: Exception) :
CodedException(message = "Failed to load the image: ${exception.message}")

View File

@@ -0,0 +1,26 @@
package expo.modules.image
import android.content.Context
import android.util.Log
import com.bumptech.glide.GlideBuilder
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.module.AppGlideModule
/**
* We need to include an [AppGlideModule] for [GlideModule] annotations
* to work.
*/
@GlideModule
class ExpoImageAppGlideModule : AppGlideModule() {
override fun applyOptions(context: Context, builder: GlideBuilder) {
super.applyOptions(context, builder)
builder.setLogLevel(
if (BuildConfig.ALLOW_GLIDE_LOGS) {
Log.VERBOSE
} else {
Log.ERROR
}
)
}
}

View File

@@ -0,0 +1,22 @@
package expo.modules.image
import android.content.ComponentCallbacks2
import android.content.res.Configuration
import expo.modules.image.blurhash.BlurhashDecoder
/**
* Clears the Blurhash cache when the memory is low.
*/
object ExpoImageComponentCallbacks : ComponentCallbacks2 {
override fun onConfigurationChanged(newConfig: Configuration) = Unit
override fun onLowMemory() {
BlurhashDecoder.clearCache()
}
override fun onTrimMemory(level: Int) {
if (level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) {
onLowMemory()
}
}
}

View File

@@ -0,0 +1,338 @@
package expo.modules.image
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.util.Base64
import androidx.core.graphics.drawable.toBitmap
import androidx.core.graphics.drawable.toBitmapOrNull
import androidx.core.view.doOnDetach
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.load.model.Headers
import com.bumptech.glide.load.model.LazyHeaders
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import com.github.penfeizhou.animation.apng.APNGDrawable
import com.github.penfeizhou.animation.gif.GifDrawable
import com.github.penfeizhou.animation.webp.WebPDrawable
import expo.modules.image.blurhash.BlurhashEncoder
import expo.modules.image.enums.ContentFit
import expo.modules.image.enums.Priority
import expo.modules.image.records.CachePolicy
import expo.modules.image.records.ContentPosition
import expo.modules.image.records.DecodeFormat
import expo.modules.image.records.DecodedSource
import expo.modules.image.records.ImageLoadOptions
import expo.modules.image.records.ImageTransition
import expo.modules.image.records.SourceMap
import expo.modules.image.thumbhash.ThumbhashEncoder
import expo.modules.kotlin.Promise
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.functions.Coroutine
import expo.modules.kotlin.functions.Queues
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.sharedobjects.SharedRef
import expo.modules.kotlin.types.Either
import expo.modules.kotlin.types.EitherOfThree
import expo.modules.kotlin.types.toKClass
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.URL
class ExpoImageModule : Module() {
override fun definition() = ModuleDefinition {
Name("ExpoImage")
OnCreate {
appContext.reactContext?.registerComponentCallbacks(ExpoImageComponentCallbacks)
}
OnDestroy {
appContext.reactContext?.unregisterComponentCallbacks(ExpoImageComponentCallbacks)
}
AsyncFunction("prefetch") { urls: List<String>, cachePolicy: CachePolicy, headersMap: Map<String, String>?, promise: Promise ->
val context = appContext.reactContext ?: return@AsyncFunction false
var imagesLoaded = 0
var failed = false
val headers = headersMap?.let {
LazyHeaders.Builder().apply {
it.forEach { (key, value) ->
addHeader(key, value)
}
}.build()
} ?: Headers.DEFAULT
urls.forEach {
Glide
.with(context)
.load(GlideUrl(it, headers)) // Use `load` instead of `download` to store the asset in the memory cache
// We added `quality` and `downsample` to create the same cache key as in final image load.
.encodeQuality(100)
.downsample(NoopDownsampleStrategy)
.customize(`when` = cachePolicy == CachePolicy.MEMORY) {
diskCacheStrategy(DiskCacheStrategy.NONE)
}
.listener(object : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>,
isFirstResource: Boolean
): Boolean {
if (!failed) {
failed = true
promise.resolve(false)
}
return true
}
override fun onResourceReady(
resource: Drawable,
model: Any,
target: Target<Drawable>,
dataSource: DataSource,
isFirstResource: Boolean
): Boolean {
imagesLoaded++
if (imagesLoaded == urls.size) {
promise.resolve(true)
}
return true
}
})
.submit()
}
}
AsyncFunction("loadAsync") Coroutine { source: SourceMap, options: ImageLoadOptions? ->
ImageLoadTask(appContext, source, options ?: ImageLoadOptions()).load()
}
suspend fun generatePlaceholder(
source: Either<URL, Image>,
encoder: (Bitmap) -> String
): String {
val image = source.let {
if (it.`is`(Image::class)) {
it.get(Image::class)
} else {
ImageLoadTask(appContext, SourceMap(uri = it.get(URL::class).toString()), ImageLoadOptions()).load()
}
}
return withContext(Dispatchers.Default) {
encoder(image.ref.toBitmap())
}
}
AsyncFunction("generateBlurhashAsync") Coroutine { source: Either<URL, Image>, numberOfComponents: Pair<Int, Int> ->
generatePlaceholder(source) { bitmap ->
BlurhashEncoder.encode(bitmap, numberOfComponents)
}
}
AsyncFunction("generateThumbhashAsync") Coroutine { source: Either<URL, Image> ->
generatePlaceholder(source) { bitmap ->
Base64.encodeToString(
ThumbhashEncoder.encode(bitmap),
Base64.NO_WRAP
)
}
}
Class(Image::class) {
Property("width") { image: Image ->
image.ref.intrinsicWidth
}
Property("height") { image: Image ->
image.ref.intrinsicHeight
}
Property("scale") { image: Image ->
// Not relying on `2x` in the filename, but want to make the following true:
// If you multiply the logical size of the image by this value, you get the dimensions of the image in pixels.
val screenDensity = appContext.reactContext?.resources?.displayMetrics?.density ?: 1f
(image.ref.toBitmapOrNull()?.density ?: 1) / (screenDensity * 160.0f)
}
Property("isAnimated") { image: Image ->
if (image.ref is GifDrawable) {
return@Property true
}
if (image.ref is APNGDrawable) {
return@Property true
}
if (image.ref is WebPDrawable) {
return@Property true
}
false
}
Property<Any?>("mediaType") { ->
null // not easily supported on Android https://github.com/bumptech/glide/issues/1378#issuecomment-236879983
}
}
AsyncFunction("clearMemoryCache") {
val activity = appContext.currentActivity ?: return@AsyncFunction false
Glide.get(activity).clearMemory()
return@AsyncFunction true
}.runOnQueue(Queues.MAIN)
AsyncFunction<Boolean>("clearDiskCache") {
val activity = appContext.currentActivity ?: return@AsyncFunction false
activity.let {
Glide.get(activity).clearDiskCache()
}
return@AsyncFunction true
}
AsyncFunction("getCachePathAsync") { cacheKey: String ->
val context = appContext.reactContext ?: return@AsyncFunction null
val glideUrl = GlideUrl(cacheKey)
val target = Glide.with(context).asFile().load(glideUrl).onlyRetrieveFromCache(true).submit()
return@AsyncFunction try {
val file = target.get()
file.absolutePath
} catch (_: Exception) {
null
}
}
View(ExpoImageViewWrapper::class) {
Events(
"onLoadStart",
"onProgress",
"onError",
"onLoad",
"onDisplay"
)
Prop("source") { view: ExpoImageViewWrapper, sources: EitherOfThree<List<SourceMap>, SharedRef<Drawable>, SharedRef<Bitmap>>? ->
if (sources == null) {
view.sources = emptyList()
return@Prop
}
if (sources.`is`(toKClass<List<SourceMap>>())) {
view.sources = sources.get(toKClass<List<SourceMap>>())
return@Prop
}
if (sources.`is`(toKClass<SharedRef<Drawable>>())) {
val drawable = sources.get(toKClass<SharedRef<Drawable>>()).ref
view.sources = listOf(DecodedSource(drawable))
return@Prop
}
val bitmap = sources.get(toKClass<SharedRef<Bitmap>>()).ref
val context = appContext.reactContext ?: throw Exceptions.ReactContextLost()
val drawable = BitmapDrawable(context.resources, bitmap)
view.sources = listOf(DecodedSource(drawable))
}
Prop("contentFit") { view: ExpoImageViewWrapper, contentFit: ContentFit? ->
view.contentFit = contentFit ?: ContentFit.Cover
}
Prop("placeholderContentFit") { view: ExpoImageViewWrapper, placeholderContentFit: ContentFit? ->
view.placeholderContentFit = placeholderContentFit ?: ContentFit.ScaleDown
}
Prop("contentPosition") { view: ExpoImageViewWrapper, contentPosition: ContentPosition? ->
view.contentPosition = contentPosition ?: ContentPosition.center
}
Prop("blurRadius") { view: ExpoImageViewWrapper, blurRadius: Int? ->
view.blurRadius = blurRadius?.takeIf { it > 0 }
}
Prop("transition") { view: ExpoImageViewWrapper, transition: ImageTransition? ->
view.transition = transition
}
Prop("tintColor") { view: ExpoImageViewWrapper, color: Int? ->
view.tintColor = color
}
Prop("placeholder") { view: ExpoImageViewWrapper, placeholder: List<SourceMap>? ->
view.placeholders = placeholder ?: emptyList()
}
Prop("accessible") { view: ExpoImageViewWrapper, accessible: Boolean? ->
view.accessible = accessible == true
}
Prop("accessibilityLabel") { view: ExpoImageViewWrapper, accessibilityLabel: String? ->
view.accessibilityLabel = accessibilityLabel
}
Prop("focusable") { view: ExpoImageViewWrapper, isFocusable: Boolean? ->
view.isFocusableProp = isFocusable == true
}
Prop("priority") { view: ExpoImageViewWrapper, priority: Priority? ->
view.priority = priority ?: Priority.NORMAL
}
Prop("cachePolicy") { view: ExpoImageViewWrapper, cachePolicy: CachePolicy? ->
view.cachePolicy = cachePolicy ?: CachePolicy.DISK
}
Prop("recyclingKey") { view: ExpoImageViewWrapper, recyclingKey: String? ->
view.recyclingKey = recyclingKey
}
Prop("allowDownscaling") { view: ExpoImageViewWrapper, allowDownscaling: Boolean? ->
view.allowDownscaling = allowDownscaling != false
}
Prop("autoplay") { view: ExpoImageViewWrapper, autoplay: Boolean? ->
view.autoplay = autoplay != false
}
Prop("decodeFormat") { view: ExpoImageViewWrapper, format: DecodeFormat? ->
view.decodeFormat = format ?: DecodeFormat.ARGB_8888
}
AsyncFunction("startAnimating") { view: ExpoImageViewWrapper ->
view.setIsAnimating(true)
}
AsyncFunction("stopAnimating") { view: ExpoImageViewWrapper ->
view.setIsAnimating(false)
}
AsyncFunction("lockResourceAsync") { view: ExpoImageViewWrapper ->
view.lockResource = true
}
AsyncFunction("unlockResourceAsync") { view: ExpoImageViewWrapper ->
view.lockResource = false
}
AsyncFunction("reloadAsync") { view: ExpoImageViewWrapper ->
view.shouldRerender = true
view.rerenderIfNeeded(force = true)
}
OnViewDidUpdateProps { view: ExpoImageViewWrapper ->
view.rerenderIfNeeded()
}
OnViewDestroys { view: ExpoImageViewWrapper ->
view.doOnDetach {
view.onViewDestroys()
}
}
}
}
}

View File

@@ -0,0 +1,129 @@
package expo.modules.image
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.PorterDuff
import android.graphics.RectF
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.util.Log
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.graphics.transform
import androidx.core.view.isVisible
import com.facebook.react.common.annotations.UnstableReactNativeAPI
import expo.modules.image.enums.ContentFit
import expo.modules.image.records.ContentPosition
@OptIn(UnstableReactNativeAPI::class)
@SuppressLint("ViewConstructor")
class ExpoImageView(
context: Context
) : AppCompatImageView(context) {
var currentTarget: ImageViewWrapperTarget? = null
var isPlaceholder: Boolean = false
fun recycleView(): ImageViewWrapperTarget? {
setImageDrawable(null)
val target = currentTarget?.apply {
isUsed = false
}
currentTarget = null
isVisible = false
isPlaceholder = false
return target
}
private var transformationMatrixChanged = false
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
applyTransformationMatrix()
}
fun applyTransformationMatrix() {
if (drawable == null) {
return
}
if (isPlaceholder) {
applyTransformationMatrix(
drawable,
placeholderContentFit,
sourceHeight = currentTarget?.placeholderHeight,
sourceWidth = currentTarget?.placeholderWidth
)
} else {
applyTransformationMatrix(drawable, contentFit, contentPosition)
}
}
private fun applyTransformationMatrix(
drawable: Drawable,
contentFit: ContentFit,
contentPosition: ContentPosition = ContentPosition.center,
sourceWidth: Int? = currentTarget?.sourceWidth,
sourceHeight: Int? = currentTarget?.sourceHeight
) {
val imageRect = RectF(0f, 0f, drawable.intrinsicWidth.toFloat(), drawable.intrinsicHeight.toFloat())
val viewRect = RectF(0f, 0f, width.toFloat(), height.toFloat())
val matrix = contentFit.toMatrix(
imageRect,
viewRect,
sourceWidth ?: -1,
sourceHeight ?: -1
)
val scaledImageRect = imageRect.transform(matrix)
imageMatrix = matrix.apply {
contentPosition.apply(this, scaledImageRect, viewRect)
}
}
init {
clipToOutline = true
scaleType = ScaleType.MATRIX
}
// region Component Props
internal var contentFit: ContentFit = ContentFit.Cover
set(value) {
field = value
transformationMatrixChanged = true
}
internal var placeholderContentFit: ContentFit = ContentFit.ScaleDown
set(value) {
field = value
transformationMatrixChanged = true
}
internal var contentPosition: ContentPosition = ContentPosition.center
set(value) {
field = value
transformationMatrixChanged = true
}
internal fun setTintColor(color: Int?) {
color?.let { setColorFilter(it, PorterDuff.Mode.SRC_IN) } ?: clearColorFilter()
}
override fun draw(canvas: Canvas) {
// If we encounter a recycled bitmap here, it suggests an issue where we may have failed to
// finish clearing the image bitmap before the UI attempts to display it.
// One solution could be to suppress the error and assume that the second image view is currently responsible for displaying the correct view.
if ((drawable as? BitmapDrawable)?.bitmap?.isRecycled == true) {
Log.e("ExpoImage", "Trying to use a recycled bitmap")
recycleView()?.let { target ->
(parent as? ExpoImageViewWrapper)?.requestManager?.let { requestManager ->
target.clear(requestManager)
}
}
}
super.draw(canvas)
}
}

View File

@@ -0,0 +1,654 @@
package expo.modules.image
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.graphics.drawable.Animatable
import android.graphics.drawable.Drawable
import android.os.Build
import android.os.Handler
import android.view.View
import android.widget.FrameLayout
import androidx.core.view.AccessibilityDelegateCompat
import androidx.core.view.ViewCompat
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
import androidx.core.view.isVisible
import com.bumptech.glide.Glide
import com.bumptech.glide.RequestManager
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.request.RequestOptions
import com.github.penfeizhou.animation.gif.GifDrawable
import expo.modules.image.enums.ContentFit
import expo.modules.image.enums.Priority
import expo.modules.image.events.GlideRequestListener
import expo.modules.image.events.OkHttpProgressListener
import expo.modules.image.okhttp.GlideUrlWrapper
import expo.modules.image.records.CachePolicy
import expo.modules.image.records.ContentPosition
import expo.modules.image.records.DecodeFormat
import expo.modules.image.records.ImageErrorEvent
import expo.modules.image.records.ImageLoadEvent
import expo.modules.image.records.ImageProgressEvent
import expo.modules.image.records.ImageTransition
import expo.modules.image.records.Source
import expo.modules.image.svg.SVGPictureDrawable
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.tracing.beginAsyncTraceBlock
import expo.modules.kotlin.tracing.trace
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
import jp.wasabeef.glide.transformations.BlurTransformation
import java.lang.ref.WeakReference
import kotlin.math.abs
import kotlin.math.min
@SuppressLint("ViewConstructor")
class ExpoImageViewWrapper(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
private val activity: Activity
get() = appContext.throwingActivity
internal val requestManager = getOrCreateRequestManager(appContext, activity)
private val progressListener = OkHttpProgressListener(WeakReference(this))
private val firstView = ExpoImageView(activity)
private val secondView = ExpoImageView(activity)
private val mainHandler = Handler(context.mainLooper)
/**
* @returns the view which is currently active or will be used when both views are empty
*/
private val activeView: ExpoImageView
get() {
if (secondView.drawable != null) {
return secondView
}
return firstView
}
private var firstTarget = ImageViewWrapperTarget(WeakReference(this))
private var secondTarget = ImageViewWrapperTarget(WeakReference(this))
internal val onLoadStart by EventDispatcher<Unit>()
internal val onProgress by EventDispatcher<ImageProgressEvent>()
internal val onError by EventDispatcher<ImageErrorEvent>()
internal val onLoad by EventDispatcher<ImageLoadEvent>()
internal val onDisplay by EventDispatcher<Unit>()
internal var sources: List<Source> = emptyList()
private val bestSource: Source?
get() = getBestSource(sources)
internal var placeholders: List<Source> = emptyList()
private val bestPlaceholder: Source?
get() = getBestSource(placeholders)
internal var blurRadius: Int? = null
set(value) {
if (field != value) {
shouldRerender = true
}
field = value
}
internal var transition: ImageTransition? = null
internal var contentFit: ContentFit = ContentFit.Cover
set(value) {
field = value
activeView.contentFit = value
transformationMatrixChanged = true
}
internal var placeholderContentFit: ContentFit = ContentFit.ScaleDown
set(value) {
field = value
activeView.placeholderContentFit = value
transformationMatrixChanged = true
}
internal var contentPosition: ContentPosition = ContentPosition.center
set(value) {
field = value
activeView.contentPosition = value
transformationMatrixChanged = true
}
internal var tintColor: Int? = null
set(value) {
field = value
// To apply the tint color to the SVG, we need to recreate the drawable.
if (activeView.drawable is SVGPictureDrawable) {
shouldRerender = true
} else {
activeView.setTintColor(value)
}
}
internal var isFocusableProp: Boolean = false
set(value) {
field = value
activeView.isFocusable = value
}
internal var accessible: Boolean = false
set(value) {
field = value
setIsScreenReaderFocusable(activeView, value)
}
internal var accessibilityLabel: String? = null
set(value) {
field = value
activeView.contentDescription = accessibilityLabel
}
var recyclingKey: String? = null
set(value) {
clearViewBeforeChangingSource = field != null && value != null && value != field
field = value
}
internal var allowDownscaling: Boolean = true
set(value) {
field = value
shouldRerender = true
}
internal var decodeFormat: DecodeFormat = DecodeFormat.ARGB_8888
set(value) {
field = value
shouldRerender = true
}
internal var autoplay: Boolean = true
internal var lockResource: Boolean = false
internal var priority: Priority = Priority.NORMAL
internal var cachePolicy: CachePolicy = CachePolicy.DISK
fun setIsAnimating(setAnimating: Boolean) {
// Animatable animations always start from the beginning when resumed.
// So we check first if the resource is a GifDrawable, because it can continue
// from where it was paused.
when (val resource = activeView.drawable) {
is GifDrawable -> setIsAnimating(resource, setAnimating)
is Animatable -> setIsAnimating(resource, setAnimating)
}
}
private fun setIsAnimating(resource: GifDrawable, setAnimating: Boolean) {
if (setAnimating) {
if (resource.isPaused) {
resource.resume()
} else {
resource.start()
}
} else {
resource.pause()
}
}
private fun setIsAnimating(resource: Animatable, setAnimating: Boolean) {
if (setAnimating) {
resource.start()
} else {
resource.stop()
}
}
/**
* Whether the image should be loaded again
*/
internal var shouldRerender = false
/**
* Currently loaded source
*/
private var loadedSource: GlideModelProvider? = null
/**
* Currently loaded placeholder
*/
private var loadedPlaceholder: GlideModelProvider? = null
/**
* Whether the transformation matrix should be reapplied
*/
private var transformationMatrixChanged = false
/**
* Whether the view content should be cleared to blank when the source was changed.
*/
private var clearViewBeforeChangingSource = false
/**
* Copies saved props to the provided view.
* It ensures that the view state is up to date.
*/
private fun copyProps(view: ExpoImageView) {
view.contentFit = contentFit
view.contentPosition = contentPosition
view.setTintColor(tintColor)
view.isFocusable = isFocusableProp
view.contentDescription = accessibilityLabel
setIsScreenReaderFocusable(view, accessible)
}
/**
* Allows `isScreenReaderFocusable` to be set on apis below level 28
*/
private fun setIsScreenReaderFocusable(view: View, value: Boolean) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
view.isScreenReaderFocusable = value
} else {
ViewCompat.setAccessibilityDelegate(
this,
object : AccessibilityDelegateCompat() {
override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) {
info.isScreenReaderFocusable = value
super.onInitializeAccessibilityNodeInfo(host, info)
}
}
)
}
}
/**
* When a new resource is available, this method tries to handle it.
* It decides where provided bitmap should be displayed and clears the previous target/image.
*/
fun onResourceReady(
target: ImageViewWrapperTarget,
resource: Drawable,
isPlaceholder: Boolean = false
) =
// The "onResourceReady" function will be triggered when the new resource is available by the Glide.
// According to the Glide documentation (https://bumptech.github.io/glide/doc/debugging.html#you-cant-start-or-clear-loads-in-requestlistener-or-target-callbacks),
// it's not advisable to clear the Glide target within the stack frame.
// To avoid this, a new runnable is posted to the front of the main queue, which can then clean or create targets.
// This ensures that the "onResourceReady" frame of the Glide code will be discarded, and the internal state can be altered once again.
// Normally, using "postAtFrontOfQueue" can lead to issues such as message queue starvation, ordering problems, and other unexpected consequences.
// However, in this case, it is safe to use as long as nothing else is added to the queue.
// The intention is simply to wait for the Glide code to finish before the content of the underlying views is changed during the same rendering tick.
mainHandler.postAtFrontOfQueue {
trace(Trace.tag, "onResourceReady") {
val transitionDuration = (transition?.duration ?: 0).toLong()
// If provided resource is a placeholder, but the target doesn't have a source, we treat it as a normal image.
if (!isPlaceholder || !target.hasSource) {
val (newView, previousView) = if (firstView.drawable == null) {
firstView to secondView
} else {
secondView to firstView
}
val clearPreviousView = {
previousView
.recycleView()
?.apply {
// When the placeholder is loaded, one target is displayed in both views.
// So we just have to move the reference to a new view instead of clearing the target.
if (this != target) {
clear(requestManager)
}
}
}
configureView(newView, target, resource, isPlaceholder)
// Dispatch "onDisplay" event only for the main source (no placeholder).
if (target.hasSource) {
onDisplay.invoke(Unit)
}
if (transitionDuration <= 0) {
clearPreviousView()
newView.alpha = 1f
newView.bringToFront()
} else {
newView.bringToFront()
previousView.alpha = 1f
newView.alpha = 0f
previousView.animate().apply {
duration = transitionDuration
alpha(0f)
withEndAction {
clearPreviousView()
}
}
newView.animate().apply {
duration = transitionDuration
alpha(1f)
}
}
} else {
// We don't want to show the placeholder if something is currently displayed.
// There is one exception - when we're displaying a different placeholder.
if ((firstView.drawable != null && !firstView.isPlaceholder) || secondView.drawable != null) {
return@trace
}
firstView
.recycleView()
?.apply {
// The current target is already bound to the view. We don't want to cancel it in that case.
if (this != target) {
clear(requestManager)
}
}
configureView(firstView, target, resource, isPlaceholder)
if (transitionDuration > 0) {
firstView.bringToFront()
firstView.alpha = 0f
secondView.isVisible = false
firstView.animate().apply {
duration = transitionDuration
alpha(1f)
}
}
}
// If our image is animated, we want to see if autoplay is disabled. If it is, we should
// stop the animation as soon as the resource is ready. Placeholders should not follow this
// value since the intention is almost certainly to display the animation (i.e. a spinner)
if (resource is Animatable && !isPlaceholder && !autoplay) {
resource.stop()
}
}
}
private fun configureView(
view: ExpoImageView,
target: ImageViewWrapperTarget,
resource: Drawable,
isPlaceholder: Boolean
) {
view.let {
it.setImageDrawable(resource)
it.isPlaceholder = isPlaceholder
it.placeholderContentFit = target.placeholderContentFit ?: ContentFit.ScaleDown
copyProps(it)
it.isVisible = true
it.currentTarget = target
// The view isn't layout when it's invisible.
// Therefore, we have to set the correct size manually.
it.layout(0, 0, width, height)
it.applyTransformationMatrix()
}
target.isUsed = true
if (resource is Animatable) {
resource.start()
}
}
private fun getBestSource(sources: List<Source>): Source? {
if (sources.isEmpty()) {
return null
}
if (sources.size == 1) {
return sources.first()
}
val targetPixelCount = width * height
if (targetPixelCount == 0) {
return null
}
var bestSource: Source? = null
var bestFit = Double.MAX_VALUE
sources.forEach {
val fit = abs(1 - (it.pixelCount / targetPixelCount))
if (fit < bestFit) {
bestFit = fit
bestSource = it
}
}
return bestSource
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
rerenderIfNeeded(
shouldRerenderBecauseOfResize = allowDownscaling &&
contentFit != ContentFit.Fill &&
contentFit != ContentFit.None
)
}
private fun createPropOptions(): RequestOptions {
return RequestOptions()
.priority(this@ExpoImageViewWrapper.priority.toGlidePriority())
.customize(`when` = cachePolicy != CachePolicy.MEMORY_AND_DISK && cachePolicy != CachePolicy.MEMORY) {
skipMemoryCache(true)
}
.customize(`when` = cachePolicy == CachePolicy.NONE || cachePolicy == CachePolicy.MEMORY) {
diskCacheStrategy(DiskCacheStrategy.NONE)
}
.customize(blurRadius) {
transform(BlurTransformation(min(it, 25), 4))
}
}
fun onViewDestroys() {
firstView.setImageDrawable(null)
secondView.setImageDrawable(null)
requestManager.clear(firstTarget)
requestManager.clear(secondTarget)
}
private fun cleanIfNeeded(
newBestSource: Source?,
newBestSourceModel: GlideModelProvider?,
newBestPlaceholderModel: GlideModelProvider?
): Boolean {
// We only clean the image when the source is set to null and we don't have a placeholder or the view is empty.
if (width == 0 || height == 0 || (newBestSource == null || newBestSourceModel == null) && newBestPlaceholderModel == null) {
firstView.recycleView()
secondView.recycleView()
requestManager.clear(firstTarget)
requestManager.clear(secondTarget)
shouldRerender = false
loadedSource = null
loadedPlaceholder = null
transformationMatrixChanged = false
clearViewBeforeChangingSource = false
return true
}
return false
}
private fun createDownsampleStrategy(target: ImageViewWrapperTarget): DownsampleStrategy {
return if (!allowDownscaling) {
DownsampleStrategy.NONE
} else if (
contentFit != ContentFit.Fill &&
contentFit != ContentFit.None
) {
ContentFitDownsampleStrategy(target, contentFit)
} else {
// it won't downscale the image if the image is smaller than hardware bitmap size limit
SafeDownsampleStrategy(decodeFormat)
}
}
private fun clearViewBeforeChangingSource() {
if (clearViewBeforeChangingSource) {
val activeView = if (firstView.drawable != null) {
firstView
} else {
secondView
}
activeView
.recycleView()
?.apply {
clear(requestManager)
}
}
}
internal fun rerenderIfNeeded(shouldRerenderBecauseOfResize: Boolean = false, force: Boolean = false) =
trace(Trace.tag, "rerenderIfNeeded(shouldRerenderBecauseOfResize=$shouldRerenderBecauseOfResize,force=$force)") {
if (lockResource && !force) {
return@trace
}
val bestSource = bestSource
val bestPlaceholder = bestPlaceholder
val sourceToLoad = bestSource?.createGlideModelProvider(context)
val placeholder = bestPlaceholder?.createGlideModelProvider(context)
if (cleanIfNeeded(bestSource, sourceToLoad, placeholder)) {
// the view was cleaned
return@trace
}
val shouldRerender = sourceToLoad != loadedSource || placeholder != loadedPlaceholder || shouldRerender || (sourceToLoad == null && placeholder != null)
if (!shouldRerender && !shouldRerenderBecauseOfResize) {
// In the case where the source didn't change, but the transformation matrix has to be
// recalculated, we can apply the new transformation right away.
// When the source and the matrix is different, we don't want to do anything.
// We don't want to changed the transformation of the currently displayed image.
// The new matrix will be applied when new resource is loaded.
if (transformationMatrixChanged) {
activeView.applyTransformationMatrix()
}
transformationMatrixChanged = false
clearViewBeforeChangingSource = false
return@trace
}
clearViewBeforeChangingSource()
this.shouldRerender = false
loadedSource = sourceToLoad
loadedPlaceholder = placeholder
val options = bestSource?.createGlideOptions(context)
val propOptions = createPropOptions()
val model = sourceToLoad?.getGlideModel()
if (model is GlideUrlWrapper) {
model.progressListener = progressListener
}
onLoadStart.invoke(Unit)
val newTarget = if (secondTarget.isUsed) {
firstTarget
} else {
secondTarget
}
newTarget.hasSource = sourceToLoad != null
val downsampleStrategy = createDownsampleStrategy(newTarget)
val request = requestManager
.asDrawable()
.load(model)
.customize(bestPlaceholder, placeholder) { placeholderSource, placeholderModel ->
val newPlaceholderContentFit = if (!placeholderSource.usesPlaceholderContentFit()) {
contentFit
} else {
placeholderContentFit
}
newTarget.placeholderContentFit = newPlaceholderContentFit
thumbnail(
requestManager.load(placeholderModel.getGlideModel())
.downsample(PlaceholderDownsampleStrategy(newTarget))
.apply(placeholderSource.createGlideOptions(context))
)
}
.downsample(downsampleStrategy)
.addListener(GlideRequestListener(WeakReference(this)))
.encodeQuality(100)
.format(decodeFormat.toGlideFormat())
.apply(propOptions)
.apply(options)
.customize(tintColor) {
apply(RequestOptions().set(CustomOptions.tintColor, it))
}
val cookie = Trace.getNextCookieValue()
beginAsyncTraceBlock(Trace.tag, Trace.loadNewImageBlock, cookie)
newTarget.setCookie(cookie)
request.into(newTarget)
transformationMatrixChanged = false
clearViewBeforeChangingSource = false
}
init {
val matchParent = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
layoutParams = matchParent
firstView.isVisible = true
secondView.isVisible = true
// We need to add a `FrameLayout` to allow views to overflow.
// With the `LinearLayout` is impossible to render two views on each other.
val layout = FrameLayout(context).apply {
layoutParams = matchParent
addView(
firstView,
matchParent
)
addView(
secondView,
matchParent
)
}
addView(layout, matchParent)
}
companion object {
private var requestManager: RequestManager? = null
private var appContextRef: WeakReference<AppContext?> = WeakReference(null)
private var activityRef: WeakReference<Activity?> = WeakReference(null)
fun getOrCreateRequestManager(
appContext: AppContext,
activity: Activity
): RequestManager = synchronized(Companion) {
val cachedRequestManager = requestManager
?: return createNewRequestManager(activity).also {
requestManager = it
appContextRef = WeakReference(appContext)
activityRef = WeakReference(activity)
}
// Request manager was created using different activity or app context
if (appContextRef.get() != appContext || activityRef.get() != activity) {
return createNewRequestManager(activity).also {
requestManager = it
appContextRef = WeakReference(appContext)
activityRef = WeakReference(activity)
}
}
return cachedRequestManager
}
private fun createNewRequestManager(activity: Activity): RequestManager = Glide.with(activity)
}
}

View File

@@ -0,0 +1,67 @@
package expo.modules.image
import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.request.RequestOptions
/**
* Conditionally applies the block to the RequestBuilder if the condition is true.
*/
fun <T> RequestBuilder<T>.customize(`when`: Boolean, block: RequestBuilder<T>.() -> RequestBuilder<T>): RequestBuilder<T> {
if (!`when`) {
return this
}
return block()
}
/**
* Conditionally applies the block to the RequestBuilder if the value is not null.
*/
inline fun <T, P> RequestBuilder<T>.customize(value: P?, block: RequestBuilder<T>.(P) -> RequestBuilder<T>): RequestBuilder<T> {
if (value == null) {
return this
}
return block(value)
}
/**
* Conditionally applies the block to the RequestBuilder if both values aren't null.
*/
inline fun <T, P1, P2> RequestBuilder<T>.customize(first: P1?, second: P2?, block: RequestBuilder<T>.(P1, P2) -> RequestBuilder<T>): RequestBuilder<T> {
if (first == null || second == null) {
return this
}
return block(first, second)
}
/**
* Conditionally applies the block to the RequestOptions if the condition is true.
*/
inline fun RequestOptions.customize(`when`: Boolean, block: RequestOptions.() -> RequestOptions): RequestOptions {
if (!`when`) {
return this
}
return block()
}
/**
* Conditionally applies the block to the RequestOptions if the value is not null.
*/
inline fun <T> RequestOptions.customize(value: T?, block: RequestOptions.(T) -> RequestOptions): RequestOptions {
if (value == null) {
return this
}
return block(value)
}
fun <T> RequestBuilder<T>.apply(options: RequestOptions?): RequestBuilder<T> {
if (options == null) {
return this
}
return apply(options)
}

View File

@@ -0,0 +1,51 @@
package expo.modules.image
import android.graphics.drawable.Drawable
import android.net.Uri
import com.bumptech.glide.load.model.GlideUrl
import expo.modules.image.blurhash.BlurhashModel
import expo.modules.image.decodedsource.DecodedModel
import expo.modules.image.okhttp.GlideUrlWrapper
import expo.modules.image.thumbhash.ThumbhashModel
fun interface GlideModelProvider {
fun getGlideModel(): Any
}
data class DecodedModelProvider(
private val drawable: Drawable
) : GlideModelProvider {
override fun getGlideModel() = DecodedModel(drawable)
}
data class UrlModelProvider(
private val glideUrl: GlideUrl
) : GlideModelProvider {
override fun getGlideModel() = GlideUrlWrapper(glideUrl)
}
data class RawModelProvider(
private val data: String
) : GlideModelProvider {
override fun getGlideModel() = data
}
data class UriModelProvider(
private val uri: Uri
) : GlideModelProvider {
override fun getGlideModel() = uri
}
data class BlurhashModelProvider(
private val uri: Uri,
private val width: Int,
private val height: Int
) : GlideModelProvider {
override fun getGlideModel() = BlurhashModel(uri, width, height)
}
data class ThumbhashModelProvider(
private val uri: Uri
) : GlideModelProvider {
override fun getGlideModel() = ThumbhashModel(uri)
}

View File

@@ -0,0 +1,20 @@
package expo.modules.image
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import expo.modules.kotlin.sharedobjects.SharedRef
class Image(ref: Drawable) : SharedRef<Drawable>(ref) {
override val nativeRefType: String = "image"
override fun getAdditionalMemoryPressure(): Int {
val ref = ref
if (ref is BitmapDrawable) {
return ref.bitmap.allocationByteCount
}
// We can't get the size in bytes of the drawable.
// Let's just return the size in pixels for now.
return ref.intrinsicWidth * ref.intrinsicHeight
}
}

View File

@@ -0,0 +1,51 @@
package expo.modules.image
import android.graphics.drawable.BitmapDrawable
import android.os.Build
import androidx.annotation.RequiresApi
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.Glide
import expo.modules.image.records.ImageLoadOptions
import expo.modules.image.records.SourceMap
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.exception.Exceptions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
open class ImageLoadTask(
private val appContext: AppContext,
private val source: SourceMap,
private val options: ImageLoadOptions
) {
@RequiresApi(Build.VERSION_CODES.O)
suspend fun load(): Image {
val context =
this@ImageLoadTask.appContext.reactContext ?: throw Exceptions.ReactContextLost()
val sourceToLoad = source.createGlideModelProvider(context)
val model = sourceToLoad?.getGlideModel()
try {
val drawable = withContext(Dispatchers.IO) {
Glide
.with(context)
.asDrawable()
.load(model)
.centerInside()
.customize(options.tintColor) {
apply(RequestOptions().set(CustomOptions.tintColor, it.toArgb()))
}
.submit(options.maxWidth, options.maxHeight)
.get()
}
if (drawable is BitmapDrawable && options.tintColor != null) {
drawable.setTint(options.tintColor.toArgb())
}
return Image(drawable)
} catch (e: Exception) {
throw ImageLoadFailed(e)
}
}
}

View File

@@ -0,0 +1,42 @@
package expo.modules.image
import android.graphics.RectF
fun calcXTranslation(
value: Float,
imageRect: RectF,
viewRect: RectF,
isPercentage: Boolean = false,
isReverse: Boolean = false
): Float = calcTranslation(value, imageRect.width(), viewRect.width(), isPercentage, isReverse)
fun calcYTranslation(
value: Float,
imageRect: RectF,
viewRect: RectF,
isPercentage: Boolean = false,
isReverse: Boolean = false
): Float = calcTranslation(value, imageRect.height(), viewRect.height(), isPercentage, isReverse)
fun calcTranslation(
value: Float,
imageRefValue: Float,
viewRefValue: Float,
isPercentage: Boolean = false,
isReverse: Boolean = false
): Float {
if (isPercentage) {
val finalPercentage = if (isReverse) {
100f - value
} else {
value
}
return (finalPercentage / 100f) * (viewRefValue - imageRefValue)
}
if (isReverse) {
return viewRefValue - imageRefValue - value
}
return value
}

View File

@@ -0,0 +1,350 @@
package expo.modules.image
import android.content.Context
import android.graphics.Point
import android.graphics.drawable.Drawable
import android.util.Log
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.view.WindowManager
import androidx.annotation.VisibleForTesting
import com.bumptech.glide.RequestManager
import com.bumptech.glide.request.Request
import com.bumptech.glide.request.ThumbnailRequestCoordinator
import com.bumptech.glide.request.target.SizeReadyCallback
import com.bumptech.glide.request.target.Target
import com.bumptech.glide.request.transition.Transition
import com.bumptech.glide.util.Preconditions
import com.bumptech.glide.util.Synthetic
import expo.modules.core.utilities.ifNull
import expo.modules.image.enums.ContentFit
import expo.modules.kotlin.tracing.endAsyncTraceBlock
import java.lang.ref.WeakReference
import kotlin.math.max
/**
* A custom target to provide a smooth transition between multiple drawables.
* It delegates images to the [ExpoImageViewWrapper], where we handle the loaded [Drawable].
* When the target is cleared, we don't do anything. The [ExpoImageViewWrapper] is responsible for
* clearing bitmaps before freeing targets. That may be error-prone, but that is the only way
* of implementing the transition between bitmaps.
*/
class ImageViewWrapperTarget(
private val imageViewHolder: WeakReference<ExpoImageViewWrapper>
) : Target<Drawable> {
/**
* Whether the target has a main, non-placeholder source
*/
var hasSource = false
/**
* Whether the target is used - the asset loaded by it has been drawn in the image view
*/
var isUsed = false
/**
* The main source height where -1 means unknown
*/
var sourceHeight = -1
/**
* The main source width where -1 means unknown
*/
var sourceWidth = -1
/**
* The placeholder height where -1 means unknown
*/
var placeholderHeight = -1
/**
* The placeholder width where -1 means unknown
*/
var placeholderWidth = -1
private var cookie = -1
fun setCookie(newValue: Int) {
endLoadingNewImageTraceBlock()
synchronized(this) {
cookie = newValue
}
}
/**
* The content fit of the placeholder
*/
var placeholderContentFit: ContentFit? = null
private var request: Request? = null
private var sizeDeterminer = SizeDeterminer(imageViewHolder)
private fun endLoadingNewImageTraceBlock() = synchronized(this) {
if (cookie < 0) {
return@synchronized
}
endAsyncTraceBlock(Trace.tag, Trace.loadNewImageBlock, cookie)
cookie = -1
}
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
// The image view should always be valid. When the view is deallocated, all targets should be
// canceled. Therefore that code shouldn't be called in that case. Instead of crashing, we
// decided to ignore that.
val imageView = imageViewHolder.get().ifNull {
endLoadingNewImageTraceBlock()
Log.w("ExpoImage", "The `ExpoImageViewWrapper` was deallocated, but the target wasn't canceled in time.")
return
}
// The thumbnail and full request are handled in the same way by Glide.
// Here we're checking if the provided resource is the final bitmap or a thumbnail.
val isPlaceholder = if (request is ThumbnailRequestCoordinator) {
(request as? ThumbnailRequestCoordinator)
?.getPrivateFullRequest()
?.isComplete == false
} else {
false
}
if (!isPlaceholder) {
endLoadingNewImageTraceBlock()
}
imageView.onResourceReady(this, resource, isPlaceholder)
}
override fun onStart() = Unit
override fun onStop() = Unit
override fun onDestroy() = Unit
override fun onLoadStarted(placeholder: Drawable?) = Unit
// When loading fails, it's handled by the global listener, therefore that method can be NOOP.
override fun onLoadFailed(errorDrawable: Drawable?) {
endLoadingNewImageTraceBlock()
}
override fun onLoadCleared(placeholder: Drawable?) = Unit
override fun getSize(cb: SizeReadyCallback) {
// If we can't resolve the image, we just return unknown size.
// It shouldn't happen in a production application, because it means that our view was deallocated.
if (imageViewHolder.get() == null) {
cb.onSizeReady(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL)
return
}
sizeDeterminer.getSize(cb)
}
override fun removeCallback(cb: SizeReadyCallback) {
sizeDeterminer.removeCallback(cb)
}
override fun setRequest(request: Request?) {
this.request = request
}
override fun getRequest() = request
fun clear(requestManager: RequestManager) {
sizeDeterminer.clearCallbacksAndListener()
requestManager.clear(this)
}
}
// Copied from the Glide codebase.
// We modified that to receive a weak ref to our view instead of strong one.
internal class SizeDeterminer(private val imageViewHolder: WeakReference<ExpoImageViewWrapper>) {
private val cbs: MutableList<SizeReadyCallback> = ArrayList()
@Synthetic
var waitForLayout = false
private var layoutListener: SizeDeterminerLayoutListener? = null
private fun notifyCbs(width: Int, height: Int) {
// One or more callbacks may trigger the removal of one or more additional callbacks, so we
// need a copy of the list to avoid a concurrent modification exception. One place this
// happens is when a full request completes from the in memory cache while its thumbnail is
// still being loaded asynchronously. See #2237.
for (cb in ArrayList(cbs)) {
cb.onSizeReady(width, height)
}
}
@Synthetic
fun checkCurrentDimens() {
if (cbs.isEmpty()) {
return
}
val currentWidth = targetWidth
val currentHeight = targetHeight
if (!isViewStateAndSizeValid(currentWidth, currentHeight)) {
return
}
notifyCbs(currentWidth, currentHeight)
clearCallbacksAndListener()
}
fun getSize(cb: SizeReadyCallback) {
val view = imageViewHolder.get() ?: return
val currentWidth = targetWidth
val currentHeight = targetHeight
if (isViewStateAndSizeValid(currentWidth, currentHeight)) {
cb.onSizeReady(currentWidth, currentHeight)
return
}
// We want to notify callbacks in the order they were added and we only expect one or two
// callbacks to be added a time, so a List is a reasonable choice.
if (!cbs.contains(cb)) {
cbs.add(cb)
}
if (layoutListener == null) {
val observer = view.viewTreeObserver
layoutListener = SizeDeterminerLayoutListener(this)
observer.addOnPreDrawListener(layoutListener)
}
}
/**
* The callback may be called anyway if it is removed by another [SizeReadyCallback] or
* otherwise removed while we're notifying the list of callbacks.
*
*
* See #2237.
*/
fun removeCallback(cb: SizeReadyCallback) {
cbs.remove(cb)
}
fun clearCallbacksAndListener() {
// Keep a reference to the layout attachStateListener and remove it here
// rather than having the observer remove itself because the observer
// we add the attachStateListener to will be almost immediately merged into
// another observer and will therefore never be alive. If we instead
// keep a reference to the attachStateListener and remove it here, we get the
// current view tree observer and should succeed.
val observer = imageViewHolder.get()?.viewTreeObserver
if (observer?.isAlive == true) {
observer.removeOnPreDrawListener(layoutListener)
}
layoutListener = null
cbs.clear()
}
private fun isViewStateAndSizeValid(width: Int, height: Int): Boolean {
return isDimensionValid(width) && isDimensionValid(height)
}
private val targetHeight: Int
get() {
val view = imageViewHolder.get() ?: return Target.SIZE_ORIGINAL
val verticalPadding = view.paddingTop + view.paddingBottom
val layoutParams = view.layoutParams
val layoutParamSize = layoutParams?.height ?: PENDING_SIZE
return getTargetDimen(view.height, layoutParamSize, verticalPadding)
}
private val targetWidth: Int
get() {
val view = imageViewHolder.get() ?: return Target.SIZE_ORIGINAL
val horizontalPadding = view.paddingLeft + view.paddingRight
val layoutParams = view.layoutParams
val layoutParamSize = layoutParams?.width ?: PENDING_SIZE
return getTargetDimen(view.width, layoutParamSize, horizontalPadding)
}
private fun getTargetDimen(viewSize: Int, paramSize: Int, paddingSize: Int): Int {
val view = imageViewHolder.get() ?: return Target.SIZE_ORIGINAL
// We consider the View state as valid if the View has non-null layout params and a non-zero
// layout params width and height. This is imperfect. We're making an assumption that View
// parents will obey their child's layout parameters, which isn't always the case.
val adjustedParamSize = paramSize - paddingSize
if (adjustedParamSize > 0) {
return adjustedParamSize
}
// Since we always prefer layout parameters with fixed sizes, even if waitForLayout is true,
// we might as well ignore it and just return the layout parameters above if we have them.
// Otherwise we should wait for a layout pass before checking the View's dimensions.
if (waitForLayout && view.isLayoutRequested) {
return PENDING_SIZE
}
// We also consider the View state valid if the View has a non-zero width and height. This
// means that the View has gone through at least one layout pass. It does not mean the Views
// width and height are from the current layout pass. For example, if a View is re-used in
// RecyclerView or ListView, this width/height may be from an old position. In some cases
// the dimensions of the View at the old position may be different than the dimensions of the
// View in the new position because the LayoutManager/ViewParent can arbitrarily decide to
// change them. Nevertheless, in most cases this should be a reasonable choice.
val adjustedViewSize = viewSize - paddingSize
if (adjustedViewSize > 0) {
return adjustedViewSize
}
// Finally we consider the view valid if the layout parameter size is set to wrap_content.
// It's difficult for Glide to figure out what to do here. Although Target.SIZE_ORIGINAL is a
// coherent choice, it's extremely dangerous because original images may be much too large to
// fit in memory or so large that only a couple can fit in memory, causing OOMs. If users want
// the original image, they can always use .override(Target.SIZE_ORIGINAL). Since wrap_content
// may never resolve to a real size unless we load something, we aim for a square whose length
// is the largest screen size. That way we're loading something and that something has some
// hope of being downsampled to a size that the device can support. We also log a warning that
// tries to explain what Glide is doing and why some alternatives are preferable.
// Since WRAP_CONTENT is sometimes used as a default layout parameter, we always wait for
// layout to complete before using this fallback parameter (ConstraintLayout among others).
if (!view.isLayoutRequested && paramSize == ViewGroup.LayoutParams.WRAP_CONTENT) {
return getMaxDisplayLength(view.context)
}
// If the layout parameters are < padding, the view size is < padding, or the layout
// parameters are set to match_parent or wrap_content and no layout has occurred, we should
// wait for layout and repeat.
return PENDING_SIZE
}
private fun isDimensionValid(size: Int): Boolean {
return size > 0 || size == Target.SIZE_ORIGINAL
}
private class SizeDeterminerLayoutListener(sizeDeterminer: SizeDeterminer) : ViewTreeObserver.OnPreDrawListener {
private val sizeDeterminerRef: WeakReference<SizeDeterminer>
init {
sizeDeterminerRef = WeakReference(sizeDeterminer)
}
override fun onPreDraw(): Boolean {
val sizeDeterminer = sizeDeterminerRef.get()
sizeDeterminer?.checkCurrentDimens()
return true
}
}
companion object {
// Some negative sizes (Target.SIZE_ORIGINAL) are valid, 0 is never valid.
private const val PENDING_SIZE = 0
@VisibleForTesting
var maxDisplayLength: Int? = null
// Use the maximum to avoid depending on the device's current orientation.
@Suppress("DEPRECATION") // We have copied this code from Glide and are waiting for them to remove the deprecated APIs.
private fun getMaxDisplayLength(context: Context): Int {
if (maxDisplayLength == null) {
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
val display = Preconditions.checkNotNull(windowManager).defaultDisplay
val displayDimensions = Point()
display.getSize(displayDimensions)
maxDisplayLength = max(displayDimensions.x, displayDimensions.y)
}
return maxDisplayLength!!
}
}
}

View File

@@ -0,0 +1,47 @@
package expo.modules.image
import android.annotation.SuppressLint
import android.content.Context
import android.net.Uri
import com.facebook.react.views.imagehelper.ResourceDrawableIdHelper
import java.util.*
object ResourceIdHelper {
private val idMap = mutableMapOf<String, Int>()
@SuppressLint("DiscouragedApi")
private fun getResourceRawId(context: Context, name: String): Int {
if (name.isEmpty()) {
return -1
}
val normalizedName = name.lowercase(Locale.ROOT).replace("-", "_")
synchronized(this) {
val id = idMap[normalizedName]
if (id != null) {
return id
}
return context
.resources
.getIdentifier(normalizedName, "raw", context.packageName)
.also {
idMap[normalizedName] = it
}
}
}
fun getResourceUri(context: Context, name: String): Uri? {
val drawableUri = ResourceDrawableIdHelper.getResourceDrawableUri(context, name)
if (drawableUri != Uri.EMPTY) {
return drawableUri
}
val resId = getResourceRawId(context, name)
return if (resId > 0) {
Uri.Builder().scheme("res").path(resId.toString()).build()
} else {
null
}
}
}

View File

@@ -0,0 +1,21 @@
package expo.modules.image
import android.util.Log
import com.bumptech.glide.request.Request
import com.bumptech.glide.request.ThumbnailRequestCoordinator
fun ThumbnailRequestCoordinator.getPrivateFullRequest(): Request? {
return getPrivateField("full")
}
private fun <T> ThumbnailRequestCoordinator.getPrivateField(name: String): T? {
return try {
val field = this.javaClass.getDeclaredField(name)
field.isAccessible = true
@Suppress("UNCHECKED_CAST")
field.get(this) as T
} catch (e: Throwable) {
Log.e("ExpoImage", "Couldn't receive the `$name` field", e)
null
}
}

View File

@@ -0,0 +1,11 @@
package expo.modules.image
object Trace {
val tag = "ExpoImage"
val loadNewImageBlock = "load new image"
private var lastCookieValue = 0
fun getNextCookieValue() = synchronized(this) {
lastCookieValue++
}
}

View File

@@ -0,0 +1,20 @@
package expo.modules.image
import com.facebook.yoga.YogaConstants
fun Float.ifYogaUndefinedUse(value: Float) =
if (YogaConstants.isUndefined(this)) {
value
} else {
this
}
inline fun Float.ifYogaDefinedUse(transformFun: (current: Float) -> Float) =
if (YogaConstants.isUndefined(this)) {
this
} else {
transformFun(this)
}
fun makeYogaUndefinedIfNegative(value: Float) =
if (!YogaConstants.isUndefined(value) && value < 0) YogaConstants.UNDEFINED else value

View File

@@ -0,0 +1,32 @@
package expo.modules.image.blurhash
import android.graphics.Bitmap
import com.bumptech.glide.Priority
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.data.DataFetcher
import expo.modules.kotlin.exception.CodedException
class BlurhashDecodingFailure(blurHash: String?) : CodedException(
message = "Cannot decode provided blurhash '$blurHash'"
)
class BlurHashFetcher(
private val blurHash: String?,
private val width: Int,
private val height: Int,
private val punch: Float
) : DataFetcher<Bitmap> {
override fun cleanup() = Unit
override fun cancel() = Unit
override fun getDataClass(): Class<Bitmap> = Bitmap::class.java
override fun getDataSource(): DataSource = DataSource.LOCAL
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in Bitmap>) {
val bitmap = BlurhashDecoder.decode(blurHash, width, height, punch)
if (bitmap == null) {
callback.onLoadFailed(BlurhashDecodingFailure(blurHash))
return
}
callback.onDataReady(bitmap)
}
}

View File

@@ -0,0 +1,180 @@
package expo.modules.image.blurhash
import android.graphics.Bitmap
import android.graphics.Color
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.withSign
/**
* Copied from https://github.com/woltapp/blurhash.
*/
object BlurhashDecoder {
// cache Math.cos() calculations to improve performance.
// The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * 2 * nBitmaps
// the cache is enabled by default, it is recommended to disable it only when just a few images are displayed
private val cacheCosinesX = HashMap<Int, DoubleArray>()
private val cacheCosinesY = HashMap<Int, DoubleArray>()
/**
* Clear calculations stored in memory cache.
* The cache is not big, but will increase when many image sizes are used,
* if the app needs memory it is recommended to clear it.
*/
fun clearCache() {
cacheCosinesX.clear()
cacheCosinesY.clear()
}
/**
* Decode a blur hash into a new bitmap.
*
* @param useCache use in memory cache for the calculated math, reused by images with same size.
* if the cache does not exist yet it will be created and populated with new calculations.
* By default it is true.
*/
fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f, useCache: Boolean = true): Bitmap? {
if (blurHash == null || blurHash.length < 6) {
return null
}
val numCompEnc = decode83(blurHash, 0, 1)
val numCompX = (numCompEnc % 9) + 1
val numCompY = (numCompEnc / 9) + 1
if (blurHash.length != 4 + 2 * numCompX * numCompY) {
return null
}
val maxAcEnc = decode83(blurHash, 1, 2)
val maxAc = (maxAcEnc + 1) / 166f
val colors = Array(numCompX * numCompY) { i ->
if (i == 0) {
val colorEnc = decode83(blurHash, 2, 6)
decodeDc(colorEnc)
} else {
val from = 4 + i * 2
val colorEnc = decode83(blurHash, from, from + 2)
decodeAc(colorEnc, maxAc * punch)
}
}
return composeBitmap(width, height, numCompX, numCompY, colors, useCache)
}
private fun decode83(str: String, from: Int = 0, to: Int = str.length): Int {
var result = 0
for (i in from until to) {
val index = charMap[str[i]] ?: -1
if (index != -1) {
result = result * 83 + index
}
}
return result
}
private fun decodeDc(colorEnc: Int): FloatArray {
val r = colorEnc shr 16
val g = (colorEnc shr 8) and 255
val b = colorEnc and 255
return floatArrayOf(BlurhashHelpers.srgbToLinear(r), BlurhashHelpers.srgbToLinear(g), BlurhashHelpers.srgbToLinear(b))
}
private fun decodeAc(value: Int, maxAc: Float): FloatArray {
val r = value / (19 * 19)
val g = (value / 19) % 19
val b = value % 19
return floatArrayOf(
signedPow2((r - 9) / 9.0f) * maxAc,
signedPow2((g - 9) / 9.0f) * maxAc,
signedPow2((b - 9) / 9.0f) * maxAc
)
}
private fun signedPow2(value: Float) = value.pow(2f).withSign(value)
private fun composeBitmap(
width: Int,
height: Int,
numCompX: Int,
numCompY: Int,
colors: Array<FloatArray>,
useCache: Boolean
): Bitmap {
// use an array for better performance when writing pixel colors
val imageArray = IntArray(width * height)
val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX)
val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX)
val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY)
val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY)
for (y in 0 until height) {
for (x in 0 until width) {
var r = 0f
var g = 0f
var b = 0f
for (j in 0 until numCompY) {
for (i in 0 until numCompX) {
val cosX = cosinesX.getCos(calculateCosX, i, numCompX, x, width)
val cosY = cosinesY.getCos(calculateCosY, j, numCompY, y, height)
val basis = (cosX * cosY).toFloat()
val color = colors[j * numCompX + i]
r += color[0] * basis
g += color[1] * basis
b += color[2] * basis
}
}
imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b))
}
}
return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888)
}
private fun getArrayForCosinesY(calculate: Boolean, height: Int, numCompY: Int) = when {
calculate -> {
DoubleArray(height * numCompY).also {
cacheCosinesY[height * numCompY] = it
}
}
else -> {
cacheCosinesY[height * numCompY]!!
}
}
private fun getArrayForCosinesX(calculate: Boolean, width: Int, numCompX: Int) = when {
calculate -> {
DoubleArray(width * numCompX).also {
cacheCosinesX[width * numCompX] = it
}
}
else -> cacheCosinesX[width * numCompX]!!
}
private fun DoubleArray.getCos(
calculate: Boolean,
x: Int,
numComp: Int,
y: Int,
size: Int
): Double {
if (calculate) {
this[x + numComp * y] = cos(Math.PI * y * x / size)
}
return this[x + numComp * y]
}
private fun linearToSrgb(value: Float): Int {
val v = value.coerceIn(0f, 1f)
return if (v <= 0.0031308f) {
(v * 12.92f * 255f + 0.5f).toInt()
} else {
((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt()
}
}
private val charMap = listOf(
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G',
'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X',
'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o',
'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',',
'-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~'
)
.mapIndexed { i, c -> c to i }
.toMap()
}

View File

@@ -0,0 +1,112 @@
package expo.modules.image.blurhash
import android.graphics.Bitmap
import android.graphics.Color
import kotlin.math.*
/**
* Rewritten in kotlin from https://github.com/woltapp/blurhash/blob/master/Swift/BlurHashEncode.swift
*/
object BlurhashEncoder {
fun encode(image: Bitmap, numberOfComponents: Pair<Int, Int>): String {
val pixels = IntArray(image.width * image.height)
image.getPixels(pixels, 0, image.width, 0, 0, image.width, image.height)
val factors = calculateBlurFactors(pixels, image.width, image.height, numberOfComponents)
val dc = factors.first()
val ac = factors.drop(1)
val hashBuilder = StringBuilder()
encodeFlag(numberOfComponents, hashBuilder)
val maximumValue = encodeMaximumValue(ac, hashBuilder)
hashBuilder.append(encode83(encodeDC(dc), 4))
for (factor in ac) {
hashBuilder.append(encode83(encodeAC(factor, maximumValue), 2))
}
return hashBuilder.toString()
}
private fun encodeFlag(numberOfComponents: Pair<Int, Int>, hashBuilder: StringBuilder) {
val sizeFlag = (numberOfComponents.first - 1) + (numberOfComponents.second - 1) * 9
hashBuilder.append(encode83(sizeFlag, 1))
}
private fun encodeMaximumValue(ac: List<Triple<Float, Float, Float>>, hash: StringBuilder): Float {
val maximumValue: Float
if (ac.isNotEmpty()) {
val actualMaximumValue = ac.maxOf { t -> max(max(abs(t.first), abs(t.second)), abs(t.third)) }
val quantisedMaximumValue = max(0f, min(82f, floor(actualMaximumValue * 166f - 0.5f))).toInt()
maximumValue = (quantisedMaximumValue + 1).toFloat() / 166f
hash.append(encode83(quantisedMaximumValue, 1))
} else {
maximumValue = 1f
hash.append(encode83(0, 1))
}
return maximumValue
}
private fun calculateBlurFactors(pixels: IntArray, width: Int, height: Int, numberOfComponents: Pair<Int, Int>): List<Triple<Float, Float, Float>> {
val factors = mutableListOf<Triple<Float, Float, Float>>()
for (y in 0 until numberOfComponents.second) {
for (x in 0 until numberOfComponents.first) {
val normalisation = if (x == 0 && y == 0) 1f else 2f
val factor = multiplyBasisFunction(pixels, width, height, x, y, normalisation)
factors.add(factor)
}
}
return factors
}
private fun encode83(value: Int, length: Int): String {
var result = ""
for (i in 1..length) {
val digit = (value / 83f.pow((length - i).toFloat())) % 83f
result += ENCODE_CHARACTERS[digit.toInt()]
}
return result
}
private const val ENCODE_CHARACTERS =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
private fun encodeDC(value: Triple<Float, Float, Float>): Int {
val roundedR = BlurhashHelpers.linearTosRGB(value.first)
val roundedG = BlurhashHelpers.linearTosRGB(value.second)
val roundedB = BlurhashHelpers.linearTosRGB(value.third)
return (roundedR shl 16) + (roundedG shl 8) + roundedB
}
private fun encodeAC(value: Triple<Float, Float, Float>, maximumValue: Float): Int {
val quantR = max(0f, min(18f, floor(BlurhashHelpers.signPow(value.first / maximumValue, 0.5f) * 9f + 9.5f)))
val quantG = max(0f, min(18f, floor(BlurhashHelpers.signPow(value.second / maximumValue, 0.5f) * 9f + 9.5f)))
val quantB = max(0f, min(18f, floor(BlurhashHelpers.signPow(value.third / maximumValue, 0.5f) * 9f + 9.5f)))
return (quantR * 19f * 19f + quantG * 19f + quantB).toInt()
}
private fun multiplyBasisFunction(pixels: IntArray, width: Int, height: Int, x: Int, y: Int, normalisation: Float): Triple<Float, Float, Float> {
var r = 0f
var g = 0f
var b = 0f
for (j in 0 until height) {
for (i in 0 until width) {
val basis = normalisation * cos(PI.toFloat() * x * i / width) * cos(PI.toFloat() * y * j / height)
val pixel = pixels[i + j * width]
val pr = BlurhashHelpers.srgbToLinear(Color.red(pixel))
val pg = BlurhashHelpers.srgbToLinear(Color.green(pixel))
val pb = BlurhashHelpers.srgbToLinear(Color.blue(pixel))
r += basis * pr
g += basis * pg
b += basis * pb
}
}
val scale = 1f / (width * height)
return Triple(r * scale, g * scale, b * scale)
}
}

View File

@@ -0,0 +1,38 @@
package expo.modules.image.blurhash
import android.graphics.Bitmap
import kotlin.math.*
object BlurhashHelpers {
fun srgbToLinear(colorEnc: Int): Float {
val v = colorEnc / 255f
return if (v <= 0.04045f) {
(v / 12.92f)
} else {
((v + 0.055f) / 1.055f).pow(2.4f)
}
}
fun linearTosRGB(value: Float): Int {
val v = max(0f, min(1f, value))
return if (v <= 0.0031308) {
(v * 12.92 * 255 + 0.5).toInt()
} else {
(1.055 * (v.pow(1f / 2.4f) - 0.055) * 255 + 0.5).toInt()
}
}
fun signPow(value: Float, exp: Float): Float {
return abs(value).pow(exp) * sign(value)
}
fun getBitsPerPixel(bitmap: Bitmap): Int {
return when (bitmap.config) {
Bitmap.Config.ARGB_8888 -> 32
Bitmap.Config.RGB_565 -> 16
Bitmap.Config.ALPHA_8 -> 8
Bitmap.Config.ARGB_4444 -> 16
else -> 0
}
}
}

View File

@@ -0,0 +1,9 @@
package expo.modules.image.blurhash
import android.net.Uri
data class BlurhashModel(
val uri: Uri,
val width: Int,
val height: Int
)

View File

@@ -0,0 +1,29 @@
package expo.modules.image.blurhash
import android.graphics.Bitmap
import android.net.Uri
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.signature.ObjectKey
class BlurhashModelLoader : ModelLoader<BlurhashModel, Bitmap> {
override fun handles(model: BlurhashModel): Boolean = true
override fun buildLoadData(
model: BlurhashModel,
width: Int,
height: Int,
options: Options
): ModelLoader.LoadData<Bitmap> {
val blurhash = getPath(model.uri, 0, null) { it }
return ModelLoader.LoadData(
ObjectKey(model),
BlurHashFetcher(blurhash, model.width, model.height, 1f)
)
}
private fun <T> getPath(uri: Uri, index: Int, default: T, converter: (String) -> T): T {
val value = uri.pathSegments.getOrNull(index) ?: return default
return converter(value)
}
}

View File

@@ -0,0 +1,13 @@
package expo.modules.image.blurhash
import android.graphics.Bitmap
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
class BlurhashModelLoaderFactory : ModelLoaderFactory<BlurhashModel, Bitmap> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<BlurhashModel, Bitmap> =
BlurhashModelLoader()
override fun teardown() = Unit
}

View File

@@ -0,0 +1,16 @@
package expo.modules.image.blurhash
import android.content.Context
import android.graphics.Bitmap
import com.bumptech.glide.Glide
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.module.LibraryGlideModule
@GlideModule
class BlurhashModule : LibraryGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
super.registerComponents(context, glide, registry)
registry.prepend(BlurhashModel::class.java, Bitmap::class.java, BlurhashModelLoaderFactory())
}
}

View File

@@ -0,0 +1,27 @@
package expo.modules.image.dataurls
import android.util.Base64
import com.bumptech.glide.Priority
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.data.DataFetcher
import java.nio.ByteBuffer
class Base64DataFetcher(private val data: String) : DataFetcher<ByteBuffer> {
override fun cleanup() = Unit
override fun cancel() = Unit
override fun getDataClass(): Class<ByteBuffer> = ByteBuffer::class.java
override fun getDataSource(): DataSource = DataSource.LOCAL
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in ByteBuffer>) {
val base64Section = getBase64Section()
val data = Base64.decode(base64Section, Base64.DEFAULT)
val byteBuffer = ByteBuffer.wrap(data)
callback.onDataReady(byteBuffer)
}
private fun getBase64Section(): String {
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs.
val startOfBase64Section = data.indexOf(',')
return data.substring(startOfBase64Section + 1)
}
}

View File

@@ -0,0 +1,24 @@
package expo.modules.image.dataurls
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.signature.ObjectKey
import java.nio.ByteBuffer
/**
* Loads an [java.io.InputStream] from a Base 64 encoded String.
*/
class Base64ModelLoader : ModelLoader<String, ByteBuffer> {
override fun handles(model: String): Boolean {
return model.startsWith("data:")
}
override fun buildLoadData(
model: String,
width: Int,
height: Int,
options: Options
): ModelLoader.LoadData<ByteBuffer> {
return ModelLoader.LoadData(ObjectKey(model), Base64DataFetcher(model))
}
}

View File

@@ -0,0 +1,12 @@
package expo.modules.image.dataurls
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
import java.nio.ByteBuffer
class Base64ModelLoaderFactory : ModelLoaderFactory<String, ByteBuffer> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<String, ByteBuffer> =
Base64ModelLoader()
override fun teardown() = Unit
}

View File

@@ -0,0 +1,16 @@
package expo.modules.image.dataurls
import android.content.Context
import com.bumptech.glide.Glide
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.module.LibraryGlideModule
import java.nio.ByteBuffer
@GlideModule
class Base64Module : LibraryGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
super.registerComponents(context, glide, registry)
registry.prepend(String::class.java, ByteBuffer::class.java, Base64ModelLoaderFactory())
}
}

View File

@@ -0,0 +1,19 @@
package expo.modules.image.decodedsource
import android.graphics.drawable.Drawable
import com.bumptech.glide.Priority
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.data.DataFetcher
class DecodedFetcher(
private val drawable: Drawable
) : DataFetcher<Drawable> {
override fun cleanup() = Unit
override fun cancel() = Unit
override fun getDataClass(): Class<Drawable> = Drawable::class.java
override fun getDataSource(): DataSource = DataSource.LOCAL
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in Drawable>) {
callback.onDataReady(drawable)
}
}

View File

@@ -0,0 +1,5 @@
package expo.modules.image.decodedsource
import android.graphics.drawable.Drawable
class DecodedModel(val drawable: Drawable)

View File

@@ -0,0 +1,18 @@
package expo.modules.image.decodedsource
import android.graphics.drawable.Drawable
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.signature.ObjectKey
class DecodedModelLoader : ModelLoader<DecodedModel, Drawable> {
override fun handles(model: DecodedModel): Boolean = true
override fun buildLoadData(
model: DecodedModel,
width: Int,
height: Int,
options: Options
): ModelLoader.LoadData<Drawable> {
return ModelLoader.LoadData(ObjectKey(model), DecodedFetcher(model.drawable))
}
}

View File

@@ -0,0 +1,13 @@
package expo.modules.image.decodedsource
import android.graphics.drawable.Drawable
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
class DecodedModelLoaderFactory : ModelLoaderFactory<DecodedModel, Drawable> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<DecodedModel, Drawable> =
DecodedModelLoader()
override fun teardown() = Unit
}

View File

@@ -0,0 +1,16 @@
package expo.modules.image.decodedsource
import android.content.Context
import android.graphics.drawable.Drawable
import com.bumptech.glide.Glide
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.module.LibraryGlideModule
@GlideModule
class DecodedModule : LibraryGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
super.registerComponents(context, glide, registry)
registry.prepend(DecodedModel::class.java, Drawable::class.java, DecodedModelLoaderFactory())
}
}

View File

@@ -0,0 +1,218 @@
package expo.modules.image.drawing
import android.content.Context
import android.graphics.Canvas
import android.graphics.Outline
import android.graphics.Path
import android.graphics.RectF
import android.os.Build
import android.view.View
import android.view.ViewOutlineProvider
import com.facebook.react.modules.i18nmanager.I18nUtil
import com.facebook.react.uimanager.FloatUtil
import com.facebook.react.uimanager.PixelUtil
import com.facebook.yoga.YogaConstants
import expo.modules.image.ifYogaUndefinedUse
class OutlineProvider(private val mContext: Context) : ViewOutlineProvider() {
enum class BorderRadiusConfig {
ALL,
TOP_LEFT,
TOP_RIGHT,
BOTTOM_RIGHT,
BOTTOM_LEFT,
TOP_START,
TOP_END,
BOTTOM_START,
BOTTOM_END
}
enum class CornerRadius {
TOP_LEFT,
TOP_RIGHT,
BOTTOM_RIGHT,
BOTTOM_LEFT
}
private var mLayoutDirection = View.LAYOUT_DIRECTION_LTR
private val mBounds = RectF()
val borderRadiiConfig = FloatArray(9) { YogaConstants.UNDEFINED }
private val mCornerRadii = FloatArray(4)
private var mCornerRadiiInvalidated = true
private val mConvexPath = Path()
private var mConvexPathInvalidated = true
init {
updateCornerRadiiIfNeeded()
}
private fun updateCornerRadiiIfNeeded() {
if (!mCornerRadiiInvalidated) {
return
}
val isRTL = mLayoutDirection == View.LAYOUT_DIRECTION_RTL
val isRTLSwap = I18nUtil.instance.doLeftAndRightSwapInRTL(mContext)
updateCornerRadius(
CornerRadius.TOP_LEFT,
BorderRadiusConfig.TOP_LEFT,
BorderRadiusConfig.TOP_RIGHT,
BorderRadiusConfig.TOP_START,
BorderRadiusConfig.TOP_END,
isRTL,
isRTLSwap
)
updateCornerRadius(
CornerRadius.TOP_RIGHT,
BorderRadiusConfig.TOP_RIGHT,
BorderRadiusConfig.TOP_LEFT,
BorderRadiusConfig.TOP_END,
BorderRadiusConfig.TOP_START,
isRTL,
isRTLSwap
)
updateCornerRadius(
CornerRadius.BOTTOM_LEFT,
BorderRadiusConfig.BOTTOM_LEFT,
BorderRadiusConfig.BOTTOM_RIGHT,
BorderRadiusConfig.BOTTOM_START,
BorderRadiusConfig.BOTTOM_END,
isRTL,
isRTLSwap
)
updateCornerRadius(
CornerRadius.BOTTOM_RIGHT,
BorderRadiusConfig.BOTTOM_RIGHT,
BorderRadiusConfig.BOTTOM_LEFT,
BorderRadiusConfig.BOTTOM_END,
BorderRadiusConfig.BOTTOM_START,
isRTL,
isRTLSwap
)
mCornerRadiiInvalidated = false
mConvexPathInvalidated = true
}
private fun updateCornerRadius(
outputPosition: CornerRadius,
inputPosition: BorderRadiusConfig,
oppositePosition: BorderRadiusConfig,
startPosition: BorderRadiusConfig,
endPosition: BorderRadiusConfig,
isRTL: Boolean,
isRTLSwap: Boolean
) {
var radius = borderRadiiConfig[inputPosition.ordinal]
if (isRTL) {
if (isRTLSwap) {
radius = borderRadiiConfig[oppositePosition.ordinal]
}
if (YogaConstants.isUndefined(radius)) {
radius = borderRadiiConfig[endPosition.ordinal]
}
} else {
if (YogaConstants.isUndefined(radius)) {
radius = borderRadiiConfig[startPosition.ordinal]
}
}
radius = radius
.ifYogaUndefinedUse(borderRadiiConfig[BorderRadiusConfig.ALL.ordinal])
.ifYogaUndefinedUse(0f)
mCornerRadii[outputPosition.ordinal] = PixelUtil.toPixelFromDIP(radius)
}
private fun updateConvexPathIfNeeded() {
if (!mConvexPathInvalidated) {
return
}
mConvexPath.reset()
mConvexPath.addRoundRect(
mBounds,
floatArrayOf(
mCornerRadii[CornerRadius.TOP_LEFT.ordinal],
mCornerRadii[CornerRadius.TOP_LEFT.ordinal],
mCornerRadii[CornerRadius.TOP_RIGHT.ordinal],
mCornerRadii[CornerRadius.TOP_RIGHT.ordinal],
mCornerRadii[CornerRadius.BOTTOM_RIGHT.ordinal],
mCornerRadii[CornerRadius.BOTTOM_RIGHT.ordinal],
mCornerRadii[CornerRadius.BOTTOM_LEFT.ordinal],
mCornerRadii[CornerRadius.BOTTOM_LEFT.ordinal]
),
Path.Direction.CW
)
mConvexPathInvalidated = false
}
fun hasEqualCorners(): Boolean {
updateCornerRadiiIfNeeded()
val initialCornerRadius = mCornerRadii[0]
return mCornerRadii.all { initialCornerRadius == it }
}
fun setBorderRadius(radius: Float, position: Int): Boolean {
if (!FloatUtil.floatsEqual(borderRadiiConfig[position], radius)) {
borderRadiiConfig[position] = radius
mCornerRadiiInvalidated = true
return true
}
return false
}
private fun updateBoundsAndLayoutDirection(view: View) {
// Update layout direction
val layoutDirection = view.layoutDirection
if (mLayoutDirection != layoutDirection) {
mLayoutDirection = layoutDirection
mCornerRadiiInvalidated = true
}
// Update size
val left = 0
val top = 0
val right = view.width
val bottom = view.height
if (mBounds.left != left.toFloat() ||
mBounds.top != top.toFloat() ||
mBounds.right != right.toFloat() ||
mBounds.bottom != bottom.toFloat()
) {
mBounds[left.toFloat(), top.toFloat(), right.toFloat()] = bottom.toFloat()
mCornerRadiiInvalidated = true
}
}
override fun getOutline(view: View, outline: Outline) {
updateBoundsAndLayoutDirection(view)
// Calculate outline
updateCornerRadiiIfNeeded()
if (hasEqualCorners()) {
val cornerRadius = mCornerRadii[0]
if (cornerRadius > 0) {
outline.setRoundRect(0, 0, mBounds.width().toInt(), mBounds.height().toInt(), cornerRadius)
} else {
outline.setRect(0, 0, mBounds.width().toInt(), mBounds.height().toInt())
}
} else {
// Clipping is not supported when using a convex path, but drawing the elevation
// shadow is. For the particular case, we fallback to canvas clipping in the view
// which is supposed to call `clipCanvasIfNeeded` in its `draw` method.
updateConvexPathIfNeeded()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
outline.setPath(mConvexPath)
} else {
@Suppress("DEPRECATION")
outline.setConvexPath(mConvexPath)
}
}
}
fun clipCanvasIfNeeded(canvas: Canvas, view: View) {
updateBoundsAndLayoutDirection(view)
updateCornerRadiiIfNeeded()
if (!hasEqualCorners()) {
updateConvexPathIfNeeded()
canvas.clipPath(mConvexPath)
}
}
}

View File

@@ -0,0 +1,89 @@
package expo.modules.image.enums
import android.graphics.Matrix
import android.graphics.RectF
import expo.modules.kotlin.types.Enumerable
import kotlin.math.max
/**
* Describes how the image should be resized to fit its container.
* - Note: It mirrors the CSS [`object-fit`](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) property.
*/
enum class ContentFit(val value: String) : Enumerable {
/**
* The image is scaled to maintain its aspect ratio while fitting within the container's box.
* The entire image is made to fill the box, while preserving its aspect ratio,
* so the image will be "letterboxed" if its aspect ratio does not match the aspect ratio of the box.
*/
Contain("contain"),
/**
* The image is sized to maintain its aspect ratio while filling the element's entire content box.
* If the image's aspect ratio does not match the aspect ratio of its box, then the object will be clipped to fit.
*/
Cover("cover"),
/**
* The image is sized to fill the element's content box. The entire object will completely fill the box.
* If the image's aspect ratio does not match the aspect ratio of its box, then the image will be stretched to fit.
*/
Fill("fill"),
/**
* The image is not resized and is centered by default.
* When specified, the exact position can be controlled with `ContentPosition`.
*/
None("none"),
/**
* The image is sized as if `none` or `contain` were specified,
* whichever would result in a smaller concrete image size.
*/
ScaleDown("scale-down");
internal fun toMatrix(imageRect: RectF, viewRect: RectF, sourceWidth: Int, sourceHeight: Int) = Matrix().apply {
when (this@ContentFit) {
Contain -> setRectToRect(imageRect, viewRect, Matrix.ScaleToFit.START)
Cover -> {
val imageWidth = imageRect.width()
val imageHeight = imageRect.height()
val reactWidth = viewRect.width()
val reactHeight = viewRect.height()
val scale = max(reactWidth / imageWidth, reactHeight / imageHeight)
setScale(scale, scale)
}
Fill -> setRectToRect(imageRect, viewRect, Matrix.ScaleToFit.FILL)
None -> {
// we don't need to do anything
}
ScaleDown -> {
// If we have information about the original size of the source, we can resize the image more drastically.
// In certain situations, we may even permit upscaling when we anticipate the image to be reloaded without any reduction in size,
// which will create a seamless transition between various states.
if (sourceWidth != -1 && sourceHeight != -1) {
val sourceRect = RectF(0f, 0f, sourceWidth.toFloat(), sourceHeight.toFloat())
// Rather than checking if the image rectangle is within the bounds of the view rectangle, we verify the original source rectangle.
// We know that the newly loaded image has larger dimensions than the current one, and therefore,
// it will not be downscaled.
if (sourceRect.width() >= viewRect.width() || sourceRect.height() >= viewRect.height()) {
setRectToRect(imageRect, viewRect, Matrix.ScaleToFit.START)
}
// If the source rectangle is larger than the view rectangle and the image rectangle has not been upscaled to match the source rectangle,
// temporary upscaling is necessary to ensure a seamless transition.
// It should be noted that this upscaling is applied to the downscaled version of the image,
// not the original source image, and will be replaced by the original asset shortly thereafter.
else {
setRectToRect(imageRect, sourceRect, Matrix.ScaleToFit.START)
}
} else {
if (imageRect.width() >= viewRect.width() || imageRect.height() >= viewRect.height()) {
setRectToRect(imageRect, viewRect, Matrix.ScaleToFit.START)
}
}
}
}
}
}

View File

@@ -0,0 +1,14 @@
package expo.modules.image.enums
import com.bumptech.glide.load.DataSource
enum class ImageCacheType(private vararg val dataSources: DataSource) {
NONE(DataSource.LOCAL, DataSource.REMOTE),
DISK(DataSource.DATA_DISK_CACHE, DataSource.RESOURCE_DISK_CACHE),
MEMORY(DataSource.MEMORY_CACHE);
companion object {
fun fromNativeValue(value: DataSource): ImageCacheType =
entries.firstOrNull { it.dataSources.contains(value) } ?: NONE
}
}

View File

@@ -0,0 +1,15 @@
package expo.modules.image.enums
import expo.modules.kotlin.types.Enumerable
enum class Priority(val value: String) : Enumerable {
LOW("low"),
NORMAL("normal"),
HIGH("high");
internal fun toGlidePriority(): com.bumptech.glide.Priority = when (this) {
LOW -> com.bumptech.glide.Priority.LOW
NORMAL -> com.bumptech.glide.Priority.NORMAL
HIGH -> com.bumptech.glide.Priority.IMMEDIATE
}
}

View File

@@ -0,0 +1,77 @@
package expo.modules.image.events
import android.graphics.drawable.Animatable
import android.graphics.drawable.Drawable
import android.util.Log
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import expo.modules.image.ExpoImageViewWrapper
import expo.modules.image.enums.ImageCacheType
import expo.modules.image.records.ImageErrorEvent
import expo.modules.image.records.ImageLoadEvent
import expo.modules.image.records.ImageSource
import expo.modules.image.svg.SVGPictureDrawable
import kotlinx.coroutines.launch
import java.lang.ref.WeakReference
import java.util.Locale
class GlideRequestListener(
private val expoImageViewWrapper: WeakReference<ExpoImageViewWrapper>
) : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>,
isFirstResource: Boolean
): Boolean {
val errorMessage = e
?.message
// Glide always append that line to the end of the message.
// It's not possible to call `logRootCauses` from the JS, so we decided to remove it.
?.removeSuffix("\n call GlideException#logRootCauses(String) for more detail")
?: "Unknown error"
expoImageViewWrapper
.get()
?.onError
?.invoke(ImageErrorEvent(errorMessage))
Log.e("ExpoImage", errorMessage)
e?.logRootCauses("ExpoImage")
return false
}
override fun onResourceReady(
resource: Drawable,
model: Any,
target: Target<Drawable>,
dataSource: DataSource,
isFirstResource: Boolean
): Boolean {
val intrinsicWidth = (resource as? SVGPictureDrawable)?.svgIntrinsicWidth
?: resource.intrinsicWidth
val intrinsicHeight = (resource as? SVGPictureDrawable)?.svgIntrinsicHeight
?: resource.intrinsicHeight
val imageWrapper = expoImageViewWrapper.get() ?: return false
val appContext = imageWrapper.appContext
appContext.mainQueue.launch {
imageWrapper.onLoad.invoke(
ImageLoadEvent(
cacheType = ImageCacheType.fromNativeValue(dataSource).name.lowercase(Locale.getDefault()),
source = ImageSource(
url = model.toString(),
width = intrinsicWidth,
height = intrinsicHeight,
mediaType = null, // TODO(@lukmccall): add mediaType
isAnimated = resource is Animatable
)
)
)
}
return false
}
}

View File

@@ -0,0 +1,26 @@
package expo.modules.image.events
import com.facebook.react.modules.network.ProgressListener
import expo.modules.image.ExpoImageViewWrapper
import expo.modules.image.records.ImageProgressEvent
import java.lang.ref.WeakReference
class OkHttpProgressListener(
private val expoImageViewWrapper: WeakReference<ExpoImageViewWrapper>
) : ProgressListener {
override fun onProgress(bytesWritten: Long, contentLength: Long, done: Boolean) {
// OkHttp calls that function twice at the end - when the last byte was downloaded with done set to false,
// and also shortly after, with done set to true. In both cases, the bytesWritten and the contentLength are equal.
// We want to avoid sending two same events to JS, that's why we return when done is set to true.
if (contentLength <= 0 || done) {
return
}
expoImageViewWrapper.get()?.onProgress?.invoke(
ImageProgressEvent(
loaded = bytesWritten.toInt(),
total = contentLength.toInt()
)
)
}
}

View File

@@ -0,0 +1,107 @@
package expo.modules.image.okhttp
import android.content.Context
import android.webkit.CookieManager
import com.bumptech.glide.Glide
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader
import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.load.model.Headers
import com.bumptech.glide.module.LibraryGlideModule
import expo.modules.image.events.OkHttpProgressListener
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import java.io.InputStream
/**
* GlideUrl with custom cache key.
* It wraps the base implementation and overrides logic behind cache key and when two
* objects are equal. Typically, Glide uses the only cache key to compare objects.
* It won't suit our use case. We want to make custom cache key transparent and
* not use it to compare objects.
*/
class GlideUrlWithCustomCacheKey(
uri: String?,
headers: Headers?,
private val cacheKey: String
) : GlideUrl(uri, headers) {
/**
* Cached hash code value
*/
private var hashCode = 0
/**
* @return a super cache key from [GlideUrl]
*/
private fun getBaseCacheKey(): String = super.getCacheKey()
override fun getCacheKey(): String = cacheKey
// Mostly copied from GlideUrl::equal
override fun equals(other: Any?): Boolean {
if (other is GlideUrlWithCustomCacheKey) {
return getBaseCacheKey() == other.getBaseCacheKey() && headers.equals(other.headers)
} else if (other is GlideUrl) {
return getBaseCacheKey() == other.cacheKey && headers.equals(other.headers)
}
return false
}
// Mostly copied from GlideUrl::hashCode
override fun hashCode(): Int {
if (hashCode == 0) {
hashCode = getBaseCacheKey().hashCode()
hashCode = 31 * hashCode + headers.hashCode()
}
return hashCode
}
}
/**
* To connect listener with the request we have to create custom model.
* In that way, we're passing more information to the final data loader.
*/
data class GlideUrlWrapper(val glideUrl: GlideUrl) {
var progressListener: OkHttpProgressListener? = null
override fun toString(): String {
return glideUrl.toString()
}
}
private object SharedCookieJar : CookieJar {
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val cookieManager = getCookieManager() ?: return
val urlString = url.toString()
for (cookie in cookies) {
cookieManager.setCookie(urlString, cookie.toString())
}
cookieManager.flush()
}
override fun loadForRequest(url: HttpUrl): List<Cookie> {
val cookieManager = getCookieManager() ?: return emptyList()
val cookieString = cookieManager.getCookie(url.toString()) ?: return emptyList()
return cookieString.split(";").mapNotNull { Cookie.parse(url, it.trim()) }
}
private fun getCookieManager(): CookieManager? = runCatching {
CookieManager.getInstance()
}.getOrNull()
}
@GlideModule
class ExpoImageOkHttpClientGlideModule : LibraryGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
val client = OkHttpClient.Builder()
.cookieJar(SharedCookieJar)
.build()
// We don't use the `GlideUrl` directly but we want to replace the default okhttp loader anyway
// to make sure that the app will use only one client.
registry.replace(GlideUrl::class.java, InputStream::class.java, OkHttpUrlLoader.Factory(client))
registry.prepend(GlideUrlWrapper::class.java, InputStream::class.java, GlideUrlWrapperLoader.Factory(client))
}
}

View File

@@ -0,0 +1,55 @@
package expo.modules.image.okhttp
import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.facebook.react.modules.network.ProgressResponseBody
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import java.io.InputStream
class GlideUrlWrapperLoader(
private val commonClient: OkHttpClient
) : ModelLoader<GlideUrlWrapper, InputStream> {
override fun buildLoadData(
model: GlideUrlWrapper,
width: Int,
height: Int,
options: Options
): ModelLoader.LoadData<InputStream>? {
val loader = OkHttpUrlLoader(
commonClient
.newBuilder()
.addInterceptor(
Interceptor { chain ->
val originalResponse = chain.proceed(chain.request())
originalResponse
.newBuilder()
.body(
ProgressResponseBody(requireNotNull(originalResponse.body)) { bytesWritten, contentLength, done ->
model.progressListener?.onProgress(bytesWritten, contentLength, done)
}
)
.build()
}
)
.build()
)
return loader.buildLoadData(model.glideUrl, width, height, options)
}
// The default http loader always returns true.
override fun handles(model: GlideUrlWrapper): Boolean = true
class Factory(
private val commonClient: OkHttpClient
) : ModelLoaderFactory<GlideUrlWrapper, InputStream> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<GlideUrlWrapper, InputStream> =
GlideUrlWrapperLoader(commonClient)
override fun teardown() = Unit
}
}

View File

@@ -0,0 +1,10 @@
package expo.modules.image.records
import expo.modules.kotlin.types.Enumerable
enum class CachePolicy(val value: String) : Enumerable {
NONE("none"),
DISK("disk"),
MEMORY("memory"),
MEMORY_AND_DISK("memory-disk")
}

View File

@@ -0,0 +1,89 @@
package expo.modules.image.records
import android.graphics.Matrix
import android.graphics.RectF
import expo.modules.image.calcXTranslation
import expo.modules.image.calcYTranslation
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
/**
* Represents a position value that might be either `Double` or `String`.
* TODO(@lukmccall): Use `Either` instead of `Any`
*/
typealias ContentPositionValue = Any
private typealias CalcAxisOffset = (
value: Float,
imageRect: RectF,
viewRect: RectF,
isPercentage: Boolean,
isReverse: Boolean
) -> Float
class ContentPosition : Record {
@Field
val top: ContentPositionValue? = null
@Field
val bottom: ContentPositionValue? = null
@Field
val right: ContentPositionValue? = null
@Field
val left: ContentPositionValue? = null
private fun ContentPositionValue?.calcOffset(
isReverse: Boolean,
imageRect: RectF,
viewRect: RectF,
calcAxisOffset: CalcAxisOffset
): Float? {
if (this == null) {
return null
}
return if (this is Double) {
val value = this.toFloat()
calcAxisOffset(value, imageRect, viewRect, false, isReverse)
} else {
val value = this as String
if (value == "center") {
calcAxisOffset(50f, imageRect, viewRect, true, isReverse)
} else {
calcAxisOffset(value.removeSuffix("%").toFloat(), imageRect, viewRect, true, isReverse)
}
}
}
private fun offsetX(
imageRect: RectF,
viewRect: RectF
): Float {
return left.calcOffset(false, imageRect, viewRect, ::calcXTranslation)
?: right.calcOffset(true, imageRect, viewRect, ::calcXTranslation)
?: calcXTranslation(50f, imageRect, viewRect, isPercentage = true) // default value
}
private fun offsetY(
imageRect: RectF,
viewRect: RectF
): Float {
return top.calcOffset(false, imageRect, viewRect, ::calcYTranslation)
?: bottom.calcOffset(true, imageRect, viewRect, ::calcYTranslation)
?: calcYTranslation(50f, imageRect, viewRect, isPercentage = true) // default value
}
internal fun apply(to: Matrix, imageRect: RectF, viewRect: RectF) {
val xOffset = offsetX(imageRect, viewRect)
val yOffset = offsetY(imageRect, viewRect)
to.postTranslate(xOffset, yOffset)
}
companion object {
val center = ContentPosition()
}
}

View File

@@ -0,0 +1,22 @@
package expo.modules.image.records
import expo.modules.kotlin.types.Enumerable
enum class DecodeFormat(val value: String) : Enumerable {
ARGB_8888("argb"),
RGB_565("rgb");
fun toGlideFormat(): com.bumptech.glide.load.DecodeFormat {
return when (this) {
ARGB_8888 -> com.bumptech.glide.load.DecodeFormat.PREFER_ARGB_8888
RGB_565 -> com.bumptech.glide.load.DecodeFormat.PREFER_RGB_565
}
}
fun toBytes(): Int {
return when (this) {
ARGB_8888 -> 4
RGB_565 -> 2
}
}
}

View File

@@ -0,0 +1,15 @@
package expo.modules.image.records
import android.graphics.Color
import com.bumptech.glide.request.target.Target.SIZE_ORIGINAL
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
data class ImageLoadOptions(
@Field
val maxWidth: Int = SIZE_ORIGINAL,
@Field
val maxHeight: Int = SIZE_ORIGINAL,
@Field
val tintColor: Color? = null
) : Record

View File

@@ -0,0 +1,8 @@
package expo.modules.image.records
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
data class ImageTransition(
@Field val duration: Int = 0
) : Record

View File

@@ -0,0 +1,192 @@
package expo.modules.image.records
import android.content.Context
import android.graphics.drawable.Drawable
import android.net.Uri
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.load.model.Headers
import com.bumptech.glide.load.model.LazyHeaders
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.signature.ApplicationVersionSignature
import expo.modules.image.BlurhashModelProvider
import expo.modules.image.DecodedModelProvider
import expo.modules.image.GlideModelProvider
import expo.modules.image.RawModelProvider
import expo.modules.image.ThumbhashModelProvider
import expo.modules.image.UriModelProvider
import expo.modules.image.UrlModelProvider
import expo.modules.image.ResourceIdHelper
import expo.modules.image.customize
import expo.modules.image.okhttp.GlideUrlWithCustomCacheKey
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
sealed interface Source {
val width: Int
val height: Int
val scale: Double
val pixelCount: Double
get() = width * height * scale * scale
fun createGlideModelProvider(context: Context): GlideModelProvider?
fun createGlideOptions(context: Context): RequestOptions
/**
* Whether it should use placeholder content fit when used as a placeholder
*/
fun usesPlaceholderContentFit(): Boolean = true
}
class DecodedSource(
val drawable: Drawable
) : Source {
override fun createGlideModelProvider(context: Context): GlideModelProvider {
return DecodedModelProvider(drawable)
}
override val width: Int = drawable.intrinsicWidth
override val height: Int = drawable.intrinsicHeight
override val scale: Double = 1.0
override fun createGlideOptions(context: Context): RequestOptions {
// We don't want to cache already decoded images.
return RequestOptions()
.skipMemoryCache(true)
.diskCacheStrategy(DiskCacheStrategy.NONE)
}
}
data class SourceMap(
@Field val uri: String? = null,
@Field override val width: Int = 0,
@Field override val height: Int = 0,
@Field override val scale: Double = 1.0,
@Field val headers: Map<String, String>? = null,
@Field val cacheKey: String? = null
) : Source, Record {
private var parsedUri: Uri? = null
private fun isDataUrl() = parsedUri?.scheme?.startsWith("data") ?: false
private fun isContentUrl() = parsedUri?.scheme?.startsWith("content") ?: false
private fun isResourceUri() = parsedUri?.scheme?.startsWith("android.resource") ?: false
private fun isLocalResourceUri() = parsedUri?.scheme?.startsWith("res") ?: false
private fun isLocalFileUri() = parsedUri?.scheme?.startsWith("file") ?: false
private fun isBlurhash() = parsedUri?.scheme?.startsWith("blurhash") ?: false
private fun isThumbhash() = parsedUri?.scheme?.startsWith("thumbhash") ?: false
override fun usesPlaceholderContentFit(): Boolean {
return !isBlurhash() && !isThumbhash()
}
private fun parseUri(context: Context) {
if (parsedUri == null) {
parsedUri = computeUri(context)
}
}
override fun createGlideModelProvider(context: Context): GlideModelProvider? {
if (uri.isNullOrBlank()) {
return null
}
parseUri(context)
if (isContentUrl() || isDataUrl()) {
return RawModelProvider(uri)
}
if (isBlurhash()) {
return BlurhashModelProvider(
parsedUri!!,
width,
height
)
}
if (isThumbhash()) {
return ThumbhashModelProvider(
parsedUri!!
)
}
if (isResourceUri()) {
return UriModelProvider(parsedUri!!)
}
if (isLocalResourceUri()) {
return UriModelProvider(
// Convert `res:/` scheme to `android.resource://`.
// Otherwise, glide can't understand the Uri.
Uri.parse(parsedUri!!.toString().replace("res:/", "android.resource://" + context.packageName + "/"))
)
}
if (isLocalFileUri()) {
return RawModelProvider(parsedUri!!.toString())
}
val glideUrl = if (cacheKey == null) {
GlideUrl(uri, getCustomHeaders())
} else {
GlideUrlWithCustomCacheKey(uri, getCustomHeaders(), cacheKey)
}
return UrlModelProvider(glideUrl)
}
override fun createGlideOptions(context: Context): RequestOptions {
parseUri(context)
return RequestOptions().customize(`when` = width != 0 && height != 0) {
// Override the size for local assets (apart from SVGs). This ensures that
// resizeMode "center" displays the image in the correct size.
override((width * scale).toInt(), (height * scale).toInt())
}.customize(`when` = isResourceUri()) {
// Every local resource (drawable) in Android has its own unique numeric id, which are
// generated at build time. Although these ids are unique, they are not guaranteed unique
// across builds. The underlying glide implementation caches these resources. To make
// sure the cache does not return the wrong image, we should clear the cache when the
// application version changes.
apply(RequestOptions.signatureOf(ApplicationVersionSignature.obtain(context)))
}
}
private fun getCustomHeaders(): Headers {
if (headers == null) {
return LazyHeaders.DEFAULT
}
return LazyHeaders
.Builder()
.apply {
headers.forEach { (key, value) ->
addHeader(key, value)
}
}
.build()
}
private fun computeUri(context: Context): Uri? {
val stringUri = uri ?: return null
return try {
val uri: Uri = Uri.parse(stringUri)
// Verify scheme is set, so that relative uri (used by static resources) are not handled.
if (uri.scheme == null) {
computeLocalUri(stringUri, context)
} else {
uri
}
} catch (e: Exception) {
computeLocalUri(stringUri, context)
}
}
private fun computeLocalUri(stringUri: String, context: Context): Uri? {
return ResourceIdHelper.getResourceUri(context, stringUri)
}
}

View File

@@ -0,0 +1,26 @@
package expo.modules.image.records
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
data class ImageSource(
@Field val url: String,
@Field val width: Int,
@Field val height: Int,
@Field val mediaType: String?,
@Field val isAnimated: Boolean
) : Record
data class ImageLoadEvent(
@Field val cacheType: String,
@Field val source: ImageSource
) : Record
data class ImageProgressEvent(
@Field val loaded: Int,
@Field val total: Int
) : Record
data class ImageErrorEvent(
@Field val error: String
) : Record

View File

@@ -0,0 +1,42 @@
package expo.modules.image.svg
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.ResourceDecoder
import com.bumptech.glide.load.engine.Resource
import com.bumptech.glide.load.resource.SimpleResource
import com.caverock.androidsvg.SVG
import com.caverock.androidsvg.SVGParseException
import java.io.IOException
import java.io.InputStream
/**
* Decodes an SVG internal representation from an [InputStream].
*
* Copied from https://github.com/bumptech/glide/blob/10acc31a16b4c1b5684f69e8de3117371dfa77a8/samples/svg/src/main/java/com/bumptech/glide/samples/svg/SvgDecoder.java
* and rewritten to Kotlin.
*/
class SVGDecoder : ResourceDecoder<InputStream, SVG> {
// TODO: Can we tell?
override fun handles(source: InputStream, options: Options) = true
@Throws(IOException::class)
override fun decode(source: InputStream, width: Int, height: Int, options: Options): Resource<SVG>? {
return try {
val svg: SVG = SVG.getFromInputStream(source)
// Use document width and height if view box is not set.
// Later, we will override the document width and height with the dimensions of the native view.
if (svg.documentViewBox == null) {
val documentWidth = svg.documentWidth
val documentHeight = svg.documentHeight
if (documentWidth != -1f && documentHeight != -1f) {
svg.setDocumentViewBox(0f, 0f, documentWidth, documentHeight)
}
}
svg.documentWidth = width.toFloat()
svg.documentHeight = height.toFloat()
SimpleResource(svg)
} catch (ex: SVGParseException) {
throw IOException("Cannot load SVG from stream", ex)
}
}
}

View File

@@ -0,0 +1,49 @@
package expo.modules.image.svg
import android.content.Context
import android.graphics.Picture
import android.graphics.drawable.Drawable
import android.graphics.drawable.PictureDrawable
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.engine.Resource
import com.bumptech.glide.load.resource.SimpleResource
import com.bumptech.glide.load.resource.transcode.ResourceTranscoder
import com.caverock.androidsvg.SVG
import com.caverock.androidsvg.applyTintColor
import expo.modules.image.CustomOptions
/**
* We have to use the intrinsicWidth/Height from the Picture to render the image at a high enough resolution, but at the same time we want to return the actual
* preferred width and height of the SVG to JS. This class allows us to do that.
*/
class SVGPictureDrawable(picture: Picture, val svgIntrinsicWidth: Int, val svgIntrinsicHeight: Int) : PictureDrawable(picture)
/**
* Convert the [SVG]'s internal representation to an Android-compatible one ([Picture]).
*
* Copied from https://github.com/bumptech/glide/blob/10acc31a16b4c1b5684f69e8de3117371dfa77a8/samples/svg/src/main/java/com/bumptech/glide/samples/svg/SvgDrawableTranscoder.java
* and rewritten to Kotlin.
*/
class SVGDrawableTranscoder(val context: Context) : ResourceTranscoder<SVG?, Drawable> {
override fun transcode(toTranscode: Resource<SVG?>, options: Options): Resource<Drawable> {
val svgData = toTranscode.get()
// If the svg doesn't have a viewBox, we can't determine its intrinsic width and height, so we default to 512x512.
// Same dimensions are used in the AndroidSVG library when the viewBox is not set.
val intrinsicWidth = svgData.documentViewBox?.width()?.toInt() ?: 512
val intrinsicHeight = svgData.documentViewBox?.height()?.toInt() ?: 512
val tintColor = options.get(CustomOptions.tintColor)
if (tintColor != null) {
applyTintColor(svgData, tintColor)
}
val picture = SVGPictureDrawable(
svgData.renderToPicture(),
intrinsicWidth,
intrinsicHeight
)
return SimpleResource(
picture
)
}
}

View File

@@ -0,0 +1,26 @@
package expo.modules.image.svg
import android.content.Context
import android.graphics.drawable.Drawable
import com.bumptech.glide.Glide
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.module.LibraryGlideModule
import com.caverock.androidsvg.SVG
import java.io.InputStream
/**
* [LibraryGlideModule] registering support for SVG to Glide.
*
* Copied from https://github.com/bumptech/glide/blob/10acc31a16b4c1b5684f69e8de3117371dfa77a8/samples/svg/src/main/java/com/bumptech/glide/samples/svg/SvgModule.java
* and rewritten to Kotlin.
*/
@GlideModule
class SVGModule : LibraryGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
super.registerComponents(context, glide, registry)
registry
.append(InputStream::class.java, SVG::class.java, SVGDecoder())
.register(SVG::class.java, Drawable::class.java, SVGDrawableTranscoder(context))
}
}

View File

@@ -0,0 +1,222 @@
package expo.modules.image.thumbhash
import android.graphics.Bitmap
import android.graphics.Color
// ThumbHash Java implementation (converted to kotlin) thanks to @evanw https://github.com/evanw/thumbhash
object ThumbhashDecoder {
/**
* Decodes a ThumbHash to an RGBA image. RGB is not be premultiplied by A.
*
* @param hash The bytes of the ThumbHash.
* @return The width, height, and pixels of the rendered placeholder image.
*/
fun thumbHashToRGBA(hash: ByteArray): Image {
// Read the constants
val header24 = hash[0].toInt() and 255 or (hash[1].toInt() and 255 shl 8) or (hash[2].toInt() and 255 shl 16)
val header16 = hash[3].toInt() and 255 or (hash[4].toInt() and 255 shl 8)
val l_dc = (header24 and 63).toFloat() / 63.0f
val p_dc = (header24 shr 6 and 63).toFloat() / 31.5f - 1.0f
val q_dc = (header24 shr 12 and 63).toFloat() / 31.5f - 1.0f
val l_scale = (header24 shr 18 and 31).toFloat() / 31.0f
val hasAlpha = header24 shr 23 != 0
val p_scale = (header16 shr 3 and 63).toFloat() / 63.0f
val q_scale = (header16 shr 9 and 63).toFloat() / 63.0f
val isLandscape = header16 shr 15 != 0
val lx = Math.max(3, if (isLandscape) if (hasAlpha) 5 else 7 else header16 and 7)
val ly = Math.max(3, if (isLandscape) header16 and 7 else if (hasAlpha) 5 else 7)
val a_dc = if (hasAlpha) (hash[5].toInt() and 15).toFloat() / 15.0f else 1.0f
val a_scale = (hash[5].toInt() shr 4 and 15).toFloat() / 15.0f
// Read the varying factors (boost saturation by 1.25x to compensate for quantization)
val ac_start = if (hasAlpha) 6 else 5
var ac_index = 0
val l_channel = Channel(lx, ly)
val p_channel = Channel(3, 3)
val q_channel = Channel(3, 3)
var a_channel: Channel? = null
ac_index = l_channel.decode(hash, ac_start, ac_index, l_scale)
ac_index = p_channel.decode(hash, ac_start, ac_index, p_scale * 1.25f)
ac_index = q_channel.decode(hash, ac_start, ac_index, q_scale * 1.25f)
if (hasAlpha) {
a_channel = Channel(5, 5)
a_channel.decode(hash, ac_start, ac_index, a_scale)
}
val l_ac = l_channel.ac
val p_ac = p_channel.ac
val q_ac = q_channel.ac
val a_ac = if (hasAlpha) a_channel!!.ac else null
// Decode using the DCT into RGB
val ratio = thumbHashToApproximateAspectRatio(hash)
val w = Math.round(if (ratio > 1.0f) 32.0f else 32.0f * ratio)
val h = Math.round(if (ratio > 1.0f) 32.0f / ratio else 32.0f)
val rgba = ByteArray(w * h * 4)
val cx_stop = Math.max(lx, if (hasAlpha) 5 else 3)
val cy_stop = Math.max(ly, if (hasAlpha) 5 else 3)
val fx = FloatArray(cx_stop)
val fy = FloatArray(cy_stop)
var y = 0
var i = 0
while (y < h) {
var x = 0
while (x < w) {
var l = l_dc
var p = p_dc
var q = q_dc
var a = a_dc
// Precompute the coefficients
for (cx in 0 until cx_stop) fx[cx] = Math.cos(Math.PI / w * (x + 0.5f) * cx).toFloat()
for (cy in 0 until cy_stop) fy[cy] = Math.cos(Math.PI / h * (y + 0.5f) * cy).toFloat()
// Decode L
run {
var cy = 0
var j = 0
while (cy < ly) {
val fy2 = fy[cy] * 2.0f
var cx = if (cy > 0) 0 else 1
while (cx * ly < lx * (ly - cy)) {
l += l_ac[j] * fx[cx] * fy2
cx++
j++
}
cy++
}
}
// Decode P and Q
var cy = 0
var j = 0
while (cy < 3) {
val fy2 = fy[cy] * 2.0f
var cx = if (cy > 0) 0 else 1
while (cx < 3 - cy) {
val f = fx[cx] * fy2
p += p_ac[j] * f
q += q_ac[j] * f
cx++
j++
}
cy++
}
// Decode A
if (hasAlpha) {
var cyAlpha = 0
var k = 0
while (cyAlpha < 5) {
val fy2 = fy[cyAlpha] * 2.0f
var cx = if (cyAlpha > 0) 0 else 1
while (cx < 5 - cyAlpha) {
a += a_ac!![k] * fx[cx] * fy2
cx++
k++
}
cyAlpha++
}
}
// Convert to RGB
val b = l - 2.0f / 3.0f * p
val r = (3.0f * l - b + q) / 2.0f
val g = r - q
rgba[i] = Math.max(0, Math.round(255.0f * Math.min(1f, r))).toByte()
rgba[i + 1] = Math.max(0, Math.round(255.0f * Math.min(1f, g))).toByte()
rgba[i + 2] = Math.max(0, Math.round(255.0f * Math.min(1f, b))).toByte()
rgba[i + 3] = Math.max(0, Math.round(255.0f * Math.min(1f, a))).toByte()
x++
i += 4
}
y++
}
return Image(w, h, rgba)
}
/**
* Converts a ThumbHash into a Bitmap image
*/
fun thumbHashToBitmap(hash: ByteArray): Bitmap {
val thumbhashImage = thumbHashToRGBA(hash)
// TODO: @behenate it should be possible to replace all of the code below with
// with BitmapFactory.decodeByteArray but it always returns null when using thumbhashImage.rgba
val imageArray = IntArray(thumbhashImage.width * thumbhashImage.height)
val thumbhashImageInt = thumbhashImage.rgba.map { it.toUByte().toInt() }
for (i in thumbhashImageInt.indices step 4) {
imageArray[i / 4] = Color.argb(
thumbhashImageInt[i + 3],
thumbhashImageInt[i],
thumbhashImageInt[i + 1],
thumbhashImageInt[i + 2]
)
}
return Bitmap.createBitmap(imageArray, thumbhashImage.width, thumbhashImage.height, Bitmap.Config.ARGB_8888)
}
/**
* Extracts the average color from a ThumbHash. RGB is not be premultiplied by A.
*
* @param hash The bytes of the ThumbHash.
* @return The RGBA values for the average color. Each value ranges from 0 to 1.
*/
fun thumbHashToAverageRGBA(hash: ByteArray): RGBA {
val header = hash[0].toInt() and 255 or (hash[1].toInt() and 255 shl 8) or (hash[2].toInt() and 255 shl 16)
val l = (header and 63).toFloat() / 63.0f
val p = (header shr 6 and 63).toFloat() / 31.5f - 1.0f
val q = (header shr 12 and 63).toFloat() / 31.5f - 1.0f
val hasAlpha = header shr 23 != 0
val a = if (hasAlpha) (hash[5].toInt() and 15).toFloat() / 15.0f else 1.0f
val b = l - 2.0f / 3.0f * p
val r = (3.0f * l - b + q) / 2.0f
val g = r - q
return RGBA(
Math.max(0f, Math.min(1f, r)),
Math.max(0f, Math.min(1f, g)),
Math.max(0f, Math.min(1f, b)),
a
)
}
/**
* Extracts the approximate aspect ratio of the original image.
*
* @param hash The bytes of the ThumbHash.
* @return The approximate aspect ratio (i.e. width / height).
*/
fun thumbHashToApproximateAspectRatio(hash: ByteArray): Float {
val header = hash[3]
val hasAlpha = hash[2].toInt() and 0x80 != 0
val isLandscape = hash[4].toInt() and 0x80 != 0
val lx = if (isLandscape) if (hasAlpha) 5 else 7 else header.toInt() and 7
val ly = if (isLandscape) header.toInt() and 7 else if (hasAlpha) 5 else 7
return lx.toFloat() / ly.toFloat()
}
class Image(var width: Int, var height: Int, var rgba: ByteArray)
class RGBA(var r: Float, var g: Float, var b: Float, var a: Float)
private class Channel(nx: Int, ny: Int) {
var ac: FloatArray
init {
var n = 0
for (cy in 0 until ny) {
var cx = if (cy > 0) 0 else 1
while (cx * ny < nx * (ny - cy)) {
n++
cx++
}
}
ac = FloatArray(n)
}
fun decode(hash: ByteArray, start: Int, index: Int, scale: Float): Int {
var currentIndex = index
for (i in ac.indices) {
val data = hash[start + (currentIndex shr 1)].toInt() shr (currentIndex and 1 shl 2)
ac[i] = ((data and 15).toFloat() / 7.5f - 1.0f) * scale
currentIndex++
}
return currentIndex
}
}
}

View File

@@ -0,0 +1,184 @@
package expo.modules.image.thumbhash
import android.graphics.Bitmap
import android.graphics.Color
// ThumbHash Java implementation (converted to kotlin) thanks to @evanw https://github.com/evanw/thumbhash
object ThumbhashEncoder {
/**
* Encodes an RGBA image to a ThumbHash. RGB should not be premultiplied by A.
* @param bitmap The bitmap to generate the ThumbHash from.
* @return The ThumbHash as a byte array.
*/
fun encode(bitmap: Bitmap): ByteArray {
// Encoding an image larger than 100x100 is slow with no benefit
val resizedBitmap = resizeKeepingAspectRatio(bitmap, 100)
val w = resizedBitmap.width
val h = resizedBitmap.height
val pixels = IntArray(w * h)
resizedBitmap.getPixels(pixels, 0, w, 0, 0, w, h)
var avg_r = 0f
var avg_g = 0f
var avg_b = 0f
var avg_a = 0f
var i = 0
while (i < w * h) {
val alpha = Color.alpha(pixels[i]) / 255.0f
avg_r += alpha / 255.0f * Color.red(pixels[i])
avg_g += alpha / 255.0f * Color.green(pixels[i])
avg_b += alpha / 255.0f * Color.blue(pixels[i])
avg_a += alpha
i++
}
if (avg_a > 0) {
avg_r /= avg_a
avg_g /= avg_a
avg_b /= avg_a
}
val hasAlpha = avg_a < w * h
val l_limit = if (hasAlpha) 5 else 7 // Use fewer luminance bits if there's alpha
val lx = Math.max(1, Math.round((l_limit * w).toFloat() / Math.max(w, h).toFloat()))
val ly = Math.max(1, Math.round((l_limit * h).toFloat() / Math.max(w, h).toFloat()))
val l = FloatArray(w * h) // luminance
val p = FloatArray(w * h) // yellow - blue
val q = FloatArray(w * h) // red - green
val a = FloatArray(w * h) // alpha
// Convert the image from RGBA to LPQA (composite atop the average color)
i = 0
while (i < w * h) {
val alpha = (Color.alpha(pixels[i]) and 255) / 255.0f
val r = avg_r * (1.0f - alpha) + alpha / 255.0f * Color.red(pixels[i])
val g = avg_g * (1.0f - alpha) + alpha / 255.0f * Color.green(pixels[i])
val b = avg_b * (1.0f - alpha) + alpha / 255.0f * Color.blue(pixels[i])
l[i] = (r + g + b) / 3.0f
p[i] = (r + g) / 2.0f - b
q[i] = r - g
a[i] = alpha
i++
}
// Encode using the DCT into DC (constant) and normalized AC (varying) terms
val l_channel = Channel(Math.max(3, lx), Math.max(3, ly)).encode(w, h, l)
val p_channel = Channel(3, 3).encode(w, h, p)
val q_channel = Channel(3, 3).encode(w, h, q)
val a_channel = if (hasAlpha) Channel(5, 5).encode(w, h, a) else null
// Write the constants
val isLandscape = w > h
val header24 = (
Math.round(63.0f * l_channel.dc)
or (Math.round(31.5f + 31.5f * p_channel.dc) shl 6)
or (Math.round(31.5f + 31.5f * q_channel.dc) shl 12)
or (Math.round(31.0f * l_channel.scale) shl 18)
or if (hasAlpha) 1 shl 23 else 0
)
val header16 = (
(if (isLandscape) ly else lx)
or (Math.round(63.0f * p_channel.scale) shl 3)
or (Math.round(63.0f * q_channel.scale) shl 9)
or if (isLandscape) 1 shl 15 else 0
)
val ac_start = if (hasAlpha) 6 else 5
val ac_count = (
l_channel.ac.size + p_channel.ac.size + q_channel.ac.size +
if (hasAlpha) a_channel!!.ac.size else 0
)
val hash = ByteArray(ac_start + (ac_count + 1) / 2)
hash[0] = header24.toByte()
hash[1] = (header24 shr 8).toByte()
hash[2] = (header24 shr 16).toByte()
hash[3] = header16.toByte()
hash[4] = (header16 shr 8).toByte()
if (hasAlpha) {
hash[5] = (
Math.round(15.0f * a_channel!!.dc)
or (Math.round(15.0f * a_channel.scale) shl 4)
).toByte()
}
// Write the varying factors
var ac_index = 0
ac_index = l_channel.writeTo(hash, ac_start, ac_index)
ac_index = p_channel.writeTo(hash, ac_start, ac_index)
ac_index = q_channel.writeTo(hash, ac_start, ac_index)
if (hasAlpha) a_channel!!.writeTo(hash, ac_start, ac_index)
return hash
}
private fun resizeKeepingAspectRatio(bitmap: Bitmap, maxSize: Int): Bitmap {
val width = bitmap.width
val height = bitmap.height
val ratio = width.toFloat() / height.toFloat()
val newWidth: Int
val newHeight: Int
if (ratio > 1) {
newWidth = maxSize
newHeight = (maxSize / ratio).toInt()
} else {
newHeight = maxSize
newWidth = (maxSize * ratio).toInt()
}
return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
}
private class Channel(var nx: Int, var ny: Int) {
var dc = 0f
var ac: FloatArray
var scale = 0f
init {
var n = 0
for (cy in 0 until ny) {
var cx = if (cy > 0) 0 else 1
while (cx * ny < nx * (ny - cy)) {
n++
cx++
}
}
ac = FloatArray(n)
}
fun encode(w: Int, h: Int, channel: FloatArray): Channel {
var n = 0
val fx = FloatArray(w)
for (cy in 0 until ny) {
var cx = 0
while (cx * ny < nx * (ny - cy)) {
var f = 0f
for (x in 0 until w) fx[x] = Math.cos(Math.PI / w * cx * (x + 0.5f)).toFloat()
for (y in 0 until h) {
val fy = Math.cos(Math.PI / h * cy * (y + 0.5f)).toFloat()
for (x in 0 until w) f += channel[x + y * w] * fx[x] * fy
}
f /= (w * h).toFloat()
if (cx > 0 || cy > 0) {
ac[n++] = f
scale = Math.max(scale, Math.abs(f))
} else {
dc = f
}
cx++
}
}
if (scale > 0) for (i in ac.indices) ac[i] = 0.5f + 0.5f / scale * ac[i]
return this
}
fun writeTo(hash: ByteArray, start: Int, index: Int): Int {
var currentIndex = index
for (v in ac) {
hash[start + (currentIndex shr 1)] = (hash[start + (currentIndex shr 1)].toInt() or (Math.round(15.0f * v) shl (currentIndex and 1 shl 2))).toByte()
currentIndex++
}
return currentIndex
}
}
}

View File

@@ -0,0 +1,31 @@
package expo.modules.image.thumbhash
import android.graphics.Bitmap
import android.util.Base64
import com.bumptech.glide.Priority
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.data.DataFetcher
import expo.modules.kotlin.exception.CodedException
class ThumbhashDecodingFailure(thumbhash: String?, cause: Exception?) : CodedException(
message = "Cannot decode provided thumbhash '$thumbhash' $cause"
)
class ThumbhashFetcher(
private val thumbhash: String?
) : DataFetcher<Bitmap> {
override fun cleanup() = Unit
override fun cancel() = Unit
override fun getDataClass(): Class<Bitmap> = Bitmap::class.java
override fun getDataSource(): DataSource = DataSource.LOCAL
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in Bitmap>) {
try {
val decodedThumbhash = Base64.decode(thumbhash, Base64.DEFAULT)
val bitmap = ThumbhashDecoder.thumbHashToBitmap(decodedThumbhash)
callback.onDataReady(bitmap)
} catch (e: Exception) {
callback.onLoadFailed(ThumbhashDecodingFailure(thumbhash, e))
}
}
}

View File

@@ -0,0 +1,7 @@
package expo.modules.image.thumbhash
import android.net.Uri
data class ThumbhashModel(
val uri: Uri
)

View File

@@ -0,0 +1,24 @@
package expo.modules.image.thumbhash
import android.graphics.Bitmap
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.signature.ObjectKey
class ThumbhashModelLoader : ModelLoader<ThumbhashModel, Bitmap> {
override fun handles(model: ThumbhashModel): Boolean = true
override fun buildLoadData(model: ThumbhashModel, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> {
// The URI looks like this: thumbhash:/3OcRJYB4d3h\iIeHeEh3eIhw+j2w
// ThumbHash may include slashes which could break the structure of the URL, so we replace them
// with backslashes on the JS side and revert them back to slashes here, before generating the image.
val thumbhash = model.uri.pathSegments.joinToString(separator = "/").replace("\\", "/")
return ModelLoader.LoadData(
ObjectKey(model),
ThumbhashFetcher(thumbhash)
)
}
}

View File

@@ -0,0 +1,13 @@
package expo.modules.image.thumbhash
import android.graphics.Bitmap
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
class ThumbhashModelLoaderFactory : ModelLoaderFactory<ThumbhashModel, Bitmap> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<ThumbhashModel, Bitmap> =
ThumbhashModelLoader()
override fun teardown() = Unit
}

View File

@@ -0,0 +1,16 @@
package expo.modules.image.thumbhash
import android.content.Context
import android.graphics.Bitmap
import com.bumptech.glide.Glide
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.module.LibraryGlideModule
@GlideModule
class ThumbhashModule : LibraryGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
super.registerComponents(context, glide, registry)
registry.prepend(ThumbhashModel::class.java, Bitmap::class.java, ThumbhashModelLoaderFactory())
}
}