react-native-parity-blur 0.1.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 (109) hide show
  1. package/LICENSE +20 -0
  2. package/ParityBlur.podspec +20 -0
  3. package/README.md +198 -0
  4. package/android/build.gradle +57 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/java/com/parityblur/AndroidBlurCalibration.kt +82 -0
  7. package/android/src/main/java/com/parityblur/BlurEngine.kt +132 -0
  8. package/android/src/main/java/com/parityblur/ParityBlurDebug.kt +24 -0
  9. package/android/src/main/java/com/parityblur/ParityBlurPackage.kt +17 -0
  10. package/android/src/main/java/com/parityblur/ParityBlurView.kt +542 -0
  11. package/android/src/main/java/com/parityblur/ParityBlurViewManager.kt +161 -0
  12. package/android/src/main/java/com/parityblur/PipelineMath.kt +202 -0
  13. package/android/src/main/java/com/parityblur/PixelCopySnapshotProvider.kt +108 -0
  14. package/android/src/main/java/com/parityblur/SoftwareSnapshotProvider.kt +173 -0
  15. package/android/src/main/java/com/parityblur/WindowBlurContext.kt +116 -0
  16. package/android/src/test/java/com/parityblur/MiniJson.kt +179 -0
  17. package/android/src/test/java/com/parityblur/PipelineFixturesTest.kt +222 -0
  18. package/ios/BlurEngine.swift +106 -0
  19. package/ios/ColorPipeline.swift +72 -0
  20. package/ios/MetalPresentationSurface.swift +51 -0
  21. package/ios/ParityBlurCoreView.swift +382 -0
  22. package/ios/ParityBlurDebug.swift +18 -0
  23. package/ios/ParityBlurView.h +14 -0
  24. package/ios/ParityBlurView.mm +135 -0
  25. package/ios/PipelineMath.swift +177 -0
  26. package/ios/SnapshotProvider.swift +128 -0
  27. package/ios/WindowBlurContext.swift +111 -0
  28. package/lib/module/BlurView.js +124 -0
  29. package/lib/module/BlurView.js.map +1 -0
  30. package/lib/module/ParityBlurViewNativeComponent.ts +82 -0
  31. package/lib/module/defaults.js +13 -0
  32. package/lib/module/defaults.js.map +1 -0
  33. package/lib/module/index.js +5 -0
  34. package/lib/module/index.js.map +1 -0
  35. package/lib/module/package.json +1 -0
  36. package/lib/module/pipeline/androidCalibration.js +55 -0
  37. package/lib/module/pipeline/androidCalibration.js.map +1 -0
  38. package/lib/module/pipeline/captureRect.js +151 -0
  39. package/lib/module/pipeline/captureRect.js.map +1 -0
  40. package/lib/module/pipeline/color.js +82 -0
  41. package/lib/module/pipeline/color.js.map +1 -0
  42. package/lib/module/pipeline/constants.js +65 -0
  43. package/lib/module/pipeline/constants.js.map +1 -0
  44. package/lib/module/pipeline/downsample.js +61 -0
  45. package/lib/module/pipeline/downsample.js.map +1 -0
  46. package/lib/module/pipeline/index.js +23 -0
  47. package/lib/module/pipeline/index.js.map +1 -0
  48. package/lib/module/pipeline/overlay.js +56 -0
  49. package/lib/module/pipeline/overlay.js.map +1 -0
  50. package/lib/module/pipeline/saturation.js +52 -0
  51. package/lib/module/pipeline/saturation.js.map +1 -0
  52. package/lib/module/pipeline/types.js +23 -0
  53. package/lib/module/pipeline/types.js.map +1 -0
  54. package/lib/module/pipeline/units.js +33 -0
  55. package/lib/module/pipeline/units.js.map +1 -0
  56. package/lib/module/presets.js +96 -0
  57. package/lib/module/presets.js.map +1 -0
  58. package/lib/module/types.js +4 -0
  59. package/lib/module/types.js.map +1 -0
  60. package/lib/typescript/package.json +1 -0
  61. package/lib/typescript/src/BlurView.d.ts +16 -0
  62. package/lib/typescript/src/BlurView.d.ts.map +1 -0
  63. package/lib/typescript/src/ParityBlurViewNativeComponent.d.ts +63 -0
  64. package/lib/typescript/src/ParityBlurViewNativeComponent.d.ts.map +1 -0
  65. package/lib/typescript/src/defaults.d.ts +12 -0
  66. package/lib/typescript/src/defaults.d.ts.map +1 -0
  67. package/lib/typescript/src/index.d.ts +5 -0
  68. package/lib/typescript/src/index.d.ts.map +1 -0
  69. package/lib/typescript/src/pipeline/androidCalibration.d.ts +42 -0
  70. package/lib/typescript/src/pipeline/androidCalibration.d.ts.map +1 -0
  71. package/lib/typescript/src/pipeline/captureRect.d.ts +84 -0
  72. package/lib/typescript/src/pipeline/captureRect.d.ts.map +1 -0
  73. package/lib/typescript/src/pipeline/color.d.ts +19 -0
  74. package/lib/typescript/src/pipeline/color.d.ts.map +1 -0
  75. package/lib/typescript/src/pipeline/constants.d.ts +52 -0
  76. package/lib/typescript/src/pipeline/constants.d.ts.map +1 -0
  77. package/lib/typescript/src/pipeline/downsample.d.ts +31 -0
  78. package/lib/typescript/src/pipeline/downsample.d.ts.map +1 -0
  79. package/lib/typescript/src/pipeline/index.d.ts +20 -0
  80. package/lib/typescript/src/pipeline/index.d.ts.map +1 -0
  81. package/lib/typescript/src/pipeline/overlay.d.ts +31 -0
  82. package/lib/typescript/src/pipeline/overlay.d.ts.map +1 -0
  83. package/lib/typescript/src/pipeline/saturation.d.ts +38 -0
  84. package/lib/typescript/src/pipeline/saturation.d.ts.map +1 -0
  85. package/lib/typescript/src/pipeline/types.d.ts +32 -0
  86. package/lib/typescript/src/pipeline/types.d.ts.map +1 -0
  87. package/lib/typescript/src/pipeline/units.d.ts +23 -0
  88. package/lib/typescript/src/pipeline/units.d.ts.map +1 -0
  89. package/lib/typescript/src/presets.d.ts +57 -0
  90. package/lib/typescript/src/presets.d.ts.map +1 -0
  91. package/lib/typescript/src/types.d.ts +116 -0
  92. package/lib/typescript/src/types.d.ts.map +1 -0
  93. package/package.json +167 -0
  94. package/src/BlurView.tsx +195 -0
  95. package/src/ParityBlurViewNativeComponent.ts +82 -0
  96. package/src/defaults.ts +20 -0
  97. package/src/index.ts +10 -0
  98. package/src/pipeline/androidCalibration.ts +64 -0
  99. package/src/pipeline/captureRect.ts +172 -0
  100. package/src/pipeline/color.ts +66 -0
  101. package/src/pipeline/constants.ts +65 -0
  102. package/src/pipeline/downsample.ts +74 -0
  103. package/src/pipeline/index.ts +20 -0
  104. package/src/pipeline/overlay.ts +46 -0
  105. package/src/pipeline/saturation.ts +65 -0
  106. package/src/pipeline/types.ts +37 -0
  107. package/src/pipeline/units.ts +32 -0
  108. package/src/presets.ts +103 -0
  109. package/src/types.ts +132 -0
@@ -0,0 +1,542 @@
1
+ package com.parityblur
2
+
3
+ import android.content.Context
4
+ import android.graphics.Bitmap
5
+ import android.graphics.Canvas
6
+ import android.graphics.Color
7
+ import android.graphics.ColorMatrix
8
+ import android.graphics.ColorMatrixColorFilter
9
+ import android.graphics.Paint
10
+ import android.graphics.Path
11
+ import android.graphics.RectF
12
+ import android.graphics.RenderEffect
13
+ import android.graphics.RenderNode
14
+ import android.graphics.Shader
15
+ import android.os.Build
16
+ import android.util.Log
17
+ import android.view.View
18
+ import android.view.ViewGroup
19
+ import com.facebook.react.views.view.ReactViewGroup
20
+
21
+ /**
22
+ * Milestone 3 static-backend implementation of ParityBlurView (docs/MASTER_PLAN.md §14, §20,
23
+ * §36; docs/PIPELINE_SPEC.md).
24
+ *
25
+ * API 31+: captures the backdrop via [SoftwareSnapshotProvider] into a downsampled bitmap, then
26
+ * presents it through a [RenderNode] carrying a chained `blur -> saturation` [RenderEffect]
27
+ * (PIPELINE_SPEC §9 steps 7-9), with the overlay color composited on top in [onDraw] (source-over
28
+ * on an opaque destination -- PIPELINE_SPEC §8) and rounded clipping applied to that whole
29
+ * presentation (blur + overlay), separate from child clipping (plan §31).
30
+ *
31
+ * API <31: [fallbackColor] fill ONLY. Must never touch [BlurEngine] / bitmaps / RenderNode /
32
+ * RenderEffect (plan §27, §45.1) -- every method below that reaches those is gated behind
33
+ * [isRealBlurSupported].
34
+ *
35
+ * State machine (plan §14.4):
36
+ * `DETACHED -> ATTACHED_WAITING_LAYOUT -> WAITING_STABLE_FRAME -> CAPTURE_PENDING -> CAPTURED`.
37
+ * Capture is requested (not performed synchronously) on every trigger; the per-window
38
+ * [WindowBlurContext] coalesces repeated requests to one capture at the next valid frame
39
+ * (plan §45.8: never capture synchronously inside JS command dispatch, never invalidate from
40
+ * preDraw).
41
+ */
42
+ class ParityBlurView(context: Context) : ReactViewGroup(context) {
43
+
44
+ private enum class CaptureState {
45
+ DETACHED,
46
+ ATTACHED_WAITING_LAYOUT,
47
+ WAITING_STABLE_FRAME,
48
+ CAPTURE_PENDING,
49
+ CAPTURED,
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------------- props
53
+
54
+ var blurRadius: Double = 0.0
55
+ private set
56
+ var mode: String = "static"
57
+ private set
58
+ var overlayColor: Int? = null
59
+ private set
60
+ var saturation: Double = 1.0
61
+ private set
62
+ var quality: String = "balanced"
63
+ private set
64
+
65
+ /** 0 = 'auto' sentinel (see ParityBlurViewNativeComponent.ts); else 1|2|4|8. */
66
+ var downsample: Int = 0
67
+ private set
68
+ var maxFps: Int = 30
69
+ private set
70
+ var fallbackColor: Int? = null
71
+ private set
72
+
73
+ fun setBlurRadius(value: Double) {
74
+ if (value == blurRadius) return
75
+ blurRadius = value
76
+ debugLog { "blurRadius=$value" }
77
+ requestCapture() // blurRadius changes the sigma -> capture region/downsample/radius (spec).
78
+ }
79
+
80
+ fun setMode(value: String?) {
81
+ val next = value ?: "static"
82
+ if (next == mode) return
83
+ mode = next
84
+ debugLog { "mode=$mode" }
85
+ syncLiveRegistration()
86
+ if (mode == "static") requestCapture() // leaving live: freeze on a fresh static capture
87
+ }
88
+
89
+ /** Milestone 6: live views register with the per-window coordinator (plan §21/§39). */
90
+ private fun syncLiveRegistration() {
91
+ if (!isRealBlurSupported()) return
92
+ val ctx = windowContext ?: return
93
+ if (mode == "live" && isAttachedToWindow) ctx.registerLive(this) else ctx.unregisterLive(this)
94
+ }
95
+
96
+ fun setOverlayColor(value: Int?) {
97
+ overlayColor = value
98
+ debugLog { "overlayColor=$value" }
99
+ invalidate() // re-present without recapture (plan §20): overlay is applied at draw time only.
100
+ }
101
+
102
+ fun setSaturation(value: Double) {
103
+ if (value == saturation) return
104
+ saturation = value
105
+ debugLog { "saturation=$value" }
106
+ // re-present without recapture: rebuild the existing RenderNode's effect chain in place.
107
+ if (isRealBlurSupported()) rebuildEffectChain()
108
+ invalidate()
109
+ }
110
+
111
+ fun setQuality(value: String?) {
112
+ val next = value ?: "balanced"
113
+ if (next == quality) return
114
+ quality = next
115
+ debugLog { "quality=$quality" }
116
+ requestCapture() // affects downsample selection -> recapture required (spec).
117
+ }
118
+
119
+ fun setDownsample(value: Int) {
120
+ if (value == downsample) return
121
+ downsample = value
122
+ debugLog { "downsample=$value (0 = auto)" }
123
+ requestCapture()
124
+ }
125
+
126
+ fun setMaxFps(value: Int) {
127
+ maxFps = value.coerceIn(1, 120)
128
+ debugLog { "maxFps=$maxFps" }
129
+ }
130
+
131
+ fun setFallbackColor(value: Int?) {
132
+ fallbackColor = value
133
+ debugLog { "fallbackColor=$value" }
134
+ updateFallbackBackground()
135
+ }
136
+
137
+ /**
138
+ * Corner radius setter invoked by [ParityBlurViewManager] (plan §31). `corner`: 0 = uniform
139
+ * (`borderRadius`), 1 = topLeft, 2 = topRight, 3 = bottomRight, 4 = bottomLeft -- matching
140
+ * React Native's `BorderRadiusProp` physical-corner ordering for indices 0..4. `radiusPx` is
141
+ * already density-resolved device px, or null to clear that slot. Percentage border radii are
142
+ * not supported for the blur-output clip in v1 (documented limitation; see manager).
143
+ */
144
+ fun setCornerRadiusPx(corner: Int, radiusPx: Float?) {
145
+ when (corner) {
146
+ 0 -> uniformRadiusPx = radiusPx
147
+ 1 -> topLeftRadiusPx = radiusPx
148
+ 2 -> topRightRadiusPx = radiusPx
149
+ 3 -> bottomRightRadiusPx = radiusPx
150
+ 4 -> bottomLeftRadiusPx = radiusPx
151
+ }
152
+ val u = uniformRadiusPx ?: 0f
153
+ cornerRadii = floatArrayOf(
154
+ topLeftRadiusPx ?: u,
155
+ topRightRadiusPx ?: u,
156
+ bottomRightRadiusPx ?: u,
157
+ bottomLeftRadiusPx ?: u
158
+ )
159
+ clipPathDirty = true
160
+ invalidate()
161
+ }
162
+
163
+ /**
164
+ * Fabric command handler for `refresh()` (plan §29): schedules a coalesced recapture on the
165
+ * next valid frame. Never captures synchronously during command dispatch.
166
+ */
167
+ fun refresh() {
168
+ debugLog { "refresh() requested" }
169
+ requestCapture()
170
+ }
171
+
172
+ // --------------------------------------------------------------------- corner radii state
173
+
174
+ private var uniformRadiusPx: Float? = null
175
+ private var topLeftRadiusPx: Float? = null
176
+ private var topRightRadiusPx: Float? = null
177
+ private var bottomRightRadiusPx: Float? = null
178
+ private var bottomLeftRadiusPx: Float? = null
179
+ private var cornerRadii: FloatArray? = null
180
+ private var clipPath: Path? = null
181
+ private var clipPathDirty = true
182
+
183
+ // -------------------------------------------------------------------------- capture target
184
+
185
+ /**
186
+ * Default capture target (plan §18): the window's root view. `blurTarget` ref override is not
187
+ * wired to native in this milestone (documented gap -- see ParityBlurViewManager); the root
188
+ * view is a reasonable default target and is guaranteed to never itself be a ParityBlurView.
189
+ */
190
+ private var resolvedTarget: ViewGroup? = null
191
+ private var targetLayoutListener: View.OnLayoutChangeListener? = null
192
+
193
+ private fun resolveTarget(): ViewGroup? {
194
+ resolvedTarget?.let { return it }
195
+ val target = rootView as? ViewGroup ?: return null
196
+ val listener = View.OnLayoutChangeListener { _, l, t, r, b, ol, ot, oR, oB ->
197
+ if ((r - l) != (oR - ol) || (b - t) != (oB - ot)) requestCapture()
198
+ }
199
+ target.addOnLayoutChangeListener(listener)
200
+ resolvedTarget = target
201
+ targetLayoutListener = listener
202
+ return target
203
+ }
204
+
205
+ private fun releaseTargetListener() {
206
+ targetLayoutListener?.let { resolvedTarget?.removeOnLayoutChangeListener(it) }
207
+ targetLayoutListener = null
208
+ resolvedTarget = null
209
+ }
210
+
211
+ // ------------------------------------------------------------------------- state machine
212
+
213
+ private var captureState = CaptureState.DETACHED
214
+ private var generation = 0
215
+ private var windowContext: WindowBlurContext? = null
216
+ private var lastLeft = Int.MIN_VALUE
217
+ private var lastTop = Int.MIN_VALUE
218
+
219
+ private fun isRealBlurSupported(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
220
+
221
+ private fun isEligibleForCapture(): Boolean {
222
+ if (!isAttachedToWindow) return false
223
+ if (width <= 0 || height <= 0) return false
224
+ val target = resolveTarget() ?: return false
225
+ return target.width > 0 && target.height > 0
226
+ }
227
+
228
+ /**
229
+ * Request a capture: bumps the generation and asks the window context to coalesce a capture
230
+ * at the next valid frame boundary (plan §14.4/§20/§23). No-ops entirely on API<31 (guardrail:
231
+ * fallback-only instances must never touch BlurEngine).
232
+ */
233
+ private fun requestCapture() {
234
+ if (!isRealBlurSupported()) return
235
+ generation++
236
+ if (!isEligibleForCapture()) {
237
+ captureState = if (isAttachedToWindow) CaptureState.ATTACHED_WAITING_LAYOUT else CaptureState.DETACHED
238
+ return
239
+ }
240
+ if (captureState != CaptureState.CAPTURED) captureState = CaptureState.WAITING_STABLE_FRAME
241
+ val ctx = windowContext ?: BlurEngine.get(context).windowContextFor(rootView).also {
242
+ it.register(this)
243
+ windowContext = it
244
+ }
245
+ ctx.scheduleCapture(this)
246
+ }
247
+
248
+ /** Invoked by [WindowBlurContext]'s coalesced Choreographer callback -- runs on the main thread. */
249
+ internal fun performScheduledCapture() {
250
+ if (!isRealBlurSupported()) return
251
+ if (!isEligibleForCapture()) {
252
+ captureState = CaptureState.ATTACHED_WAITING_LAYOUT
253
+ return
254
+ }
255
+ val target = resolveTarget() ?: return
256
+ captureState = CaptureState.CAPTURE_PENDING
257
+ val myGeneration = generation
258
+ val engine = BlurEngine.get(context)
259
+
260
+ // First capture after mount: GPU-composited PixelCopy so HARDWARE bitmaps (RN Images) are
261
+ // included -- the software canvas cannot rasterize them (see PixelCopySnapshotProvider).
262
+ // Safe only before this view has ever presented (no self-content on screen to feed back).
263
+ // Async: generation + attachment re-validated on completion (plan §23).
264
+ if (!hasContent) {
265
+ PixelCopySnapshotProvider.capture(this, target, blurRadius, downsample, quality, engine) { result ->
266
+ if (myGeneration != generation || !isAttachedToWindow) {
267
+ result?.let { engine.releaseBitmap(it.bitmap) }
268
+ return@capture
269
+ }
270
+ if (result != null) {
271
+ presentCapture(result, engine)
272
+ captureState = CaptureState.CAPTURED
273
+ } else {
274
+ // PixelCopy unavailable (no window surface, teardown race): software fallback.
275
+ completeSoftwareCapture(target, engine, myGeneration)
276
+ }
277
+ }
278
+ return
279
+ }
280
+
281
+ completeSoftwareCapture(target, engine, myGeneration)
282
+ }
283
+
284
+ // ------------------------------------------------------------------- live (Milestone 6)
285
+
286
+ private var lastLiveCaptureNanos = 0L
287
+
288
+ /**
289
+ * Visibility heuristic + per-view maxFps throttle (plan §24, §21). Called by the window
290
+ * coordinator on every pre-draw tick while any live view is registered.
291
+ */
292
+ internal fun isLiveEligible(nowNanos: Long): Boolean {
293
+ if (mode != "live" || !isRealBlurSupported()) return false
294
+ if (!isAttachedToWindow || windowVisibility != View.VISIBLE) return false
295
+ if (!isShown || alpha <= 0.02f || width <= 0 || height <= 0) return false
296
+ val minInterval = 1_000_000_000L / maxFps
297
+ if (nowNanos - lastLiveCaptureNanos < minInterval) return false
298
+ val loc = IntArray(2)
299
+ getLocationInWindow(loc)
300
+ val root = rootView
301
+ if (loc[0] + width <= 0 || loc[1] + height <= 0) return false
302
+ if (loc[0] >= root.width || loc[1] >= root.height) return false
303
+ return true
304
+ }
305
+
306
+ /**
307
+ * Live recapture INSIDE the in-flight frame (M0-proven): synchronous software capture, then
308
+ * re-record the presentation node WITHOUT invalidate() -- the retained RenderNode propagates
309
+ * its own damage, so no frame loop forms (plan §45.8). Software-only: PixelCopy would read
310
+ * back this view's own committed output (feedback). Backpressure is inherent -- one capture
311
+ * per view per frame, no queue (plan §22).
312
+ */
313
+ internal fun performLiveCapture(nowNanos: Long) {
314
+ val target = resolveTarget() ?: return
315
+ val engine = BlurEngine.get(context)
316
+ val result = SoftwareSnapshotProvider.capture(this, target, blurRadius, downsample, quality, engine)
317
+ ?: return
318
+ lastLiveCaptureNanos = nowNanos
319
+ presentCapture(result, engine, invalidateAfter = false)
320
+ captureState = CaptureState.CAPTURED
321
+ }
322
+
323
+ /** Synchronous software-canvas capture (recaptures; PixelCopy fallback). */
324
+ private fun completeSoftwareCapture(target: ViewGroup, engine: BlurEngine, myGeneration: Int) {
325
+ val result = SoftwareSnapshotProvider.capture(this, target, blurRadius, downsample, quality, engine)
326
+ if (myGeneration != generation) {
327
+ result?.let { engine.releaseBitmap(it.bitmap) }
328
+ return
329
+ }
330
+ if (result == null) {
331
+ captureState = CaptureState.ATTACHED_WAITING_LAYOUT
332
+ return
333
+ }
334
+ presentCapture(result, engine)
335
+ captureState = CaptureState.CAPTURED
336
+ }
337
+
338
+ // ------------------------------------------------------------------------- presentation
339
+
340
+ private var presentNode: RenderNode? = null
341
+ private var capturedBitmap: Bitmap? = null
342
+ private var cropRect: PipelineMath.Rect? = null
343
+ private var capturedDownsample: Int = 1
344
+ private var noBlurCurrent = true
345
+ private var radiusPlatformCurrent = 0f
346
+ private var hasContent = false
347
+
348
+ private val overlayPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL }
349
+
350
+ private fun presentCapture(
351
+ result: SoftwareSnapshotProvider.Result,
352
+ engine: BlurEngine,
353
+ invalidateAfter: Boolean = true,
354
+ ) {
355
+ capturedBitmap?.let { old -> if (old !== result.bitmap) engine.releaseBitmap(old) }
356
+ capturedBitmap = result.bitmap
357
+ cropRect = result.cropRect
358
+ capturedDownsample = result.downsample
359
+ noBlurCurrent = result.noBlur
360
+ radiusPlatformCurrent = result.radiusPlatform
361
+
362
+ val node = presentNode ?: RenderNode("parityBlurPresent").also { presentNode = it }
363
+ node.setPosition(0, 0, result.bitmap.width, result.bitmap.height)
364
+ val recordingCanvas = node.beginRecording()
365
+ try {
366
+ recordingCanvas.drawBitmap(result.bitmap, 0f, 0f, null)
367
+ } finally {
368
+ node.endRecording()
369
+ }
370
+ rebuildEffectChain()
371
+ hasContent = true
372
+ // Live captures run inside preDraw: the re-recorded RenderNode propagates damage itself,
373
+ // and invalidating from preDraw would schedule an infinite frame loop (plan §45.8).
374
+ if (invalidateAfter) invalidate()
375
+ }
376
+
377
+ /**
378
+ * Rebuilds the presentation node's RenderEffect chain: blur (CLAMP) then saturation
379
+ * (PIPELINE_SPEC §9 steps 8-9), via `createColorFilterEffect(filter, blurEffect)` so saturation
380
+ * composes AFTER blur. blurRadius=0 / no-blur clamp presents the un-blurred snapshot (spec §2)
381
+ * by simply omitting the blur stage; overlay compositing (spec §8, the only tint) happens
382
+ * separately in [onDraw] on top of this node's output, not inside the effect chain.
383
+ */
384
+ private fun rebuildEffectChain() {
385
+ val node = presentNode ?: return
386
+ var effect: RenderEffect? = if (!noBlurCurrent && radiusPlatformCurrent > 0f) {
387
+ RenderEffect.createBlurEffect(radiusPlatformCurrent, radiusPlatformCurrent, Shader.TileMode.CLAMP)
388
+ } else {
389
+ null
390
+ }
391
+ if (saturation != 1.0) {
392
+ val colorFilter = ColorMatrixColorFilter(ColorMatrix(PipelineMath.saturationMatrixFloat(saturation)))
393
+ effect = if (effect != null) {
394
+ RenderEffect.createColorFilterEffect(colorFilter, effect)
395
+ } else {
396
+ RenderEffect.createColorFilterEffect(colorFilter)
397
+ }
398
+ }
399
+ node.setRenderEffect(effect)
400
+ }
401
+
402
+ private fun releaseCapturedResources() {
403
+ if (isRealBlurSupported()) {
404
+ capturedBitmap?.let { bmp -> BlurEngine.get(context).releaseBitmap(bmp) }
405
+ }
406
+ capturedBitmap = null
407
+ presentNode = null
408
+ cropRect = null
409
+ hasContent = false
410
+ releaseTargetListener()
411
+ }
412
+
413
+ // ------------------------------------------------------------------------- fallback path
414
+
415
+ /** API<31: fallbackColor fill only. Never touches BlurEngine/bitmaps (plan §27). */
416
+ private fun updateFallbackBackground() {
417
+ if (isRealBlurSupported()) {
418
+ setBackgroundColor(Color.TRANSPARENT)
419
+ } else {
420
+ setBackgroundColor(fallbackColor ?: Color.TRANSPARENT)
421
+ }
422
+ }
423
+
424
+ // --------------------------------------------------------------------------- lifecycle
425
+
426
+ init {
427
+ setWillNotDraw(false)
428
+ }
429
+
430
+ override fun onAttachedToWindow() {
431
+ super.onAttachedToWindow()
432
+ captureState = CaptureState.ATTACHED_WAITING_LAYOUT
433
+ if (isRealBlurSupported()) {
434
+ CapturePass.registered.add(this)
435
+ val ctx = BlurEngine.get(context).windowContextFor(rootView)
436
+ ctx.register(this)
437
+ windowContext = ctx
438
+ requestCapture()
439
+ syncLiveRegistration()
440
+ }
441
+ }
442
+
443
+ override fun onDetachedFromWindow() {
444
+ captureState = CaptureState.DETACHED
445
+ windowContext?.let {
446
+ it.unregister(this)
447
+ it.cancelScheduledCapture(this)
448
+ }
449
+ windowContext = null
450
+ CapturePass.registered.remove(this)
451
+ releaseCapturedResources()
452
+ ParityBlurDebug.log { "instance-release id=${System.identityHashCode(this)} mode=$mode" }
453
+ super.onDetachedFromWindow()
454
+ }
455
+
456
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
457
+ super.onSizeChanged(w, h, oldw, oldh)
458
+ clipPathDirty = true
459
+ if (w > 0 && h > 0) requestCapture()
460
+ }
461
+
462
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
463
+ super.onLayout(changed, left, top, right, bottom)
464
+ val moved = left != lastLeft || top != lastTop
465
+ lastLeft = left
466
+ lastTop = top
467
+ // Only treat this as a recapture trigger once we already have a captured frame -- the
468
+ // initial layout is handled by onSizeChanged/onAttachedToWindow already scheduling one.
469
+ if (moved && captureState == CaptureState.CAPTURED) requestCapture()
470
+ }
471
+
472
+ // ------------------------------------------------------------------------------- drawing
473
+
474
+ override fun draw(canvas: Canvas) {
475
+ // Software-path in-tree exclusion (plan §14.2; M0 finding 1): during any capture pass this
476
+ // view contributes NOTHING -- neither its blur result nor its children -- so it can never
477
+ // recursively capture its own (or another registered view's) presentation. Normal
478
+ // composition (CapturePass.active == false) is unaffected: children stay sharp above the
479
+ // blur result (plan §30).
480
+ if (CapturePass.active) return
481
+ super.draw(canvas)
482
+ }
483
+
484
+ override fun onDraw(canvas: Canvas) {
485
+ super.onDraw(canvas)
486
+ if (!isRealBlurSupported()) return // fallback path: background color only, already drawn.
487
+ if (!hasContent || !canvas.isHardwareAccelerated) return
488
+ val node = presentNode ?: return
489
+ val crop = cropRect ?: return
490
+
491
+ val saved = canvas.save()
492
+ ensureClipPath()?.let { canvas.clipPath(it) }
493
+
494
+ canvas.save()
495
+ canvas.clipRect(0, 0, width, height)
496
+ val d = capturedDownsample.toFloat()
497
+ canvas.scale(d, d)
498
+ canvas.translate(-crop.x.toFloat(), -crop.y.toFloat())
499
+ canvas.drawRenderNode(node)
500
+ canvas.restore()
501
+
502
+ // Overlay compositing (spec §8): the ONLY tint in the pipeline. Drawing the already-parsed
503
+ // overlayColor with a normal Paint is exactly straight-alpha source-over on the opaque
504
+ // blurred destination beneath it -- see PipelineMath.sourceOver for the mirrored pure-math
505
+ // form exercised by the fixture suite.
506
+ overlayColor?.let { color ->
507
+ if (Color.alpha(color) > 0) {
508
+ overlayPaint.color = color
509
+ canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), overlayPaint)
510
+ }
511
+ }
512
+
513
+ canvas.restoreToCount(saved)
514
+ }
515
+
516
+ private fun ensureClipPath(): Path? {
517
+ val radii = cornerRadii
518
+ if (radii == null || radii.all { it <= 0f }) return null
519
+ if (!clipPathDirty && clipPath != null) return clipPath
520
+ val path = clipPath ?: Path().also { clipPath = it }
521
+ path.reset()
522
+ val rect = RectF(0f, 0f, width.toFloat(), height.toFloat())
523
+ val r = floatArrayOf(
524
+ radii[0], radii[0], // top-left
525
+ radii[1], radii[1], // top-right
526
+ radii[2], radii[2], // bottom-right
527
+ radii[3], radii[3] // bottom-left
528
+ )
529
+ path.addRoundRect(rect, r, Path.Direction.CW)
530
+ clipPathDirty = false
531
+ return path
532
+ }
533
+
534
+ private inline fun debugLog(msg: () -> String) {
535
+ if (DEBUG_LOGS) Log.d(TAG, msg())
536
+ }
537
+
538
+ companion object {
539
+ private const val TAG = "ParityBlurView"
540
+ private const val DEBUG_LOGS = false
541
+ }
542
+ }
@@ -0,0 +1,161 @@
1
+ package com.parityblur
2
+
3
+ import com.facebook.react.bridge.Dynamic
4
+ import com.facebook.react.bridge.DynamicFromObject
5
+ import com.facebook.react.bridge.ReadableArray
6
+ import com.facebook.react.module.annotations.ReactModule
7
+ import com.facebook.react.uimanager.BackgroundStyleApplicator
8
+ import com.facebook.react.uimanager.LengthPercentage
9
+ import com.facebook.react.uimanager.LengthPercentageType
10
+ import com.facebook.react.uimanager.PixelUtil
11
+ import com.facebook.react.uimanager.ThemedReactContext
12
+ import com.facebook.react.uimanager.ViewGroupManager
13
+ import com.facebook.react.uimanager.ViewManagerDelegate
14
+ import com.facebook.react.uimanager.ViewProps
15
+ import com.facebook.react.uimanager.annotations.ReactPropGroup
16
+ import com.facebook.react.uimanager.style.BorderRadiusProp
17
+ import com.facebook.react.viewmanagers.ParityBlurViewManagerDelegate
18
+ import com.facebook.react.viewmanagers.ParityBlurViewManagerInterface
19
+
20
+ /**
21
+ * Milestone 3 ViewManager for ParityBlurView (docs/MASTER_PLAN.md §36).
22
+ *
23
+ * Extends [ViewGroupManager] (not SimpleViewManager) so React Native children are hosted
24
+ * normally above the blur result -- see plan §30. Codegen-declared props/commands (plan §28/§29)
25
+ * are plumbed straight through to [ParityBlurView] via [ParityBlurViewManagerInterface].
26
+ *
27
+ * Border-radius handling (plan §31) is wired here directly rather than through codegen: common
28
+ * View style props like `borderRadius` are NOT part of the codegen-generated
29
+ * [ParityBlurViewManagerInterface] (that interface only covers props explicitly declared in
30
+ * ParityBlurViewNativeComponent.ts). Android's Fabric ViewManagers apply such common style props
31
+ * through the same legacy `@ReactProp`/`@ReactPropGroup` reflection path used by the plain
32
+ * `<View>` -- see `BaseViewManager.setBorderRadius` (a no-op stub that just logs "doesn't support
33
+ * property 'borderRadius'", which is exactly the warning Milestone 1 observed) and
34
+ * `ReactViewManager.setBorderRadius` (the real, annotated override). We mirror
35
+ * `ReactViewManager`'s approach: apply the radius to the view's background/border machinery via
36
+ * [BackgroundStyleApplicator] (so any backgroundColor/border the view might have also respects
37
+ * it) AND forward a resolved device-px value to [ParityBlurView.setCornerRadiusPx] so the blur
38
+ * OUTPUT can be clipped with a matching rounded-rect path (plan §31: blur-output clipping and
39
+ * child clipping are separate concerns -- children keep normal RN overflow semantics and are not
40
+ * force-clipped here).
41
+ *
42
+ * Only the five physical corner properties from plan §31 are wired (uniform + four corners);
43
+ * RTL logical corners (borderStartStartRadius etc.) and percentage radii are out of scope for
44
+ * v1's blur-output clip (documented limitation) -- BackgroundStyleApplicator still receives the
45
+ * raw value either way so the view's own background/border rendering is unaffected.
46
+ */
47
+ @ReactModule(name = ParityBlurViewManager.NAME)
48
+ class ParityBlurViewManager :
49
+ ViewGroupManager<ParityBlurView>(),
50
+ ParityBlurViewManagerInterface<ParityBlurView> {
51
+
52
+ /**
53
+ * On Fabric, ALL prop updates route through the codegen [ViewManagerDelegate]; the
54
+ * `@ReactPropGroup` reflection path below is consulted only on the legacy architecture.
55
+ * Standard View style props not in our codegen spec (the border radii) fall through the
56
+ * generated delegate to BaseViewManagerDelegate, which does not handle them and logs
57
+ * "ParityBlurView doesn't support property 'borderRadius'" (observed on-device on RN 0.85).
58
+ * This subclass intercepts the five physical corner props and routes them to the same
59
+ * handler the legacy path uses, restoring plan §31 rounded clipping under Fabric.
60
+ */
61
+ private class BorderRadiusAwareDelegate(
62
+ private val manager: ParityBlurViewManager,
63
+ ) : ViewManagerDelegate<ParityBlurView> {
64
+ // Composition, not inheritance: subclassing the generated Java delegate from Kotlin trips
65
+ // an erased-JVM-signature clash on receiveCommand (platform declaration clash).
66
+ private val inner =
67
+ ParityBlurViewManagerDelegate<ParityBlurView, ParityBlurViewManager>(manager)
68
+
69
+ override fun setProperty(view: ParityBlurView, propName: String, value: Any?) {
70
+ val cornerIndex = when (propName) {
71
+ ViewProps.BORDER_RADIUS -> 0
72
+ ViewProps.BORDER_TOP_LEFT_RADIUS -> 1
73
+ ViewProps.BORDER_TOP_RIGHT_RADIUS -> 2
74
+ ViewProps.BORDER_BOTTOM_RIGHT_RADIUS -> 3
75
+ ViewProps.BORDER_BOTTOM_LEFT_RADIUS -> 4
76
+ else -> null
77
+ }
78
+ if (cornerIndex != null) {
79
+ manager.setBorderRadius(view, cornerIndex, DynamicFromObject(value))
80
+ return
81
+ }
82
+ @Suppress("DEPRECATION")
83
+ inner.setProperty(view, propName, value)
84
+ }
85
+
86
+ override fun receiveCommand(view: ParityBlurView, commandName: String, args: ReadableArray) {
87
+ inner.receiveCommand(view, commandName, args)
88
+ }
89
+ }
90
+
91
+ private val mDelegate: ViewManagerDelegate<ParityBlurView> =
92
+ BorderRadiusAwareDelegate(this)
93
+
94
+ override fun getDelegate(): ViewManagerDelegate<ParityBlurView> = mDelegate
95
+
96
+ override fun getName(): String = NAME
97
+
98
+ override fun createViewInstance(context: ThemedReactContext): ParityBlurView =
99
+ ParityBlurView(context)
100
+
101
+ override fun setBlurRadius(view: ParityBlurView?, value: Double) {
102
+ view?.setBlurRadius(value)
103
+ }
104
+
105
+ override fun setMode(view: ParityBlurView?, value: String?) {
106
+ view?.setMode(value)
107
+ }
108
+
109
+ override fun setOverlayColor(view: ParityBlurView?, value: Int?) {
110
+ view?.setOverlayColor(value)
111
+ }
112
+
113
+ override fun setSaturation(view: ParityBlurView?, value: Double) {
114
+ view?.setSaturation(value)
115
+ }
116
+
117
+ override fun setQuality(view: ParityBlurView?, value: String?) {
118
+ view?.setQuality(value)
119
+ }
120
+
121
+ override fun setDownsample(view: ParityBlurView?, value: Int) {
122
+ view?.setDownsample(value)
123
+ }
124
+
125
+ override fun setMaxFps(view: ParityBlurView?, value: Int) {
126
+ view?.setMaxFps(value)
127
+ }
128
+
129
+ override fun setFallbackColor(view: ParityBlurView?, value: Int?) {
130
+ view?.setFallbackColor(value)
131
+ }
132
+
133
+ override fun refresh(view: ParityBlurView?) {
134
+ view?.refresh()
135
+ }
136
+
137
+ @ReactPropGroup(
138
+ names = [
139
+ ViewProps.BORDER_RADIUS,
140
+ ViewProps.BORDER_TOP_LEFT_RADIUS,
141
+ ViewProps.BORDER_TOP_RIGHT_RADIUS,
142
+ ViewProps.BORDER_BOTTOM_RIGHT_RADIUS,
143
+ ViewProps.BORDER_BOTTOM_LEFT_RADIUS,
144
+ ]
145
+ )
146
+ fun setBorderRadius(view: ParityBlurView, index: Int, rawBorderRadius: Dynamic) {
147
+ val lengthPercentage = LengthPercentage.setFromDynamic(rawBorderRadius)
148
+ BackgroundStyleApplicator.setBorderRadius(view, BorderRadiusProp.values()[index], lengthPercentage)
149
+
150
+ val px = if (lengthPercentage != null && lengthPercentage.type == LengthPercentageType.POINT) {
151
+ PixelUtil.toPixelFromDIP(lengthPercentage.resolve(0f).toDouble())
152
+ } else {
153
+ null
154
+ }
155
+ view.setCornerRadiusPx(index, px)
156
+ }
157
+
158
+ companion object {
159
+ const val NAME = "ParityBlurView"
160
+ }
161
+ }