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
package/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lakhwinder Singh
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ of this software and associated documentation files (the "Software"), to deal
6
+ in the Software without restriction, including without limitation the rights
7
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the Software is
9
+ furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all
12
+ copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ SOFTWARE.
@@ -0,0 +1,20 @@
1
+ require "json"
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, "package.json")))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = "ParityBlur"
7
+ s.version = package["version"]
8
+ s.summary = package["description"]
9
+ s.homepage = package["homepage"]
10
+ s.license = package["license"]
11
+ s.authors = package["author"]
12
+
13
+ s.platforms = { :ios => min_ios_version_supported }
14
+ s.source = { :git => "https://github.com/lakhwindersingh/react-native-parity-blur.git", :tag => "#{s.version}" }
15
+
16
+ s.source_files = "ios/**/*.{h,m,mm,swift,cpp}"
17
+ s.private_header_files = "ios/**/*.h"
18
+
19
+ install_modules_dependencies(s)
20
+ end
package/README.md ADDED
@@ -0,0 +1,198 @@
1
+ # react-native-parity-blur
2
+
3
+ Cross-platform native backdrop blur for React Native (New Architecture / Fabric).
4
+
5
+ ## Why
6
+
7
+ Every React Native blur library today wraps a different native primitive with a different
8
+ default look — `UIVisualEffectView` on iOS, a custom renderscript/RenderEffect approximation on
9
+ Android — and ships no cross-platform definition of what a given "blur amount" should look like.
10
+ The result: the same prop value produces a visibly different blur on each platform, and there is
11
+ no way to reason about it other than eyeballing a device.
12
+
13
+ `react-native-parity-blur` starts from one canonical, documented blur pipeline
14
+ (docs/PIPELINE_SPEC.md) — a single Gaussian-sigma unit, a single capture/downsample/blur/
15
+ saturate/overlay order, the same edge handling, the same color space — and implements it natively
16
+ on both platforms: real `RenderEffect` on Android (API 31+), real `MPSImageGaussianBlur` on iOS.
17
+ The two backends were then calibrated against each other on physical devices
18
+ (docs/CALIBRATION_REPORT.md): across 110 measured strip comparisons, the worst per-strip color
19
+ difference is **ΔE76 0.90** and the lowest structural similarity is **SSIM 0.983** — i.e. the same
20
+ `blurRadius` really does mean the same thing on both platforms, not just approximately.
21
+
22
+ ## Installation
23
+
24
+ ```sh
25
+ npm install react-native-parity-blur
26
+ ```
27
+
28
+ or
29
+
30
+ ```sh
31
+ yarn add react-native-parity-blur
32
+ ```
33
+
34
+ This library ships native Kotlin and Swift code and requires **autolinking** (no manual native
35
+ setup) and a native rebuild after install (`pod install` for iOS, then a full app rebuild — a
36
+ JS-only reload is not enough).
37
+
38
+ ### Requirements
39
+
40
+ - **React Native 0.76+ with the New Architecture (Fabric) enabled.** This is a Fabric-only
41
+ package (`create-react-native-library` type `fabric-view`); it does not support the legacy
42
+ (Paper) renderer.
43
+ - **iOS**: real blur on all supported iOS versions via Metal Performance Shaders.
44
+ - **Android**: real blur requires **API 31+** (`RenderEffect`). Below API 31 there is no CPU blur
45
+ fallback by design (plan §44) — the view instead renders `fallbackColor` as a flat color. Set a
46
+ `fallbackColor` close to your intended blurred backdrop's average color so older devices still
47
+ look reasonable.
48
+ - iOS "Reduce Transparency" (accessibility) forces the same `fallbackColor` fallback path on iOS,
49
+ for the same reason system blur is disabled there.
50
+
51
+ ## Quick start
52
+
53
+ ```tsx
54
+ import { BlurView } from 'react-native-parity-blur';
55
+
56
+ function BottomSheet() {
57
+ return (
58
+ <BlurView
59
+ style={{ position: 'absolute', left: 0, right: 0, bottom: 0, height: 280 }}
60
+ blurRadius={20}
61
+ overlayColor="rgba(16,16,16,0.35)"
62
+ saturation={1.4}
63
+ fallbackColor="rgba(20,20,20,0.92)"
64
+ >
65
+ <Text>Sharp content on top of a blurred backdrop</Text>
66
+ </BlurView>
67
+ );
68
+ }
69
+ ```
70
+
71
+ By default (`mode="static"`), the backdrop is captured once — on mount and on layout/relevant-prop
72
+ changes — and presented as a still blurred snapshot. If content behind the view changes afterward
73
+ without a layout change, call `refresh()` (see below) or switch to `mode="live"`.
74
+
75
+ ## The canonical unit: `blurRadius`
76
+
77
+ `blurRadius` is a **Gaussian sigma expressed in dp** (density-independent pixels — iOS `pt` and
78
+ Android `dp` are treated as the same unit). It is *not* a platform-native blur-radius parameter:
79
+ internally it's converted to whatever each backend's real API expects (Android's `RenderEffect`
80
+ radius, iOS's MPS sigma — see docs/PIPELINE_SPEC.md §2), so you write one number and get a
81
+ calibrated, matching blur weight on both platforms. There is no "roughly similar" — see
82
+ [Parity](#parity) below for the measured numbers.
83
+
84
+ ## Props
85
+
86
+ | Prop | Type | Default | Semantics |
87
+ |---|---|---|---|
88
+ | `blurRadius` | `number` | `0` | Gaussian sigma in dp — the canonical cross-platform blur unit (see above). `0` (or omitted) renders no blur. Negative/non-finite values clamp to `0` with a DEV warning. |
89
+ | `mode` | `'static' \| 'live'` | `'static'` | `'static'` captures the backdrop once (mount/layout/relevant-prop change) and shows a still snapshot — call `refresh()` after further backdrop changes. `'live'` recaptures continuously, throttled by `maxFps`, while the view is visible. |
90
+ | `preset` | `BlurPresetName` | none | Named material-like preset (see [Presets](#presets)) resolved client-side to `{ blurRadius, saturation, overlayColor }` before this component's own explicit props are layered on top. Pure JS sugar — no native prop is added. |
91
+ | `overlayColor` | `string` (color) | `'transparent'` | Straight-alpha color composited source-over the blurred + saturated result — the only tint anywhere in the pipeline (no hidden per-platform tint). Unparseable colors resolve to "no overlay" with a DEV warning. |
92
+ | `saturation` | `number` | `1` | Post-blur, pre-overlay saturation multiplier: `1` = unchanged, `0` = Rec.709 grayscale, `>1` = boosted. Negative/non-finite values clamp to `1` with a DEV warning. |
93
+ | `quality` | `'high' \| 'balanced' \| 'performance'` | `'balanced'` | Biases automatic downsample selection: `high` prefers 1×/2× snapshots, `balanced` prefers 2×/4×, `performance` prefers 4×/8×. No effect when `downsample` is an explicit number. |
94
+ | `downsample` | `'auto' \| 1 \| 2 \| 4 \| 8` | `'auto'` | Snapshot downsample factor. `'auto'` derives a factor from sigma, capture area, and `quality`; an explicit factor overrides selection for advanced tuning. |
95
+ | `maxFps` | `number` | `30` | Upper bound on live-mode recapture rate. Ignored in `'static'` mode. Clamped to `[1, 120]` with a DEV warning outside that range. |
96
+ | `fallbackColor` | `string` (color) | none | Color rendered instead of real blur wherever real blur is unavailable (Android < API 31, or "Reduce Transparency" on either platform). No effect where real blur runs. If unset, the fallback path renders fully transparent. |
97
+ | `blurTarget` | `React.RefObject<any>` | none | **Not implemented in v1** — accepted only to keep the public API shape stable; passing it currently has no effect. See [Known limitations](docs/LIMITATIONS.md). Planned for v1.1. |
98
+ | `children` | `React.ReactNode` | — | Rendered on top of the blur, unaffected by it. |
99
+ | `style` | `StyleProp<ViewStyle>` | — | Standard RN view style. `borderRadius` (and other clip styles) clip the blur output itself, not just the children. |
100
+
101
+ Imperative ref:
102
+
103
+ ```tsx
104
+ const blurRef = useRef<BlurViewRef>(null);
105
+ // ...
106
+ <BlurView ref={blurRef} mode="static" blurRadius={20}>
107
+ {children}
108
+ </BlurView>
109
+ // later, after the backdrop behind it changed:
110
+ blurRef.current?.refresh();
111
+ ```
112
+
113
+ `refresh()` schedules a coalesced recapture on the next valid frame — it is a real, fully wired
114
+ native command on both platforms (not a stub). Repeated calls before the pending capture runs
115
+ coalesce into a single recapture, so it's safe to call from scroll/animation callbacks.
116
+
117
+ ## Presets
118
+
119
+ A preset is nothing more than a fixed `{ blurRadius, saturation, overlayColor }` bundle — plain
120
+ public props, no hidden or platform-specific backend behavior (plan §11). Spread one directly:
121
+
122
+ ```tsx
123
+ import { BlurPresets } from 'react-native-parity-blur';
124
+
125
+ <BlurView {...BlurPresets.dark} style={styles.sheet}>
126
+ {children}
127
+ </BlurView>
128
+ ```
129
+
130
+ or pass the name via the `preset` prop, still overridable per-field:
131
+
132
+ ```tsx
133
+ <BlurView preset="dark" blurRadius={30} style={styles.sheet}>
134
+ {children}
135
+ </BlurView>
136
+ ```
137
+
138
+ Available presets: `ultraThin`, `thin`, `regular`, `thick`, `chrome`, `light`, `dark`. These are
139
+ deterministic approximations of common material "weights" (inspired by, but not measured against,
140
+ iOS system materials) — see `src/presets.ts` for the exact values and rationale. Because
141
+ `blurRadius`/`saturation`/`overlayColor` are calibrated cross-platform, any preset renders the same
142
+ on iOS and Android.
143
+
144
+ ## Static vs. live mode
145
+
146
+ - **`mode="static"`** (default): capture happens once, then the blurred result is a cheap static
147
+ presentation with no ongoing per-frame cost — measured at **0 rendered frames over 6s of idle
148
+ time** on Android (docs/HARDENING_REPORT.md). Use this for bottom sheets, modals, and any
149
+ backdrop that only changes on discrete events (call `refresh()` after those events).
150
+ - **`mode="live"`** continuously recaptures the backdrop, throttled to `maxFps` (default 30) and
151
+ paused automatically when the view isn't visible. Use this for blur over actively scrolling or
152
+ animating content (e.g. a blurred header above a scrolling list). It costs strictly more than
153
+ static — see [Performance](docs/PERFORMANCE.md) for measured numbers and when the extra cost is
154
+ and isn't worth it, and note iOS live is the more expensive of the two platforms' live paths.
155
+
156
+ ## Parity
157
+
158
+ Measured on physical devices (Android Pixel 6a API 36, iOS iPhone 14 Pro) across 5 blur radii × 6
159
+ color/contrast fixtures × clear/tinted variants — 110 valid strip comparisons
160
+ (docs/CALIBRATION_REPORT.md):
161
+
162
+ | Metric | Worst observed | Notes |
163
+ |---|---|---|
164
+ | Per-strip mean ΔE76 (color) | **0.90** | at σ=32dp on a full-frame luminance ramp (screen-edge capture-support truncation); every other strip ≤ 0.64 |
165
+ | Per-strip SSIM (structural similarity) | **0.983** | same worst-case strip; all others ≥ 0.995 |
166
+ | Edge-crossing displacement | ≤ 0.96 dp | sub-pixel on both device rasters |
167
+ | Overlay/saturation tint residual | ≤ 1.45 /255 | confirms the overlay and saturation math match on both platforms |
168
+ | Convolution color space | gamma-space on both | confirmed via a checkerboard gamma-midpoint discriminator |
169
+
170
+ The framing is deliberately "perceptually matched within documented tolerance," not pixel-identical
171
+ — exact pixel identity isn't guaranteed (and isn't the goal) across OS versions, GPU vendors, and
172
+ device scales. See docs/CALIBRATION_REPORT.md for the full methodology and per-fixture breakdown,
173
+ and docs/PIPELINE_SPEC.md for the locked pipeline both backends implement.
174
+
175
+ ## Known limitations & performance
176
+
177
+ - [docs/LIMITATIONS.md](docs/LIMITATIONS.md) — cross-window/Modal capture, surface-backed content
178
+ (video/camera/maps), static-mode staleness, iOS live cost, nested-blur semantics, `blurTarget`
179
+ status, and more.
180
+ - [docs/PERFORMANCE.md](docs/PERFORMANCE.md) — measured frame/GPU costs, static-vs-live guidance,
181
+ downsample/quality tuning, `maxFps` guidance.
182
+ - [docs/PIPELINE_SPEC.md](docs/PIPELINE_SPEC.md) — the normative, language-neutral pipeline spec
183
+ both native backends implement.
184
+ - [docs/CALIBRATION_REPORT.md](docs/CALIBRATION_REPORT.md) — the full parity measurement report.
185
+
186
+ ## Contributing
187
+
188
+ - [Development workflow](CONTRIBUTING.md#development-workflow)
189
+ - [Sending a pull request](CONTRIBUTING.md#sending-a-pull-request)
190
+ - [Code of conduct](CODE_OF_CONDUCT.md)
191
+
192
+ ## License
193
+
194
+ MIT
195
+
196
+ ---
197
+
198
+ Made with [create-react-native-library](https://github.com/callstack/react-native-builder-bob)
@@ -0,0 +1,57 @@
1
+ buildscript {
2
+ ext.ParityBlur = [
3
+ kotlinVersion: "2.0.21",
4
+ minSdkVersion: 24,
5
+ compileSdkVersion: 36
6
+ ]
7
+
8
+ ext.getExtOrDefault = { prop ->
9
+ if (rootProject.ext.has(prop)) {
10
+ return rootProject.ext.get(prop)
11
+ }
12
+
13
+ return ParityBlur[prop]
14
+ }
15
+
16
+ repositories {
17
+ google()
18
+ mavenCentral()
19
+ }
20
+
21
+ dependencies {
22
+ classpath "com.android.tools.build:gradle:8.7.2"
23
+ // noinspection DifferentKotlinGradleVersion
24
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}"
25
+ }
26
+ }
27
+
28
+
29
+ apply plugin: "com.android.library"
30
+ apply plugin: "kotlin-android"
31
+
32
+ apply plugin: "com.facebook.react"
33
+
34
+ android {
35
+ namespace "com.parityblur"
36
+
37
+ compileSdkVersion getExtOrDefault("compileSdkVersion")
38
+
39
+ defaultConfig {
40
+ minSdkVersion getExtOrDefault("minSdkVersion")
41
+ }
42
+
43
+ compileOptions {
44
+ sourceCompatibility JavaVersion.VERSION_17
45
+ targetCompatibility JavaVersion.VERSION_17
46
+ }
47
+ }
48
+
49
+ dependencies {
50
+ implementation "com.facebook.react:react-android"
51
+
52
+ // Minimal JVM unit-test setup for the Milestone 3 pipeline-fixture parity suite
53
+ // (android/src/test/java/com/parityblur/PipelineFixturesTest.kt). Pure JUnit4 -- the
54
+ // AndroidBlurCalibration/PipelineMath classes under test have no android.* imports, so no
55
+ // Robolectric/instrumentation is needed.
56
+ testImplementation "junit:junit:4.13.2"
57
+ }
@@ -0,0 +1,2 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ </manifest>
@@ -0,0 +1,82 @@
1
+ package com.parityblur
2
+
3
+ /**
4
+ * Locked calibration constants (docs/PIPELINE_SPEC.md §1-2, plan §5.2, §10, §12-13).
5
+ *
6
+ * Exact Kotlin mirror of `src/pipeline/constants.ts` + `src/pipeline/androidCalibration.ts`.
7
+ * This is the SINGLE home for the HWUI sigma<->radius relation and the other locked pipeline
8
+ * constants on the Android side (plan §45.10: do not hard-code Android conversion logic
9
+ * throughout the codebase). `android/src/test/.../PipelineFixturesTest.kt` asserts these
10
+ * functions match `test/pipeline-fixtures.json` within 1e-6.
11
+ *
12
+ * All math here is pure Double arithmetic with NO android.* imports, so it is testable as a
13
+ * plain JVM unit test without Robolectric.
14
+ */
15
+ object AndroidBlurCalibration {
16
+
17
+ /**
18
+ * HWUI RenderEffect Gaussian relation, verified exact on a Pixel 6a (API 36) in M0:
19
+ * `sigma = HWUI_SIGMA_SLOPE * radiusPlatform + HWUI_SIGMA_INTERCEPT`.
20
+ */
21
+ const val HWUI_SIGMA_SLOPE: Double = 0.57735
22
+ const val HWUI_SIGMA_INTERCEPT: Double = 0.5
23
+
24
+ /**
25
+ * Below this snapshot-domain sigma the inverse radius is non-positive, so we emit a no-blur
26
+ * passthrough instead of feeding a bogus radius to RenderEffect.createBlurEffect. Equal to
27
+ * HWUI_SIGMA_INTERCEPT by construction (radius 0 <=> sigma 0.5).
28
+ */
29
+ const val ANDROID_MIN_BLUR_SIGMA: Double = HWUI_SIGMA_INTERCEPT
30
+
31
+ /** Gaussian support multiplier for capture expansion (plan §7). */
32
+ const val CAPTURE_SUPPORT_K: Double = 3.0
33
+
34
+ /** Auto-downsample: keep sigmaSnapshot = sigmaPx / D >= this many snapshot px. */
35
+ const val MIN_SIGMA_SNAPSHOT: Double = 1.0
36
+
37
+ /**
38
+ * Auto-downsample: captures with area (device px^2) below this are capped at 2x.
39
+ * 256 * 256 device px.
40
+ */
41
+ const val SMALL_CAPTURE_AREA_PX: Double = 256.0 * 256.0
42
+
43
+ /** Auto-downsample: per-quality ceiling on the downsample factor (plan §13). */
44
+ const val QUALITY_MAX_DOWNSAMPLE_HIGH: Int = 2
45
+ const val QUALITY_MAX_DOWNSAMPLE_BALANCED: Int = 4
46
+ const val QUALITY_MAX_DOWNSAMPLE_PERFORMANCE: Int = 8
47
+
48
+ /** Rec. 709 luminance coefficients (plan §10), used by the saturation matrix. */
49
+ const val LUMA_R: Double = 0.2126
50
+ const val LUMA_G: Double = 0.7152
51
+ const val LUMA_B: Double = 0.0722
52
+
53
+ /**
54
+ * Result of [radiusForSigma]. [radiusPlatform] is always >= 0; it is 0 when [noBlur] is true.
55
+ * `radiusPlatform` is intentionally Double (not Float) so the pure-math mirror can be asserted
56
+ * against the fixture table within 1e-6 -- callers cast to Float only at the RenderEffect call
57
+ * site.
58
+ */
59
+ data class BlurParam(val noBlur: Boolean, val radiusPlatform: Double)
60
+
61
+ /**
62
+ * Forward relation, exposed for tests/verification: the sigma HWUI produces for a given
63
+ * platform radius.
64
+ */
65
+ fun sigmaForRadius(radiusPlatform: Double): Double =
66
+ HWUI_SIGMA_SLOPE * radiusPlatform + HWUI_SIGMA_INTERCEPT
67
+
68
+ /**
69
+ * Inverse relation: the platform radius required to achieve a target snapshot-domain sigma.
70
+ * Below the intercept this returns a no-blur result. Monotonically non-decreasing in
71
+ * sigmaSnapshot. Exact mirror of androidCalibration.ts `radiusForSigma`.
72
+ */
73
+ fun radiusForSigma(sigmaSnapshot: Double): BlurParam {
74
+ if (!sigmaSnapshot.isFinite() || sigmaSnapshot <= ANDROID_MIN_BLUR_SIGMA) {
75
+ return BlurParam(noBlur = true, radiusPlatform = 0.0)
76
+ }
77
+ return BlurParam(
78
+ noBlur = false,
79
+ radiusPlatform = (sigmaSnapshot - HWUI_SIGMA_INTERCEPT) / HWUI_SIGMA_SLOPE
80
+ )
81
+ }
82
+ }
@@ -0,0 +1,132 @@
1
+ package com.parityblur
2
+
3
+ import android.content.ComponentCallbacks2
4
+ import android.content.Context
5
+ import android.content.res.Configuration
6
+ import android.graphics.Bitmap
7
+ import android.util.Log
8
+ import android.view.View
9
+ import java.util.WeakHashMap
10
+
11
+ /**
12
+ * Process-wide blur engine (plan §4, §14.5, §26, §27).
13
+ *
14
+ * Created LAZILY on first eligible capture -- NEVER at class load / package init / module
15
+ * import. API<31 and no-blur-only [ParityBlurView] instances must never call [get] (plan §27,
16
+ * §45.1); enforcing that is the caller's responsibility (see ParityBlurView.isRealBlurSupported).
17
+ *
18
+ * Holds:
19
+ * - trim-memory registration (ComponentCallbacks2) that clears the bitmap pool under memory
20
+ * pressure (plan §26).
21
+ * - a small bitmap pool keyed by (width, height), capped in retained bytes (plan §14.5: a pool
22
+ * is justified here because the software capture path allocates a bitmap per capture --
23
+ * M0-REPORT "Plan amendments" #2).
24
+ * - a weak per-window registry of [WindowBlurContext] (plan §4), keyed by each window's root
25
+ * view so contexts disappear with their window and are never a source of leaks.
26
+ */
27
+ class BlurEngine private constructor() : ComponentCallbacks2 {
28
+
29
+ private val bitmapPool = HashMap<Long, MutableList<Bitmap>>()
30
+ private var pooledBytes = 0L
31
+
32
+ private val windowContexts = WeakHashMap<View, WindowBlurContext>()
33
+
34
+ @Synchronized
35
+ fun windowContextFor(rootView: View): WindowBlurContext {
36
+ return windowContexts.getOrPut(rootView) { WindowBlurContext(rootView) }
37
+ }
38
+
39
+ @Synchronized
40
+ fun releaseWindowContext(rootView: View) {
41
+ windowContexts.remove(rootView)
42
+ }
43
+
44
+ /** Acquire a pooled ARGB_8888 bitmap sized exactly (width x height), or allocate one. */
45
+ @Synchronized
46
+ fun acquireBitmap(width: Int, height: Int): Bitmap {
47
+ val key = poolKey(width, height)
48
+ val bucket = bitmapPool[key]
49
+ val reused = if (!bucket.isNullOrEmpty()) bucket.removeAt(bucket.size - 1) else null
50
+ if (reused != null && !reused.isRecycled) {
51
+ pooledBytes -= reused.allocationByteCount.toLong()
52
+ reused.eraseColor(0)
53
+ return reused
54
+ }
55
+ return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
56
+ }
57
+
58
+ /** Return a bitmap to the pool for reuse, capping total retained bytes (plan §14.5). */
59
+ @Synchronized
60
+ fun releaseBitmap(bitmap: Bitmap) {
61
+ if (bitmap.isRecycled) return
62
+ val size = bitmap.allocationByteCount.toLong()
63
+ if (pooledBytes + size > MAX_POOL_BYTES) {
64
+ // Do not grow unbounded and do not evict-and-thrash; just drop this one (plan §45.13:
65
+ // do not pool large resources without measured need -- keep the cap conservative).
66
+ return
67
+ }
68
+ val key = poolKey(bitmap.width, bitmap.height)
69
+ val bucket = bitmapPool.getOrPut(key) { mutableListOf() }
70
+ bucket.add(bitmap)
71
+ pooledBytes += size
72
+ }
73
+
74
+ @Synchronized
75
+ private fun clearPool() {
76
+ for (bucket in bitmapPool.values) {
77
+ for (bmp in bucket) if (!bmp.isRecycled) bmp.recycle()
78
+ bucket.clear()
79
+ }
80
+ bitmapPool.clear()
81
+ pooledBytes = 0L
82
+ }
83
+
84
+ // ------------------------------------------------------- ComponentCallbacks2 (plan §26)
85
+
86
+ @Suppress("DEPRECATION")
87
+ override fun onTrimMemory(level: Int) {
88
+ if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW ||
89
+ level == ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN
90
+ ) {
91
+ Log.d(TAG, "onTrimMemory($level): clearing bitmap pool ($pooledBytes bytes)")
92
+ clearPool()
93
+ }
94
+ }
95
+
96
+ override fun onConfigurationChanged(newConfig: Configuration) = Unit
97
+
98
+ @Suppress("OVERRIDE_DEPRECATION")
99
+ override fun onLowMemory() {
100
+ clearPool()
101
+ }
102
+
103
+ companion object {
104
+ private const val TAG = "ParityBlur.Engine"
105
+
106
+ /** Conservative retained-bitmap cap; snapshots are already downsampled (plan §14.5). */
107
+ private const val MAX_POOL_BYTES = 16L * 1024 * 1024 // 16 MiB
108
+
109
+ @Volatile
110
+ private var instance: BlurEngine? = null
111
+
112
+ /**
113
+ * Lazily create (or return) the process-wide engine. MUST be called only from the first
114
+ * eligible real-blur capture path -- never from class init, static fields, or fallback-only
115
+ * view code (plan §27, §45.1).
116
+ */
117
+ fun get(context: Context): BlurEngine {
118
+ instance?.let { return it }
119
+ synchronized(this) {
120
+ instance?.let { return it }
121
+ val created = BlurEngine()
122
+ context.applicationContext.registerComponentCallbacks(created)
123
+ instance = created
124
+ ParityBlurDebug.log { "engine-init" }
125
+ return created
126
+ }
127
+ }
128
+
129
+ private fun poolKey(width: Int, height: Int): Long =
130
+ (width.toLong() shl 32) or (height.toLong() and 0xffffffffL)
131
+ }
132
+ }
@@ -0,0 +1,24 @@
1
+ package com.parityblur
2
+
3
+ import android.util.Log
4
+
5
+ /**
6
+ * Milestone 7 laziness/lifecycle instrumentation (plan §42.4, §40).
7
+ *
8
+ * Gated behind [ENABLED], which defaults to `false` so a shipped build pays zero cost here (no
9
+ * fields mutated, no string built, no log call made -- the `inline` + constant-folded branch is
10
+ * dropped by the compiler). Flip to `true` only for local hardening runs to prove:
11
+ * - package import -> no engine init (nothing logs before the first eligible capture)
12
+ * - first eligible BlurView -> "engine-init" logs exactly once per process
13
+ * - live registration -> "scheduler-install"/"scheduler-uninstall" bracket the live window
14
+ * - detach -> "instance-release" logs for every ParityBlurView, live or static
15
+ */
16
+ internal object ParityBlurDebug {
17
+ const val ENABLED = false // debug-only laziness/lifecycle instrumentation (plan §42.4); flip true for local hardening runs
18
+
19
+ private const val TAG = "ParityBlur.Debug"
20
+
21
+ inline fun log(event: () -> String) {
22
+ if (ENABLED) Log.d(TAG, event())
23
+ }
24
+ }
@@ -0,0 +1,17 @@
1
+ package com.parityblur
2
+
3
+ import com.facebook.react.BaseReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.module.model.ReactModuleInfoProvider
7
+ import com.facebook.react.uimanager.ViewManager
8
+
9
+ class ParityBlurViewPackage : BaseReactPackage() {
10
+ override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
11
+ return listOf(ParityBlurViewManager())
12
+ }
13
+
14
+ override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? = null
15
+
16
+ override fun getReactModuleInfoProvider() = ReactModuleInfoProvider { emptyMap() }
17
+ }