react-native-nitro-image-pipeline 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +124 -8
  2. package/android/CMakeLists.txt +4 -0
  3. package/android/src/main/cpp/GaussianBlur.cpp +165 -0
  4. package/android/src/main/cpp/GaussianBlur.hpp +52 -0
  5. package/android/src/main/cpp/GaussianBlurJni.cpp +50 -0
  6. package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/HybridNitroImagePipeline.kt +167 -75
  7. package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/PipelineImageLoader.kt +177 -0
  8. package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/transform/BlurTransformation.kt +36 -81
  9. package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/transform/HardwareBitmapTransformation.kt +45 -0
  10. package/ios/GaussianBlur.swift +48 -9
  11. package/ios/HybridNitroImagePipeline.swift +109 -41
  12. package/ios/PipelineImageLoader.swift +195 -0
  13. package/ios/RoundedCornersProcessor.swift +5 -0
  14. package/lib/commonjs/NativePipelineImage.js +72 -0
  15. package/lib/commonjs/NativePipelineImage.js.map +1 -0
  16. package/lib/commonjs/PipelineImage.js.map +1 -1
  17. package/lib/commonjs/index.js +27 -0
  18. package/lib/commonjs/index.js.map +1 -1
  19. package/lib/commonjs/resolveImageSource.js +89 -0
  20. package/lib/commonjs/resolveImageSource.js.map +1 -0
  21. package/lib/commonjs/useImage.js +8 -4
  22. package/lib/commonjs/useImage.js.map +1 -1
  23. package/lib/commonjs/usePipelineImageLoader.js +74 -0
  24. package/lib/commonjs/usePipelineImageLoader.js.map +1 -0
  25. package/lib/module/NativePipelineImage.js +68 -0
  26. package/lib/module/NativePipelineImage.js.map +1 -0
  27. package/lib/module/PipelineImage.js.map +1 -1
  28. package/lib/module/index.js +3 -0
  29. package/lib/module/index.js.map +1 -1
  30. package/lib/module/resolveImageSource.js +83 -0
  31. package/lib/module/resolveImageSource.js.map +1 -0
  32. package/lib/module/useImage.js +8 -4
  33. package/lib/module/useImage.js.map +1 -1
  34. package/lib/module/usePipelineImageLoader.js +70 -0
  35. package/lib/module/usePipelineImageLoader.js.map +1 -0
  36. package/lib/typescript/src/NativePipelineImage.d.ts +71 -0
  37. package/lib/typescript/src/NativePipelineImage.d.ts.map +1 -0
  38. package/lib/typescript/src/PipelineImage.d.ts +7 -2
  39. package/lib/typescript/src/PipelineImage.d.ts.map +1 -1
  40. package/lib/typescript/src/index.d.ts +4 -1
  41. package/lib/typescript/src/index.d.ts.map +1 -1
  42. package/lib/typescript/src/resolveImageSource.d.ts +56 -0
  43. package/lib/typescript/src/resolveImageSource.d.ts.map +1 -0
  44. package/lib/typescript/src/specs/nitro-image-toolkit.nitro.d.ts +56 -1
  45. package/lib/typescript/src/specs/nitro-image-toolkit.nitro.d.ts.map +1 -1
  46. package/lib/typescript/src/useImage.d.ts +10 -4
  47. package/lib/typescript/src/useImage.d.ts.map +1 -1
  48. package/lib/typescript/src/usePipelineImageLoader.d.ts +17 -0
  49. package/lib/typescript/src/usePipelineImageLoader.d.ts.map +1 -0
  50. package/nitrogen/generated/android/c++/JHybridNitroImagePipelineSpec.cpp +13 -0
  51. package/nitrogen/generated/android/c++/JHybridNitroImagePipelineSpec.hpp +1 -0
  52. package/nitrogen/generated/android/c++/JViewOptions.hpp +77 -0
  53. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/HybridNitroImagePipelineSpec.kt +5 -0
  54. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/ViewOptions.kt +66 -0
  55. package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Bridge.cpp +10 -0
  56. package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Bridge.hpp +44 -0
  57. package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Umbrella.hpp +8 -0
  58. package/nitrogen/generated/ios/c++/HybridNitroImagePipelineSpecSwift.hpp +14 -0
  59. package/nitrogen/generated/ios/swift/HybridNitroImagePipelineSpec.swift +1 -0
  60. package/nitrogen/generated/ios/swift/HybridNitroImagePipelineSpec_cxx.swift +15 -0
  61. package/nitrogen/generated/ios/swift/ViewOptions.swift +101 -0
  62. package/nitrogen/generated/shared/c++/HybridNitroImagePipelineSpec.cpp +1 -0
  63. package/nitrogen/generated/shared/c++/HybridNitroImagePipelineSpec.hpp +7 -0
  64. package/nitrogen/generated/shared/c++/ViewOptions.hpp +104 -0
  65. package/package.json +4 -3
  66. package/src/NativePipelineImage.tsx +108 -0
  67. package/src/PipelineImage.tsx +7 -2
  68. package/src/index.ts +12 -0
  69. package/src/resolveImageSource.ts +83 -0
  70. package/src/specs/nitro-image-toolkit.nitro.ts +57 -1
  71. package/src/useImage.ts +26 -15
  72. package/src/usePipelineImageLoader.ts +90 -0
@@ -1,7 +1,9 @@
1
1
  package com.margelo.nitro.nitroimagepipeline
2
2
 
3
3
  import android.content.Context
4
+ import android.graphics.Bitmap
4
5
  import androidx.core.graphics.drawable.toBitmap
6
+ import androidx.core.net.toUri
5
7
  import coil3.BitmapImage
6
8
  import coil3.ColorImage
7
9
  import coil3.DrawableImage
@@ -26,12 +28,15 @@ import com.margelo.nitro.core.Promise
26
28
  import com.margelo.nitro.image.HybridImage
27
29
  import com.margelo.nitro.image.HybridImageSpec
28
30
  import com.margelo.nitro.nitroimagepipeline.transform.BlurTransformation
31
+ import com.margelo.nitro.nitroimagepipeline.transform.HardwareBitmapTransformation
29
32
  import com.margelo.nitro.nitroimagepipeline.transform.ResizeTransformation
30
33
  import kotlin.math.roundToInt
34
+ import kotlinx.coroutines.CoroutineScope
31
35
  import kotlinx.coroutines.Dispatchers
32
36
  import kotlinx.coroutines.async
33
37
  import kotlinx.coroutines.awaitAll
34
38
  import kotlinx.coroutines.coroutineScope
39
+ import kotlinx.coroutines.launch
35
40
  import kotlinx.coroutines.withContext
36
41
  import okhttp3.OkHttpClient
37
42
  import okio.Path.Companion.toOkioPath
@@ -44,82 +49,27 @@ class HybridNitroImagePipeline : HybridNitroImagePipelineSpec() {
44
49
  private val imageLoader: ImageLoader
45
50
  get() = getOrCreateImageLoader(context)
46
51
 
47
- override fun loadImage(url: String, options: Options?): Promise<HybridImageSpec> = Promise.async {
48
- val blur = options?.blur?.toFloat() ?: 0f
49
- val resize =
50
- options?.resize?.let { r ->
51
- val width = r.width.roundToInt()
52
- val height = r.height.roundToInt()
53
- if (width > 0 && height > 0) width to height else null
54
- }
55
- val transformations = buildList {
56
- // Resize first: blur sigma and corner radii are in pixels of the bitmap
57
- // they run on, so they must see the final size.
58
- resize?.let { (width, height) -> add(ResizeTransformation(width, height)) }
59
- if (blur > 0f) add(BlurTransformation(context, blur))
60
- options
61
- ?.cornerRadius
62
- ?.match(
63
- first = { radius ->
64
- if (radius > 0.0) add(RoundedCornersTransformation(radius.toFloat()))
65
- },
66
- second = { radii ->
67
- // RoundedCornersTransformation rejects negative radii; treat them as square.
68
- val topLeft = (radii.topLeft?.toFloat() ?: 0f).coerceAtLeast(0f)
69
- val topRight = (radii.topRight?.toFloat() ?: 0f).coerceAtLeast(0f)
70
- val bottomLeft = (radii.bottomLeft?.toFloat() ?: 0f).coerceAtLeast(0f)
71
- val bottomRight = (radii.bottomRight?.toFloat() ?: 0f).coerceAtLeast(0f)
72
- if (topLeft > 0f || topRight > 0f || bottomLeft > 0f || bottomRight > 0f) {
73
- add(RoundedCornersTransformation(topLeft, topRight, bottomLeft, bottomRight))
74
- }
75
- },
76
- )
77
- }
78
- val request =
79
- ImageRequest.Builder(context)
80
- .data(url)
81
- .apply {
82
- when (options?.cache) {
83
- CacheOption.MEMORY -> {
84
- memoryCachePolicy(CachePolicy.ENABLED)
85
- diskCachePolicy(CachePolicy.DISABLED)
86
- }
87
- CacheOption.DISK -> {
88
- memoryCachePolicy(CachePolicy.DISABLED)
89
- diskCachePolicy(CachePolicy.ENABLED)
90
- }
91
- CacheOption.NONE -> {
92
- memoryCachePolicy(CachePolicy.DISABLED)
93
- diskCachePolicy(CachePolicy.DISABLED)
94
- }
95
- null -> Unit // Coil defaults: both enabled
96
- }
97
- // Ask the decoder for the target size so a large source is
98
- // subsampled near it instead of decoded at full resolution;
99
- // ResizeTransformation then makes the size exact.
100
- resize?.let { (width, height) ->
101
- size(width, height)
102
- scale(Scale.FILL)
103
- }
104
- }
105
- .allowHardware(true)
106
- .transformations(transformations)
107
- .build()
52
+ init {
53
+ // Build the shared loader now, on a background thread, instead of on
54
+ // whichever thread the first request happens to run on. Creating it
55
+ // builds a CronetEngine, an OkHttpClient and the disk cache — well over
56
+ // a frame's worth of work — and the first `PipelineImageLoader` request
57
+ // would otherwise do that on the main thread.
58
+ (NitroModules.applicationContext as? Context)?.let { warmUpImageLoader(it) }
59
+ }
108
60
 
109
- when (val result = imageLoader.execute(request)) {
61
+ override fun loadImage(url: String, options: Options?): Promise<HybridImageSpec> = Promise.async {
62
+ when (val result = imageLoader.execute(buildRequest(context, url, options))) {
110
63
  is ErrorResult -> throw result.throwable
111
- is SuccessResult -> {
112
- val bitmap =
113
- when (val img = result.image) {
114
- is BitmapImage -> img.bitmap
115
- is DrawableImage -> img.drawable.toBitmap()
116
- else -> throw Error("Unsupported image type: ${img.javaClass.simpleName}")
117
- }
118
- HybridImage(bitmap)
119
- }
64
+ is SuccessResult -> HybridImage(bitmapOf(result))
120
65
  }
121
66
  }
122
67
 
68
+ override fun createImageLoader(
69
+ url: String,
70
+ options: ViewOptions?,
71
+ ): com.margelo.nitro.image.HybridImageLoaderSpec = PipelineImageLoader(context, url, options)
72
+
123
73
  // Preloading warms the disk cache only: the memory cache is skipped and the
124
74
  // decode step is replaced with a no-op (Coil's documented preload pattern),
125
75
  // so prefetching N URLs costs network + disk I/O instead of N full-resolution
@@ -127,7 +77,7 @@ class HybridNitroImagePipeline : HybridNitroImagePipelineSpec() {
127
77
  // size — only when a loadImage actually displays it.
128
78
  private fun preloadRequest(url: String): ImageRequest =
129
79
  ImageRequest.Builder(context)
130
- .data(url)
80
+ .data(requestData(context, url))
131
81
  .memoryCachePolicy(CachePolicy.DISABLED)
132
82
  .decoderFactory { _, _, _ -> Decoder { DecodeResult(ColorImage(), false) } }
133
83
  .build()
@@ -151,8 +101,7 @@ class HybridNitroImagePipeline : HybridNitroImagePipelineSpec() {
151
101
  if (sigma <= 0f) {
152
102
  hybridImage
153
103
  } else {
154
- val blurred =
155
- BlurTransformation(context, sigma).transform(hybridImage.bitmap, Size.ORIGINAL)
104
+ val blurred = BlurTransformation(sigma).transform(hybridImage.bitmap, Size.ORIGINAL)
156
105
  HybridImage(blurred)
157
106
  }
158
107
  }
@@ -182,12 +131,155 @@ class HybridNitroImagePipeline : HybridNitroImagePipelineSpec() {
182
131
  // must be shared across all HybridNitroImagePipeline instances.
183
132
  @Volatile private var sharedImageLoader: ImageLoader? = null
184
133
 
185
- private fun getOrCreateImageLoader(context: Context): ImageLoader =
134
+ /**
135
+ * The [ImageRequest] for [url] with [options] applied — shared by [loadImage] and
136
+ * [PipelineImageLoader] so both hit the same caches with identical cache keys.
137
+ *
138
+ * With [hardwareResult], a transformed result is converted to a hardware bitmap before it is
139
+ * cached (see [HardwareBitmapTransformation]). Only for callers that draw the result: its
140
+ * pixels cannot be read back, and its cache key differs from the software one.
141
+ */
142
+ internal fun buildRequest(
143
+ context: Context,
144
+ url: String,
145
+ options: Options?,
146
+ hardwareResult: Boolean = false,
147
+ ): ImageRequest {
148
+ val blur = options?.blur?.toFloat() ?: 0f
149
+ val resize =
150
+ options?.resize?.let { r ->
151
+ val width = r.width.roundToInt()
152
+ val height = r.height.roundToInt()
153
+ if (width > 0 && height > 0) width to height else null
154
+ }
155
+ val roundedCorners: RoundedCornersTransformation? =
156
+ options
157
+ ?.cornerRadius
158
+ ?.match(
159
+ first = { radius ->
160
+ if (radius > 0.0) RoundedCornersTransformation(radius.toFloat()) else null
161
+ },
162
+ second = { radii ->
163
+ // RoundedCornersTransformation rejects negative radii; treat them as square.
164
+ val topLeft = (radii.topLeft?.toFloat() ?: 0f).coerceAtLeast(0f)
165
+ val topRight = (radii.topRight?.toFloat() ?: 0f).coerceAtLeast(0f)
166
+ val bottomLeft = (radii.bottomLeft?.toFloat() ?: 0f).coerceAtLeast(0f)
167
+ val bottomRight = (radii.bottomRight?.toFloat() ?: 0f).coerceAtLeast(0f)
168
+ if (topLeft > 0f || topRight > 0f || bottomLeft > 0f || bottomRight > 0f) {
169
+ RoundedCornersTransformation(topLeft, topRight, bottomLeft, bottomRight)
170
+ } else {
171
+ null
172
+ }
173
+ },
174
+ )
175
+ val transformations = buildList {
176
+ // Resize first: blur sigma and corner radii are in pixels of the bitmap
177
+ // they run on, so they must see the final size. Coil's
178
+ // RoundedCornersTransformation already scale-fills and center-crops
179
+ // to the request size (`resize`, when set) as part of its own draw,
180
+ // so with rounded corners and no blur the explicit resize would only
181
+ // add an intermediate bitmap — and the radii still apply to the final
182
+ // size, because that is the size its output has.
183
+ if (resize != null && (blur > 0f || roundedCorners == null)) {
184
+ add(ResizeTransformation(resize.first, resize.second))
185
+ }
186
+ if (blur > 0f) add(BlurTransformation(blur))
187
+ roundedCorners?.let { add(it) }
188
+ // Without transformations Coil decodes straight to a hardware bitmap
189
+ // already (allowHardware below); only a transformed result needs the
190
+ // explicit upload.
191
+ if (hardwareResult && isNotEmpty() && HardwareBitmapTransformation.isSupported) {
192
+ add(HardwareBitmapTransformation.instance)
193
+ }
194
+ }
195
+ return ImageRequest.Builder(context)
196
+ .data(requestData(context, url))
197
+ .apply {
198
+ when (options?.cache) {
199
+ CacheOption.MEMORY -> {
200
+ memoryCachePolicy(CachePolicy.ENABLED)
201
+ diskCachePolicy(CachePolicy.DISABLED)
202
+ }
203
+ CacheOption.DISK -> {
204
+ memoryCachePolicy(CachePolicy.DISABLED)
205
+ diskCachePolicy(CachePolicy.ENABLED)
206
+ }
207
+ CacheOption.NONE -> {
208
+ memoryCachePolicy(CachePolicy.DISABLED)
209
+ diskCachePolicy(CachePolicy.DISABLED)
210
+ }
211
+ null -> Unit // Coil defaults: both enabled
212
+ }
213
+ // Ask the decoder for the target size so a large source is
214
+ // subsampled near it instead of decoded at full resolution;
215
+ // ResizeTransformation then makes the size exact.
216
+ resize?.let { (width, height) ->
217
+ size(width, height)
218
+ scale(Scale.FILL)
219
+ }
220
+ }
221
+ .allowHardware(true)
222
+ .transformations(transformations)
223
+ .build()
224
+ }
225
+
226
+ /**
227
+ * What Coil should load for [url]. Coil itself handles `http(s)://`, `file://`, `content://`
228
+ * and plain absolute paths. The one form it doesn't is a bare resource name — a string with
229
+ * no scheme, like `src_assets_logo` — which is what React Native's `require()` resolves to
230
+ * in a release build (assets are packed into `res/drawable-*`). Resolve that to the resource
231
+ * id here; the density-qualified variant is then picked by the resources system, like
232
+ * `<Image>` does. An unknown name is passed through so Coil reports the failure as an
233
+ * [ErrorResult] (the loaders must not throw while building a request).
234
+ */
235
+ internal fun requestData(context: Context, url: String): Any {
236
+ if (url.startsWith("/") || url.toUri().scheme != null) return url
237
+ // Tolerate `logo.png`: RN's generated names carry no extension, but nitro-image's
238
+ // `{ resource: 'logo.png' }` convention does.
239
+ val name = url.substringBefore('.')
240
+ for (type in RESOURCE_TYPES) {
241
+ val id = context.resources.getIdentifier(name, type, context.packageName)
242
+ if (id != 0) return id
243
+ }
244
+ return url
245
+ }
246
+
247
+ /** Where React Native puts bundled image assets, then the other image resource types. */
248
+ private val RESOURCE_TYPES = arrayOf("drawable", "mipmap", "raw")
249
+
250
+ /** The decoded bitmap of a successful load. */
251
+ internal fun bitmapOf(result: SuccessResult): Bitmap =
252
+ when (val img = result.image) {
253
+ is BitmapImage -> img.bitmap
254
+ is DrawableImage -> img.drawable.toBitmap()
255
+ else -> throw Error("Unsupported image type: ${img.javaClass.simpleName}")
256
+ }
257
+
258
+ internal fun getOrCreateImageLoader(context: Context): ImageLoader =
186
259
  sharedImageLoader
187
260
  ?: synchronized(this) {
188
261
  sharedImageLoader ?: createImageLoader(context).also { sharedImageLoader = it }
189
262
  }
190
263
 
264
+ /**
265
+ * The shared loader, creating it on [Dispatchers.IO] if it doesn't exist yet. Use this from
266
+ * main-thread coroutines: once the loader exists this returns synchronously (so memory-cache
267
+ * hits stay synchronous), and until then the expensive creation runs off the main thread.
268
+ */
269
+ internal suspend fun awaitImageLoader(context: Context): ImageLoader =
270
+ sharedImageLoader ?: withContext(Dispatchers.IO) { getOrCreateImageLoader(context) }
271
+
272
+ /** Starts creating the shared loader on a background thread, if it doesn't exist yet. */
273
+ private fun warmUpImageLoader(context: Context) {
274
+ if (sharedImageLoader != null) return
275
+ CoroutineScope(Dispatchers.IO).launch {
276
+ // Warm-up only: an uncaught exception here would take the process
277
+ // down. If creation fails, `sharedImageLoader` stays null and the
278
+ // next request retries it and surfaces the error itself.
279
+ runCatching { getOrCreateImageLoader(context) }
280
+ }
281
+ }
282
+
191
283
  private fun createImageLoader(context: Context): ImageLoader {
192
284
  val okHttpClient =
193
285
  OkHttpClient.Builder()
@@ -0,0 +1,177 @@
1
+ package com.margelo.nitro.nitroimagepipeline
2
+
3
+ import android.content.Context
4
+ import android.util.Log
5
+ import android.view.View
6
+ import android.widget.ImageView
7
+ import androidx.annotation.Keep
8
+ import coil3.request.ErrorResult
9
+ import coil3.request.SuccessResult
10
+ import com.facebook.proguard.annotations.DoNotStrip
11
+ import com.margelo.nitro.core.Promise
12
+ import com.margelo.nitro.image.HybridImage
13
+ import com.margelo.nitro.image.HybridImageLoaderSpec
14
+ import com.margelo.nitro.image.HybridImageSpec
15
+ import com.margelo.nitro.image.HybridImageView
16
+ import com.margelo.nitro.image.HybridNitroImageViewSpec
17
+ import kotlin.coroutines.resume
18
+ import kotlinx.coroutines.CoroutineScope
19
+ import kotlinx.coroutines.Dispatchers
20
+ import kotlinx.coroutines.Job
21
+ import kotlinx.coroutines.SupervisorJob
22
+ import kotlinx.coroutines.isActive
23
+ import kotlinx.coroutines.launch
24
+ import kotlinx.coroutines.suspendCancellableCoroutine
25
+
26
+ /**
27
+ * An `ImageLoader` (react-native-nitro-image) backed by the shared pipeline.
28
+ *
29
+ * `<NativeNitroImage image={loader} />` drives it entirely natively: the view calls [requestImage]
30
+ * when it attaches to the window and [dropImage] when it detaches. The load runs at the view's
31
+ * laid-out size with no JS round trips, and detaching cancels the request and releases the bitmap —
32
+ * the decoded image stays in the shared memory/disk caches, so re-attaching (list recycling) is
33
+ * instant.
34
+ *
35
+ * [ViewOptions] values are in points (dp); this class converts them to the pixel-based [Options] of
36
+ * the shared request builder using the display density, so cache keys match an equivalent
37
+ * `loadImage` call. View sizes are already physical pixels on Android, so only blur and corner
38
+ * radii need scaling.
39
+ */
40
+ @DoNotStrip
41
+ @Keep
42
+ class PipelineImageLoader(
43
+ private val context: Context,
44
+ private val url: String,
45
+ private val options: ViewOptions?,
46
+ ) : HybridImageLoaderSpec() {
47
+ // Per-view in-flight work, keyed by the view's identity, so a loader shared
48
+ // between several views cancels only the right request. Confined to the
49
+ // main thread — the view calls requestImage/dropImage there.
50
+ private val jobs = HashMap<HybridNitroImageViewSpec, Job>()
51
+ private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
52
+
53
+ /** An explicit `resize` override (pixels), when set and valid. */
54
+ private val explicitResize: ResizeOptions?
55
+ get() = options?.resize?.takeIf { it.width > 0 && it.height > 0 }
56
+
57
+ /**
58
+ * The point-based [ViewOptions] as pixel-based [Options], resolved against the display [scale]
59
+ * and the target size in pixels.
60
+ */
61
+ private fun pixelOptions(scale: Float, resize: ResizeOptions?): Options {
62
+ val cornerRadius =
63
+ options
64
+ ?.cornerRadius
65
+ ?.match(
66
+ first = { radius -> Variant_Double_CornerRadii.create(radius * scale) },
67
+ second = { radii ->
68
+ Variant_Double_CornerRadii.create(
69
+ CornerRadii(
70
+ topLeft = radii.topLeft?.times(scale),
71
+ topRight = radii.topRight?.times(scale),
72
+ bottomLeft = radii.bottomLeft?.times(scale),
73
+ bottomRight = radii.bottomRight?.times(scale),
74
+ )
75
+ )
76
+ },
77
+ )
78
+ return Options(
79
+ blur = options?.blur?.times(scale),
80
+ cache = options?.cache,
81
+ cornerRadius = cornerRadius,
82
+ resize = resize,
83
+ )
84
+ }
85
+
86
+ override fun loadImage(): Promise<HybridImageSpec> = Promise.async {
87
+ // No view to measure here: use the explicit resize if given, and the
88
+ // display density for the point-based options.
89
+ val scale = context.resources.displayMetrics.density
90
+ val request =
91
+ HybridNitroImagePipeline.buildRequest(context, url, pixelOptions(scale, explicitResize))
92
+ when (val result = HybridNitroImagePipeline.getOrCreateImageLoader(context).execute(request)) {
93
+ is ErrorResult -> throw result.throwable
94
+ is SuccessResult -> HybridImage(HybridNitroImagePipeline.bitmapOf(result))
95
+ }
96
+ }
97
+
98
+ override fun requestImage(forView: HybridNitroImageViewSpec) {
99
+ val view = forView as? HybridImageView ?: return
100
+ val imageView = view.imageView
101
+ jobs[forView]?.cancel()
102
+ val job = mainScope.launch {
103
+ // Views attach before they are laid out, so the size may not be
104
+ // known yet — wait for the first non-empty layout.
105
+ val resize = explicitResize ?: measuredSize(imageView)
106
+ val scale = imageView.resources.displayMetrics.density
107
+ // The result is only ever drawn here, so a transformed one is cached as
108
+ // a hardware bitmap: no texture upload per view that shows it.
109
+ val request =
110
+ HybridNitroImagePipeline.buildRequest(
111
+ context,
112
+ url,
113
+ pixelOptions(scale, resize),
114
+ hardwareResult = true,
115
+ )
116
+ // Synchronous once the shared loader exists; otherwise its (slow)
117
+ // creation runs on a background thread instead of here on main.
118
+ val loader = HybridNitroImagePipeline.awaitImageLoader(context)
119
+ val result = loader.execute(request)
120
+ // Cancellation normally surfaces as a CancellationException at the
121
+ // suspension point above, but make it explicit: a job that was
122
+ // dropped or replaced while the result was in flight must never
123
+ // touch the view.
124
+ if (!isActive) return@launch
125
+ when (result) {
126
+ is SuccessResult -> imageView.setImageBitmap(HybridNitroImagePipeline.bitmapOf(result))
127
+ // Deliberately no URL in the message — signed URLs and query
128
+ // tokens must not leak into consuming apps' Logcat.
129
+ is ErrorResult -> Log.w(TAG, "Failed to load image", result.throwable)
130
+ }
131
+ }
132
+ jobs[forView] = job
133
+ job.invokeOnCompletion { if (jobs[forView] === job) jobs.remove(forView) }
134
+ }
135
+
136
+ override fun dropImage(forView: HybridNitroImageViewSpec) {
137
+ val view = forView as? HybridImageView ?: return
138
+ jobs.remove(forView)?.cancel()
139
+ view.imageView.setImageDrawable(null)
140
+ }
141
+
142
+ /** The view's laid-out size in pixels, suspending until it has one. */
143
+ private suspend fun measuredSize(view: ImageView): ResizeOptions {
144
+ if (view.width > 0 && view.height > 0) {
145
+ return ResizeOptions(view.width.toDouble(), view.height.toDouble())
146
+ }
147
+ return suspendCancellableCoroutine { continuation ->
148
+ val listener =
149
+ object : View.OnLayoutChangeListener {
150
+ override fun onLayoutChange(
151
+ v: View,
152
+ left: Int,
153
+ top: Int,
154
+ right: Int,
155
+ bottom: Int,
156
+ oldLeft: Int,
157
+ oldTop: Int,
158
+ oldRight: Int,
159
+ oldBottom: Int,
160
+ ) {
161
+ val width = right - left
162
+ val height = bottom - top
163
+ if (width > 0 && height > 0) {
164
+ v.removeOnLayoutChangeListener(this)
165
+ continuation.resume(ResizeOptions(width.toDouble(), height.toDouble()))
166
+ }
167
+ }
168
+ }
169
+ view.addOnLayoutChangeListener(listener)
170
+ continuation.invokeOnCancellation { view.removeOnLayoutChangeListener(listener) }
171
+ }
172
+ }
173
+
174
+ companion object {
175
+ private const val TAG = "PipelineImageLoader"
176
+ }
177
+ }
@@ -1,36 +1,27 @@
1
- @file:Suppress("DEPRECATION")
2
-
3
1
  package com.margelo.nitro.nitroimagepipeline.transform
4
2
 
5
- import android.content.Context
6
3
  import android.graphics.Bitmap
7
- import android.graphics.Paint
8
- import android.renderscript.Allocation
9
- import android.renderscript.Element
10
- import android.renderscript.RenderScript
11
- import android.renderscript.ScriptIntrinsicBlur
12
- import androidx.core.graphics.applyCanvas
13
- import androidx.core.graphics.scale
4
+ import android.graphics.Canvas
14
5
  import coil3.size.Size
15
6
  import coil3.transform.Transformation
16
- import kotlin.math.ceil
17
- import kotlin.math.max
18
7
 
19
8
  /**
20
9
  * Gaussian blur of standard deviation [sigma], measured in *source image pixels*.
21
10
  *
22
11
  * The unit is the point of this class: the same sigma on the same source file produces the same
23
- * result here and on iOS. It is deliberately not a "radius" — RenderScript, CoreImage and React
12
+ * result here and on iOS. It is deliberately not a "radius" — CoreImage, RenderScript and React
24
13
  * Native's `blurRadius` each define radius differently, which is why blurs never matched across
25
14
  * platforms.
26
15
  *
16
+ * The kernel is `android/src/main/cpp/GaussianBlur.cpp`, a port of the iOS one: three box-blur
17
+ * passes whose widths are solved for the requested sigma, clamping at the image edges. There is no
18
+ * upper bound on sigma and no downscaling — `bun run verify:blur` measures both kernels against
19
+ * each other.
20
+ *
27
21
  * For reference, React Native's `<Image blurRadius={n} />` halves its input before convolving, so
28
22
  * `blurRadius={n}` is roughly `sigma = n / 2`.
29
23
  */
30
- class BlurTransformation(
31
- private val context: Context,
32
- private val sigma: Float,
33
- ) : Transformation() {
24
+ class BlurTransformation(private val sigma: Float) : Transformation() {
34
25
 
35
26
  init {
36
27
  require(sigma > 0f && sigma.isFinite()) { "sigma must be > 0." }
@@ -39,79 +30,43 @@ class BlurTransformation(
39
30
  override val cacheKey = "${BlurTransformation::class.java.name}-$sigma"
40
31
 
41
32
  override suspend fun transform(input: Bitmap, size: Size): Bitmap {
42
- // ScriptIntrinsicBlur's `radius` maps to a Gaussian sigma of 0.4 * radius + 0.6, and radius is
43
- // capped at 25 so a single pass tops out at sigma ~10.6px. Larger blurs are reached by
44
- // blurring a downscaled copy: scaling down by `s`, blurring with sigma/s and scaling back up
45
- // multiplies the effective sigma by `s`. A blur is a low-pass filter, so the detail the
46
- // downscale drops is detail the blur was going to remove anyway. Downscale only as far as the
47
- // sigma ceiling forces, which keeps small blurs pixel-exact.
48
- val sampling = max(1f, sigma / MAX_SIGMA)
49
- val scaledWidth = ceil(input.width / sampling).toInt().coerceAtLeast(1)
50
- val scaledHeight = ceil(input.height / sampling).toInt().coerceAtLeast(1)
51
- // Rounding to whole pixels shifts the scale slightly; derive the sigma from the scale actually
52
- // applied rather than the requested one.
53
- val scaleX = scaledWidth.toFloat() / input.width
54
- val scaleY = scaledHeight.toFloat() / input.height
55
- val scaledSigma = sigma * max(scaleX, scaleY)
56
- val radius = ((scaledSigma - SIGMA_INTERCEPT) / SIGMA_SLOPE).coerceIn(MIN_RADIUS, MAX_RADIUS)
57
-
58
- val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
59
- val softwareInput =
60
- if (input.config == Bitmap.Config.HARDWARE) input.copy(Bitmap.Config.ARGB_8888, false)
61
- else input
62
- val softwareConfig = softwareInput.config ?: Bitmap.Config.ARGB_8888
63
-
64
- val output = Bitmap.createBitmap(scaledWidth, scaledHeight, softwareConfig)
65
- output.applyCanvas {
66
- scale(scaleX, scaleY)
67
- drawBitmap(softwareInput, 0f, 0f, paint)
68
- }
69
-
70
- var script: RenderScript? = null
71
- var tmpIn: Allocation? = null
72
- var tmpOut: Allocation? = null
73
- var blur: ScriptIntrinsicBlur? = null
74
- try {
75
- script = RenderScript.create(context)
76
- tmpIn =
77
- Allocation.createFromBitmap(
78
- script,
79
- output,
80
- Allocation.MipmapControl.MIPMAP_NONE,
81
- Allocation.USAGE_SCRIPT,
82
- )
83
- tmpOut = Allocation.createTyped(script, tmpIn.type)
84
- blur = ScriptIntrinsicBlur.create(script, Element.U8_4(script))
85
- blur.setRadius(radius)
86
- blur.setInput(tmpIn)
87
- blur.forEach(tmpOut)
88
- tmpOut.copyTo(output)
89
- } finally {
90
- script?.destroy()
91
- tmpIn?.destroy()
92
- tmpOut?.destroy()
93
- blur?.destroy()
94
- }
95
-
96
- return if (scaledWidth == input.width && scaledHeight == input.height) output
97
- else output.scale(input.width, input.height)
33
+ // The kernel blurs in place, on premultiplied ARGB_8888 pixels. A plain
34
+ // copy is the cheapest way to get a mutable bitmap of that layout also
35
+ // from a hardware bitmap, which a software canvas could not draw. Only a
36
+ // non-premultiplied input goes through a canvas, which converts it.
37
+ val output =
38
+ if (
39
+ input.config == Bitmap.Config.HARDWARE ||
40
+ (input.config == Bitmap.Config.ARGB_8888 &&
41
+ (input.isPremultiplied || !input.hasAlpha()))
42
+ ) {
43
+ input.copy(Bitmap.Config.ARGB_8888, true) ?: error("Bitmap.copy returned null")
44
+ } else {
45
+ Bitmap.createBitmap(input.width, input.height, Bitmap.Config.ARGB_8888).also {
46
+ Canvas(it).drawBitmap(input, 0f, 0f, null)
47
+ }
48
+ }
49
+ check(nativeBlur(output, sigma)) { "Native blur failed (see logcat)." }
50
+ return output
98
51
  }
99
52
 
100
53
  override fun equals(other: Any?): Boolean {
101
54
  if (this === other) return true
102
- return other is BlurTransformation && context == other.context && sigma == other.sigma
55
+ return other is BlurTransformation && sigma == other.sigma
103
56
  }
104
57
 
105
- override fun hashCode(): Int = 31 * context.hashCode() + sigma.hashCode()
58
+ override fun hashCode(): Int = sigma.hashCode()
106
59
 
107
60
  override fun toString() = "BlurTransformation(sigma=$sigma)"
108
61
 
109
62
  private companion object {
110
- // sigma = 0.4 * radius + 0.6, per RenderScript's ScriptIntrinsicBlur.
111
- const val SIGMA_SLOPE = 0.4f
112
- const val SIGMA_INTERCEPT = 0.6f
113
- const val MIN_RADIUS = 0.01f
114
- const val MAX_RADIUS = 25f
115
- const val MAX_SIGMA = SIGMA_SLOPE * MAX_RADIUS + SIGMA_INTERCEPT
63
+ init {
64
+ // Nitro loads the library at module registration, but the transformation
65
+ // must not depend on that order; loadLibrary is idempotent.
66
+ System.loadLibrary("NitroImagePipeline")
67
+ }
68
+
69
+ /** Blurs [bitmap] (mutable ARGB_8888) in place. Returns false on failure. */
70
+ @JvmStatic private external fun nativeBlur(bitmap: Bitmap, sigma: Float): Boolean
116
71
  }
117
72
  }
@@ -0,0 +1,45 @@
1
+ package com.margelo.nitro.nitroimagepipeline.transform
2
+
3
+ import android.graphics.Bitmap
4
+ import android.os.Build
5
+ import coil3.size.Size
6
+ import coil3.transform.Transformation
7
+
8
+ /**
9
+ * Uploads the transformed bitmap to a [Bitmap.Config.HARDWARE] bitmap (API 26+), so that is what
10
+ * the memory cache holds and the view draws.
11
+ *
12
+ * Coil decodes straight to a hardware bitmap when a request has no transformations, but with any
13
+ * (resize, blur, rounded corners) the decode and the transformations run on software bitmaps and
14
+ * the software result is what gets cached. Every view showing it then costs a texture upload (and,
15
+ * with the pixels kept for the bitmap, a copy in RAM). Appending this transformation converts the
16
+ * result once, on the transformation thread, and it stays converted in the cache.
17
+ *
18
+ * A hardware bitmap's pixels are not readable, so this must only be added by callers that draw the
19
+ * result — `PipelineImageLoader.requestImage` — never by `loadImage`, whose `HybridImage` needs
20
+ * readable pixels for `toArrayBuffer`/`toBase64`. The extra cache key keeps the two paths' cache
21
+ * entries apart.
22
+ *
23
+ * Below API 26, or if the upload fails, the software bitmap is passed through unchanged.
24
+ */
25
+ class HardwareBitmapTransformation private constructor() : Transformation() {
26
+
27
+ override val cacheKey = HardwareBitmapTransformation::class.java.name
28
+
29
+ override suspend fun transform(input: Bitmap, size: Size): Bitmap {
30
+ if (!isSupported || input.config == Bitmap.Config.HARDWARE) return input
31
+ // Performance-only: a failed upload (null, or an allocation/driver
32
+ // exception) must not fail the request, so fall back to the software
33
+ // bitmap either way.
34
+ return runCatching { input.copy(Bitmap.Config.HARDWARE, false) }.getOrNull() ?: input
35
+ }
36
+
37
+ override fun toString() = "HardwareBitmapTransformation"
38
+
39
+ companion object {
40
+ val isSupported: Boolean
41
+ get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
42
+
43
+ val instance = HardwareBitmapTransformation()
44
+ }
45
+ }