@glassly/crust 0.1.0-dev.1

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 (67) hide show
  1. package/README.md +41 -0
  2. package/android/build.gradle +146 -0
  3. package/android/src/internal/AndroidManifest.xml +9 -0
  4. package/android/src/internal/java/com/glassly/crust/receivers/CaptionsTesterIncidentReceiver.kt +46 -0
  5. package/android/src/main/AndroidManifest.xml +33 -0
  6. package/android/src/main/java/com/glassly/crust/CrustModule.kt +1074 -0
  7. package/android/src/main/java/com/glassly/crust/CrustView.kt +30 -0
  8. package/android/src/main/java/com/glassly/crust/heading/HeadingManager.kt +150 -0
  9. package/android/src/main/java/com/glassly/crust/jsc/JSCDispatcher.kt +189 -0
  10. package/android/src/main/java/com/glassly/crust/jsc/JSCPolyfillBridge.kt +287 -0
  11. package/android/src/main/java/com/glassly/crust/jsc/JSCRuntime.kt +593 -0
  12. package/android/src/main/java/com/glassly/crust/navigation/NavigationManager.kt +1466 -0
  13. package/android/src/main/java/com/glassly/crust/services/NotificationListener.kt +502 -0
  14. package/android/src/main/java/com/glassly/crust/services/NotificationProcessBridge.kt +222 -0
  15. package/android/src/main/java/com/glassly/crust/utils/ImageProcessor.java +452 -0
  16. package/android/src/main/java/com/glassly/crust/utils/VideoStabilizer.kt +556 -0
  17. package/android/src/main/res/values/strings.xml +3 -0
  18. package/app.plugin.js +3 -0
  19. package/build/Crust.types.d.ts +153 -0
  20. package/build/Crust.types.d.ts.map +1 -0
  21. package/build/Crust.types.js +2 -0
  22. package/build/Crust.types.js.map +1 -0
  23. package/build/CrustModule.d.ts +186 -0
  24. package/build/CrustModule.d.ts.map +1 -0
  25. package/build/CrustModule.js +4 -0
  26. package/build/CrustModule.js.map +1 -0
  27. package/build/CrustModule.web.d.ts +39 -0
  28. package/build/CrustModule.web.d.ts.map +1 -0
  29. package/build/CrustModule.web.js +69 -0
  30. package/build/CrustModule.web.js.map +1 -0
  31. package/build/CrustView.d.ts +4 -0
  32. package/build/CrustView.d.ts.map +1 -0
  33. package/build/CrustView.js +8 -0
  34. package/build/CrustView.js.map +1 -0
  35. package/build/CrustView.web.d.ts +3 -0
  36. package/build/CrustView.web.d.ts.map +1 -0
  37. package/build/CrustView.web.js +5 -0
  38. package/build/CrustView.web.js.map +1 -0
  39. package/build/index.d.ts +4 -0
  40. package/build/index.d.ts.map +1 -0
  41. package/build/index.js +6 -0
  42. package/build/index.js.map +1 -0
  43. package/expo-module.config.json +9 -0
  44. package/ios/Crust.podspec +65 -0
  45. package/ios/CrustModule.swift +784 -0
  46. package/ios/CrustView.swift +38 -0
  47. package/ios/Resources/startup.js +804 -0
  48. package/ios/Source/JSCDispatcher.swift +226 -0
  49. package/ios/Source/JSCPolyfillBridge.swift +378 -0
  50. package/ios/Source/JSCRuntime.swift +674 -0
  51. package/ios/Source/utils/ImageProcessor.swift +392 -0
  52. package/ios/Source/utils/SystemGestures.swift +53 -0
  53. package/ios/Source/utils/VideoStabilizer.swift +374 -0
  54. package/ios/heading/HeadingManager.swift +74 -0
  55. package/ios/navigation/NavPayloads.swift +62 -0
  56. package/ios/navigation/NavigationManager.swift +751 -0
  57. package/package.json +69 -0
  58. package/plugin/build/index.d.ts +21 -0
  59. package/plugin/build/index.js +25 -0
  60. package/plugin/build/withAndroid.d.ts +2 -0
  61. package/plugin/build/withAndroid.js +133 -0
  62. package/src/Crust.types.ts +162 -0
  63. package/src/CrustModule.ts +194 -0
  64. package/src/CrustModule.web.ts +72 -0
  65. package/src/CrustView.tsx +10 -0
  66. package/src/CrustView.web.tsx +9 -0
  67. package/src/index.ts +5 -0
@@ -0,0 +1,222 @@
1
+ package com.glassly.crust.services
2
+
3
+ import android.app.ActivityManager
4
+ import android.app.Application
5
+ import android.content.BroadcastReceiver
6
+ import android.content.ComponentName
7
+ import android.content.Context
8
+ import android.content.Intent
9
+ import android.content.IntentFilter
10
+ import android.os.Build
11
+ import android.os.Process
12
+ import android.util.Log
13
+ import java.io.File
14
+
15
+ /**
16
+ * Identifies the lightweight process used by NotificationListenerService.
17
+ *
18
+ * The host app's generated MainApplication calls this before initializing
19
+ * React Native. Keep this object free of Expo/React dependencies so loading it
20
+ * cannot recreate the cold-start work that the process split avoids.
21
+ */
22
+ object NotificationProcess {
23
+ const val SUFFIX = ":notif"
24
+
25
+ @JvmStatic
26
+ fun isCurrent(context: Context): Boolean = currentName(context).endsWith(SUFFIX)
27
+
28
+ private fun currentName(context: Context): String {
29
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
30
+ return Application.getProcessName()
31
+ }
32
+
33
+ val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
34
+ activityManager
35
+ ?.runningAppProcesses
36
+ ?.firstOrNull { it.pid == Process.myPid() }
37
+ ?.processName
38
+ ?.let { return it }
39
+
40
+ return runCatching {
41
+ File("/proc/self/cmdline").inputStream().bufferedReader().use { it.readText().trimEnd('\u0000') }
42
+ }.getOrDefault(context.packageName)
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Signature-protected, process-safe handoff from the notification process to
48
+ * the React Native process. The receiver is dynamic, so sending while the app
49
+ * runtime is dead does not start the expensive main process.
50
+ */
51
+ object NotificationProcessBridge {
52
+ private const val TAG = "CrustNotificationBridge"
53
+ private const val POSTED_SUFFIX = ".crust.PHONE_NOTIFICATION"
54
+ private const val DISMISSED_SUFFIX = ".crust.PHONE_NOTIFICATION_DISMISSED"
55
+ private const val CONFIG_SUFFIX = ".crust.NOTIFICATION_CONFIG"
56
+ private const val PERMISSION_SUFFIX = ".permission.CRUST_NOTIFICATION_BRIDGE"
57
+
58
+ private const val EXTRA_NOTIFICATION_ID = "notificationId"
59
+ private const val EXTRA_NOTIFICATION_KEY = "notificationKey"
60
+ private const val EXTRA_PACKAGE_NAME = "packageName"
61
+ private const val EXTRA_APP = "app"
62
+ private const val EXTRA_TITLE = "title"
63
+ private const val EXTRA_CONTENT = "content"
64
+ private const val EXTRA_PRIORITY = "priority"
65
+ private const val EXTRA_TIMESTAMP = "timestamp"
66
+ private const val EXTRA_LISTENER_ENABLED = "listenerEnabled"
67
+ private const val EXTRA_BLOCKLIST = "blocklist"
68
+ private const val EXTRA_REQUEST_REBIND = "requestRebind"
69
+
70
+ fun register(
71
+ context: Context,
72
+ onEvent: (String, Map<String, Any>) -> Unit,
73
+ ): BroadcastReceiver {
74
+ val applicationContext = context.applicationContext
75
+ val receiver =
76
+ object : BroadcastReceiver() {
77
+ override fun onReceive(receiveContext: Context, intent: Intent) {
78
+ when (intent.action) {
79
+ postedAction(receiveContext) -> {
80
+ onEvent(
81
+ "phone_notification",
82
+ mapOf(
83
+ EXTRA_NOTIFICATION_ID to intent.getStringExtra(EXTRA_NOTIFICATION_ID).orEmpty(),
84
+ EXTRA_APP to intent.getStringExtra(EXTRA_APP).orEmpty(),
85
+ EXTRA_TITLE to intent.getStringExtra(EXTRA_TITLE).orEmpty(),
86
+ EXTRA_CONTENT to intent.getStringExtra(EXTRA_CONTENT).orEmpty(),
87
+ EXTRA_PRIORITY to intent.getIntExtra(EXTRA_PRIORITY, 0),
88
+ EXTRA_TIMESTAMP to intent.getLongExtra(EXTRA_TIMESTAMP, 0L),
89
+ EXTRA_PACKAGE_NAME to intent.getStringExtra(EXTRA_PACKAGE_NAME).orEmpty(),
90
+ ),
91
+ )
92
+ }
93
+ dismissedAction(receiveContext) -> {
94
+ onEvent(
95
+ "phone_notification_dismissed",
96
+ mapOf(
97
+ EXTRA_NOTIFICATION_ID to intent.getStringExtra(EXTRA_NOTIFICATION_ID).orEmpty(),
98
+ EXTRA_NOTIFICATION_KEY to intent.getStringExtra(EXTRA_NOTIFICATION_KEY).orEmpty(),
99
+ EXTRA_PACKAGE_NAME to intent.getStringExtra(EXTRA_PACKAGE_NAME).orEmpty(),
100
+ ),
101
+ )
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ val filter =
108
+ IntentFilter().apply {
109
+ addAction(postedAction(applicationContext))
110
+ addAction(dismissedAction(applicationContext))
111
+ }
112
+ val permission = bridgePermission(applicationContext)
113
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
114
+ applicationContext.registerReceiver(
115
+ receiver,
116
+ filter,
117
+ permission,
118
+ null,
119
+ Context.RECEIVER_NOT_EXPORTED,
120
+ )
121
+ } else {
122
+ @Suppress("UnspecifiedRegisterReceiverFlag")
123
+ applicationContext.registerReceiver(receiver, filter, permission, null)
124
+ }
125
+ return receiver
126
+ }
127
+
128
+ fun unregister(context: Context, receiver: BroadcastReceiver) {
129
+ runCatching { context.applicationContext.unregisterReceiver(receiver) }
130
+ .onFailure { Log.w(TAG, "Could not unregister notification bridge", it) }
131
+ }
132
+
133
+ fun emitPosted(
134
+ context: Context,
135
+ notificationKey: String,
136
+ packageName: String,
137
+ appName: String,
138
+ title: String,
139
+ text: String,
140
+ timestamp: Long,
141
+ priority: Int,
142
+ ) {
143
+ val intent =
144
+ Intent(postedAction(context))
145
+ .setPackage(context.packageName)
146
+ .putExtra(EXTRA_NOTIFICATION_ID, "$packageName-$notificationKey")
147
+ .putExtra(EXTRA_APP, appName)
148
+ .putExtra(EXTRA_TITLE, title.ifEmpty { appName })
149
+ .putExtra(EXTRA_CONTENT, text)
150
+ .putExtra(EXTRA_PRIORITY, priority)
151
+ .putExtra(EXTRA_TIMESTAMP, timestamp)
152
+ .putExtra(EXTRA_PACKAGE_NAME, packageName)
153
+ context.sendBroadcast(intent, bridgePermission(context))
154
+ }
155
+
156
+ fun emitDismissed(context: Context, notificationKey: String, packageName: String) {
157
+ val intent =
158
+ Intent(dismissedAction(context))
159
+ .setPackage(context.packageName)
160
+ .putExtra(EXTRA_NOTIFICATION_ID, "$packageName-$notificationKey")
161
+ .putExtra(EXTRA_NOTIFICATION_KEY, notificationKey)
162
+ .putExtra(EXTRA_PACKAGE_NAME, packageName)
163
+ context.sendBroadcast(intent, bridgePermission(context))
164
+ }
165
+
166
+ fun sendConfig(
167
+ context: Context,
168
+ listenerEnabled: Boolean,
169
+ blocklist: Set<String>,
170
+ requestRebind: Boolean,
171
+ ) {
172
+ val intent =
173
+ Intent(configAction(context))
174
+ .setComponent(ComponentName(context, NotificationConfigReceiver::class.java))
175
+ .putExtra(EXTRA_LISTENER_ENABLED, listenerEnabled)
176
+ .putStringArrayListExtra(EXTRA_BLOCKLIST, ArrayList(blocklist))
177
+ .putExtra(EXTRA_REQUEST_REBIND, requestRebind)
178
+ context.sendBroadcast(intent, bridgePermission(context))
179
+ }
180
+
181
+ internal fun readConfig(intent: Intent): NotificationConfigUpdate {
182
+ return NotificationConfigUpdate(
183
+ listenerEnabled = intent.getBooleanExtra(EXTRA_LISTENER_ENABLED, false),
184
+ blocklist = intent.getStringArrayListExtra(EXTRA_BLOCKLIST)?.toSet() ?: emptySet(),
185
+ requestRebind = intent.getBooleanExtra(EXTRA_REQUEST_REBIND, false),
186
+ )
187
+ }
188
+
189
+ internal fun isConfigAction(context: Context, intent: Intent): Boolean = intent.action == configAction(context)
190
+
191
+ private fun postedAction(context: Context) = context.packageName + POSTED_SUFFIX
192
+
193
+ private fun dismissedAction(context: Context) = context.packageName + DISMISSED_SUFFIX
194
+
195
+ private fun configAction(context: Context) = context.packageName + CONFIG_SUFFIX
196
+
197
+ private fun bridgePermission(context: Context) = context.packageName + PERMISSION_SUFFIX
198
+ }
199
+
200
+ internal data class NotificationConfigUpdate(
201
+ val listenerEnabled: Boolean,
202
+ val blocklist: Set<String>,
203
+ val requestRebind: Boolean,
204
+ )
205
+
206
+ /** Persists and applies config inside the isolated notification process. */
207
+ class NotificationConfigReceiver : BroadcastReceiver() {
208
+ override fun onReceive(context: Context, intent: Intent) {
209
+ if (!NotificationProcessBridge.isConfigAction(context, intent)) return
210
+ val config = NotificationProcessBridge.readConfig(intent)
211
+
212
+ // SharedPreferences caches are per-process. Persist the payload here before
213
+ // touching the live instance so a listener recreated by requestRebind reads
214
+ // the same config rather than this process's stale cache.
215
+ NotificationListener.persistConfig(context, config.listenerEnabled, config.blocklist)
216
+ NotificationListener.applyConfigToExisting(config.listenerEnabled, config.blocklist)
217
+
218
+ if (config.listenerEnabled && config.requestRebind) {
219
+ NotificationListener.requestListenerRebind(context)
220
+ }
221
+ }
222
+ }
@@ -0,0 +1,452 @@
1
+ package com.glassly.crust.utils;
2
+
3
+ import android.graphics.Bitmap;
4
+ import android.graphics.BitmapFactory;
5
+ import android.util.Log;
6
+
7
+ import java.io.File;
8
+ import java.io.FileOutputStream;
9
+ import java.io.IOException;
10
+
11
+ /**
12
+ * Image processor for gallery photos synced from Glassly glasses.
13
+ * Applies lens distortion correction and color correction to improve
14
+ * photo quality before saving to the user's camera roll.
15
+ */
16
+ public class ImageProcessor {
17
+ private static final String TAG = "ImageProcessor";
18
+
19
+ // Brown-Conrady distortion coefficients for the Mentra Live camera
20
+ // (Sony sensor, 118-degree FOV fisheye lens)
21
+ // Calibrated from chessboard photos at 3264x2448 native sensor resolution.
22
+ // Brown-Conrady lens distortion coefficients
23
+ private static final double K1 = -0.10; // Radial distortion (barrel)
24
+ private static final double K2 = 0.02; // Radial distortion (higher order)
25
+ private static final double P1 = 0.0; // Tangential distortion
26
+ private static final double P2 = 0.0; // Tangential distortion
27
+
28
+ // --- Color pipeline tuning parameters ---
29
+
30
+ // Tone curve anchor points (X values fixed at 0.0, 0.25, 0.50, 0.75, 1.0)
31
+ // Y values control the S-curve shape in linear space.
32
+ // Slight shadow lift (0.02) for low-light camera, full whites, punchy midtone contrast.
33
+ private static final double[] TONE_CURVE_Y = {0.02, 0.20, 0.52, 0.82, 1.0};
34
+
35
+ // Vibrance: selective saturation boost for desaturated colors (0.0 = off, 1.0 = max)
36
+ private static final float VIBRANCE_AMOUNT = 0.45f;
37
+
38
+ // Color correction matrix (3x4: RGB coefficients + bias per channel)
39
+ // Adjusts warmth/white balance to compensate for the glasses camera's color cast.
40
+ private static final float CM_RR = 1.06f, CM_RG = 0.02f, CM_RB = -0.01f, CM_R_BIAS = 5.0f / 255.0f;
41
+ private static final float CM_GR = 0.01f, CM_GG = 1.04f, CM_GB = -0.01f, CM_G_BIAS = 3.0f / 255.0f;
42
+ private static final float CM_BR = -0.02f, CM_BG = 0.01f, CM_BB = 1.02f, CM_B_BIAS = 0.0f;
43
+
44
+ // sRGB gamma decode LUT: sRGB byte (0-255) -> linear float (0.0-1.0)
45
+ // iOS CIFilters operate in linear space internally; this matches that behavior.
46
+ private static final float[] LINEARIZE_LUT = buildLinearizeLut();
47
+
48
+ // sRGB gamma encode LUT: linear value (0-4095, Q12 fixed-point) -> sRGB byte (0-255)
49
+ private static final int[] DELINEARIZE_LUT = buildDelinearizeLut();
50
+ private static final int DELIN_LUT_SIZE = 4096;
51
+
52
+ private static float[] buildLinearizeLut() {
53
+ float[] lut = new float[256];
54
+ for (int i = 0; i < 256; i++) {
55
+ double v = i / 255.0;
56
+ if (v <= 0.04045) {
57
+ lut[i] = (float) (v / 12.92);
58
+ } else {
59
+ lut[i] = (float) Math.pow((v + 0.055) / 1.055, 2.4);
60
+ }
61
+ }
62
+ return lut;
63
+ }
64
+
65
+ private static int[] buildDelinearizeLut() {
66
+ int[] lut = new int[DELIN_LUT_SIZE];
67
+ for (int i = 0; i < DELIN_LUT_SIZE; i++) {
68
+ double v = (double) i / (DELIN_LUT_SIZE - 1);
69
+ double s;
70
+ if (v <= 0.0031308) {
71
+ s = v * 12.92;
72
+ } else {
73
+ s = 1.055 * Math.pow(v, 1.0 / 2.4) - 0.055;
74
+ }
75
+ lut[i] = Math.max(0, Math.min(255, (int) (s * 255 + 0.5)));
76
+ }
77
+ return lut;
78
+ }
79
+
80
+ // Tone-curve LUT operating in linear space [0.0-1.0] -> [0.0-1.0]
81
+ // Piecewise-linear matching iOS CIToneCurve anchor points.
82
+ // Input/output are linear-light values, not gamma-encoded.
83
+ private static final float[] TONE_LUT_LINEAR = buildToneLutLinear();
84
+
85
+ private static float[] buildToneLutLinear() {
86
+ float[] lut = new float[DELIN_LUT_SIZE];
87
+ double[] ax = {0.0, 0.25, 0.50, 0.75, 1.0};
88
+ double[] ay = TONE_CURVE_Y;
89
+ for (int i = 0; i < DELIN_LUT_SIZE; i++) {
90
+ double x = (double) i / (DELIN_LUT_SIZE - 1);
91
+ double y;
92
+ if (x <= ax[1]) {
93
+ y = ay[0] + (ay[1] - ay[0]) * (x - ax[0]) / (ax[1] - ax[0]);
94
+ } else if (x <= ax[2]) {
95
+ y = ay[1] + (ay[2] - ay[1]) * (x - ax[1]) / (ax[2] - ax[1]);
96
+ } else if (x <= ax[3]) {
97
+ y = ay[2] + (ay[3] - ay[2]) * (x - ax[2]) / (ax[3] - ax[2]);
98
+ } else {
99
+ y = ay[3] + (ay[4] - ay[3]) * (x - ax[3]) / (ax[4] - ax[3]);
100
+ }
101
+ lut[i] = (float) Math.max(0.0, Math.min(1.0, y));
102
+ }
103
+ return lut;
104
+ }
105
+
106
+ /** Look up a tone-curve value for a linear float input. */
107
+ private static float toneCurveLookup(float linearVal) {
108
+ int idx = Math.max(0, Math.min(DELIN_LUT_SIZE - 1, (int) (linearVal * (DELIN_LUT_SIZE - 1) + 0.5f)));
109
+ return TONE_LUT_LINEAR[idx];
110
+ }
111
+
112
+ /** Convert a linear [0,1] float back to sRGB byte via LUT. */
113
+ private static int toSrgbByte(float linear) {
114
+ int idx = Math.max(0, Math.min(DELIN_LUT_SIZE - 1, (int) (linear * (DELIN_LUT_SIZE - 1) + 0.5f)));
115
+ return DELINEARIZE_LUT[idx];
116
+ }
117
+
118
+ // Precomputed LUT for lens correction (lazily initialized).
119
+ // Stored as fixed-point Q8 (multiply by 256) for sub-pixel bilinear interpolation.
120
+ private static int[] sRemapXQ8;
121
+ private static int[] sRemapYQ8;
122
+ private static int sLutWidth;
123
+ private static int sLutHeight;
124
+
125
+ /**
126
+ * Process a gallery image with the specified corrections.
127
+ *
128
+ * @param inputPath Path to the input JPEG file
129
+ * @param outputPath Path to write the processed JPEG
130
+ * @param lensCorrection Whether to apply barrel distortion correction
131
+ * @param colorCorrection Whether to apply color/white balance correction
132
+ * @param jpegQuality JPEG output quality (1-100)
133
+ * @return processing time in milliseconds, or -1 on failure
134
+ */
135
+ public static long process(String inputPath, String outputPath,
136
+ boolean lensCorrection, boolean colorCorrection,
137
+ int jpegQuality) {
138
+ long startTime = System.currentTimeMillis();
139
+
140
+ try {
141
+ // Decode the input image
142
+ BitmapFactory.Options opts = new BitmapFactory.Options();
143
+ opts.inMutable = !lensCorrection; // Need mutable only for color correction without lens
144
+ Bitmap src = BitmapFactory.decodeFile(inputPath, opts);
145
+ if (src == null) {
146
+ Log.e(TAG, "Failed to decode image: " + inputPath);
147
+ return -1;
148
+ }
149
+
150
+ int w = src.getWidth();
151
+ int h = src.getHeight();
152
+ Log.d(TAG, "Processing image: " + w + "x" + h
153
+ + " lens=" + lensCorrection + " color=" + colorCorrection);
154
+
155
+ Bitmap result = src;
156
+
157
+ // Step 1: Lens distortion correction
158
+ if (lensCorrection) {
159
+ result = applyLensCorrection(src, w, h);
160
+ if (result != src) {
161
+ src.recycle();
162
+ }
163
+ }
164
+
165
+ // Step 2: Tone mapping + vibrance + color correction in linear space
166
+ if (colorCorrection) {
167
+ Bitmap colorCorrected = applyColorPipeline(result);
168
+ if (colorCorrected != result) {
169
+ result.recycle();
170
+ result = colorCorrected;
171
+ }
172
+ }
173
+
174
+ // Write output JPEG
175
+ File outFile = new File(outputPath);
176
+ try (FileOutputStream fos = new FileOutputStream(outFile)) {
177
+ result.compress(Bitmap.CompressFormat.JPEG, jpegQuality, fos);
178
+ }
179
+
180
+ result.recycle();
181
+
182
+ long elapsed = System.currentTimeMillis() - startTime;
183
+ Log.d(TAG, "Image processing complete in " + elapsed + "ms -> " + outputPath);
184
+ return elapsed;
185
+
186
+ } catch (Exception e) {
187
+ Log.e(TAG, "Image processing failed", e);
188
+ return -1;
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Apply Brown-Conrady lens distortion correction using a precomputed LUT
194
+ * with bilinear interpolation for sub-pixel accuracy.
195
+ */
196
+ private static Bitmap applyLensCorrection(Bitmap src, int w, int h) {
197
+ // Build or reuse the remapping LUT
198
+ if (sRemapXQ8 == null || sLutWidth != w || sLutHeight != h) {
199
+ buildRemapLut(w, h);
200
+ }
201
+
202
+ // Read source pixels
203
+ int[] srcPixels = new int[w * h];
204
+ src.getPixels(srcPixels, 0, w, 0, 0, w, h);
205
+
206
+ // Apply remapping with bilinear interpolation
207
+ int[] dstPixels = new int[w * h];
208
+ int maxX = w - 1;
209
+ int maxY = h - 1;
210
+
211
+ for (int i = 0; i < w * h; i++) {
212
+ int sxQ8 = sRemapXQ8[i];
213
+ int syQ8 = sRemapYQ8[i];
214
+
215
+ // Integer part (floor)
216
+ int x0 = sxQ8 >> 8;
217
+ int y0 = syQ8 >> 8;
218
+
219
+ if (x0 < 0 || x0 >= maxX || y0 < 0 || y0 >= maxY) {
220
+ // Out of bounds — black pixel
221
+ dstPixels[i] = 0xFF000000;
222
+ continue;
223
+ }
224
+
225
+ // Fractional part (0-255)
226
+ int fx = sxQ8 & 0xFF;
227
+ int fy = syQ8 & 0xFF;
228
+ int ifx = 256 - fx;
229
+ int ify = 256 - fy;
230
+
231
+ // Four source pixels
232
+ int idx00 = y0 * w + x0;
233
+ int p00 = srcPixels[idx00];
234
+ int p10 = srcPixels[idx00 + 1];
235
+ int p01 = srcPixels[idx00 + w];
236
+ int p11 = srcPixels[idx00 + w + 1];
237
+
238
+ // Bilinear blend per channel
239
+ int r = (ifx * ify * ((p00 >> 16) & 0xFF) + fx * ify * ((p10 >> 16) & 0xFF)
240
+ + ifx * fy * ((p01 >> 16) & 0xFF) + fx * fy * ((p11 >> 16) & 0xFF)) >> 16;
241
+ int g = (ifx * ify * ((p00 >> 8) & 0xFF) + fx * ify * ((p10 >> 8) & 0xFF)
242
+ + ifx * fy * ((p01 >> 8) & 0xFF) + fx * fy * ((p11 >> 8) & 0xFF)) >> 16;
243
+ int b = (ifx * ify * (p00 & 0xFF) + fx * ify * (p10 & 0xFF)
244
+ + ifx * fy * (p01 & 0xFF) + fx * fy * (p11 & 0xFF)) >> 16;
245
+
246
+ dstPixels[i] = 0xFF000000 | (r << 16) | (g << 8) | b;
247
+ }
248
+
249
+ Bitmap dst = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
250
+ dst.setPixels(dstPixels, 0, w, 0, 0, w, h);
251
+ return dst;
252
+ }
253
+
254
+ /**
255
+ * Build the remapping LUT for lens distortion correction.
256
+ * Coordinates are stored as Q8 fixed-point (value * 256) to support
257
+ * sub-pixel bilinear interpolation without floating point per-pixel.
258
+ */
259
+ private static synchronized void buildRemapLut(int w, int h) {
260
+ Log.d(TAG, "Building lens correction LUT for " + w + "x" + h);
261
+ long t0 = System.currentTimeMillis();
262
+
263
+ int[] remapXQ8 = new int[w * h];
264
+ int[] remapYQ8 = new int[w * h];
265
+
266
+ double cx = w / 2.0;
267
+ double cy = h / 2.0;
268
+ double norm = Math.sqrt(cx * cx + cy * cy);
269
+
270
+ for (int y = 0; y < h; y++) {
271
+ for (int x = 0; x < w; x++) {
272
+ double xn = (x - cx) / norm;
273
+ double yn = (y - cy) / norm;
274
+ double r2 = xn * xn + yn * yn;
275
+ double r4 = r2 * r2;
276
+
277
+ double radial = 1.0 + K1 * r2 + K2 * r4;
278
+ double xd = xn * radial + 2 * P1 * xn * yn + P2 * (r2 + 2 * xn * xn);
279
+ double yd = yn * radial + P1 * (r2 + 2 * yn * yn) + 2 * P2 * xn * yn;
280
+
281
+ // Store as Q8 fixed-point for bilinear interpolation
282
+ int idx = y * w + x;
283
+ remapXQ8[idx] = (int) ((xd * norm + cx) * 256);
284
+ remapYQ8[idx] = (int) ((yd * norm + cy) * 256);
285
+ }
286
+ }
287
+
288
+ sRemapXQ8 = remapXQ8;
289
+ sRemapYQ8 = remapYQ8;
290
+ sLutWidth = w;
291
+ sLutHeight = h;
292
+
293
+ Log.d(TAG, "LUT built in " + (System.currentTimeMillis() - t0) + "ms");
294
+ }
295
+
296
+ /**
297
+ * Apply tone mapping + vibrance + color correction in a single pass,
298
+ * all in linear light space to match iOS CIFilter behavior.
299
+ *
300
+ * Pipeline per pixel:
301
+ * 1. sRGB gamma decode (byte → linear float via LUT)
302
+ * 2. Tone curve (piecewise-linear S-curve in linear space)
303
+ * 3. Vibrance (luminance-based selective saturation boost)
304
+ * 4. Color correction matrix (warmth/white balance)
305
+ * 5. sRGB gamma encode (linear float → byte via LUT)
306
+ */
307
+ private static Bitmap applyColorPipeline(Bitmap src) {
308
+ int w = src.getWidth(), h = src.getHeight();
309
+ int[] pixels = new int[w * h];
310
+ src.getPixels(pixels, 0, w, 0, 0, w, h);
311
+
312
+ for (int i = 0; i < pixels.length; i++) {
313
+ // 1. Linearize sRGB → linear
314
+ float lr = LINEARIZE_LUT[(pixels[i] >> 16) & 0xFF];
315
+ float lg = LINEARIZE_LUT[(pixels[i] >> 8) & 0xFF];
316
+ float lb = LINEARIZE_LUT[pixels[i] & 0xFF];
317
+
318
+ // 2a. Tone curve in linear space
319
+ lr = toneCurveLookup(lr);
320
+ lg = toneCurveLookup(lg);
321
+ lb = toneCurveLookup(lb);
322
+
323
+ // 2b. Vibrance — luminance-based, stays in linear space (no HSV round-trip)
324
+ // Matches CIVibrance behavior: selectively boosts desaturated colors
325
+ float lum = 0.2126f * lr + 0.7152f * lg + 0.0722f * lb;
326
+ float maxC = Math.max(lr, Math.max(lg, lb));
327
+ float minC = Math.min(lr, Math.min(lg, lb));
328
+ float sat = (maxC > 0.0001f) ? (maxC - minC) / maxC : 0f;
329
+ float amount = VIBRANCE_AMOUNT * (1.0f - sat); // boost desaturated more
330
+ lr = lr + (lr - lum) * amount;
331
+ lg = lg + (lg - lum) * amount;
332
+ lb = lb + (lb - lum) * amount;
333
+
334
+ // 2c. Color correction matrix in linear space
335
+ float cr = CM_RR * lr + CM_RG * lg + CM_RB * lb + CM_R_BIAS;
336
+ float cg = CM_GR * lr + CM_GG * lg + CM_GB * lb + CM_G_BIAS;
337
+ float cb = CM_BR * lr + CM_BG * lg + CM_BB * lb + CM_B_BIAS;
338
+
339
+ // Clamp to [0, 1]
340
+ cr = Math.max(0f, Math.min(1f, cr));
341
+ cg = Math.max(0f, Math.min(1f, cg));
342
+ cb = Math.max(0f, Math.min(1f, cb));
343
+
344
+ // 5. Encode linear → sRGB
345
+ int outR = toSrgbByte(cr);
346
+ int outG = toSrgbByte(cg);
347
+ int outB = toSrgbByte(cb);
348
+
349
+ pixels[i] = 0xFF000000 | (outR << 16) | (outG << 8) | outB;
350
+ }
351
+
352
+ Bitmap dst = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
353
+ dst.setPixels(pixels, 0, w, 0, 0, w, h);
354
+ return dst;
355
+ }
356
+
357
+ /**
358
+ * Merge 3 exposure-bracketed images into a single HDR result using
359
+ * simple exposure fusion (Mertens' method approximation).
360
+ *
361
+ * @param underPath Path to the underexposed image (EV-2)
362
+ * @param normalPath Path to the normally exposed image (EV0)
363
+ * @param overPath Path to the overexposed image (EV+2)
364
+ * @param outputPath Path to write the merged HDR result
365
+ * @param jpegQuality JPEG output quality
366
+ * @return processing time in ms, or -1 on failure
367
+ */
368
+ public static long mergeHdr(String underPath, String normalPath, String overPath,
369
+ String outputPath, int jpegQuality) {
370
+ long startTime = System.currentTimeMillis();
371
+
372
+ try {
373
+ Bitmap under = BitmapFactory.decodeFile(underPath);
374
+ Bitmap normal = BitmapFactory.decodeFile(normalPath);
375
+ Bitmap over = BitmapFactory.decodeFile(overPath);
376
+
377
+ if (under == null || normal == null || over == null) {
378
+ Log.e(TAG, "Failed to decode one or more HDR bracket images");
379
+ return -1;
380
+ }
381
+
382
+ int w = normal.getWidth();
383
+ int h = normal.getHeight();
384
+
385
+ // Ensure all images are the same size
386
+ if (under.getWidth() != w || under.getHeight() != h
387
+ || over.getWidth() != w || over.getHeight() != h) {
388
+ Log.w(TAG, "HDR bracket images have different sizes, resizing");
389
+ under = Bitmap.createScaledBitmap(under, w, h, true);
390
+ over = Bitmap.createScaledBitmap(over, w, h, true);
391
+ }
392
+
393
+ int[] underPixels = new int[w * h];
394
+ int[] normalPixels = new int[w * h];
395
+ int[] overPixels = new int[w * h];
396
+ under.getPixels(underPixels, 0, w, 0, 0, w, h);
397
+ normal.getPixels(normalPixels, 0, w, 0, 0, w, h);
398
+ over.getPixels(overPixels, 0, w, 0, 0, w, h);
399
+
400
+ int[] resultPixels = new int[w * h];
401
+
402
+ // Simple exposure fusion: weight each pixel by how well-exposed it is
403
+ // Well-exposed = closer to mid-gray (128). Clip recovery from under/over.
404
+ for (int i = 0; i < w * h; i++) {
405
+ int uR = (underPixels[i] >> 16) & 0xFF;
406
+ int uG = (underPixels[i] >> 8) & 0xFF;
407
+ int uB = underPixels[i] & 0xFF;
408
+ float uLum = (uR + uG + uB) / 3.0f / 255.0f;
409
+ float uWeight = 4.0f * uLum * (1.0f - uLum) + 0.01f; // Gaussian-ish around 0.5
410
+
411
+ int nR = (normalPixels[i] >> 16) & 0xFF;
412
+ int nG = (normalPixels[i] >> 8) & 0xFF;
413
+ int nB = normalPixels[i] & 0xFF;
414
+ float nLum = (nR + nG + nB) / 3.0f / 255.0f;
415
+ float nWeight = 4.0f * nLum * (1.0f - nLum) + 0.01f;
416
+
417
+ int oR = (overPixels[i] >> 16) & 0xFF;
418
+ int oG = (overPixels[i] >> 8) & 0xFF;
419
+ int oB = overPixels[i] & 0xFF;
420
+ float oLum = (oR + oG + oB) / 3.0f / 255.0f;
421
+ float oWeight = 4.0f * oLum * (1.0f - oLum) + 0.01f;
422
+
423
+ float total = uWeight + nWeight + oWeight;
424
+ int rOut = Math.min(255, Math.round((uR * uWeight + nR * nWeight + oR * oWeight) / total));
425
+ int gOut = Math.min(255, Math.round((uG * uWeight + nG * nWeight + oG * oWeight) / total));
426
+ int bOut = Math.min(255, Math.round((uB * uWeight + nB * nWeight + oB * oWeight) / total));
427
+
428
+ resultPixels[i] = 0xFF000000 | (rOut << 16) | (gOut << 8) | bOut;
429
+ }
430
+
431
+ under.recycle();
432
+ normal.recycle();
433
+ over.recycle();
434
+
435
+ Bitmap result = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
436
+ result.setPixels(resultPixels, 0, w, 0, 0, w, h);
437
+
438
+ try (FileOutputStream fos = new FileOutputStream(new File(outputPath))) {
439
+ result.compress(Bitmap.CompressFormat.JPEG, jpegQuality, fos);
440
+ }
441
+ result.recycle();
442
+
443
+ long elapsed = System.currentTimeMillis() - startTime;
444
+ Log.d(TAG, "HDR merge complete in " + elapsed + "ms -> " + outputPath);
445
+ return elapsed;
446
+
447
+ } catch (Exception e) {
448
+ Log.e(TAG, "HDR merge failed", e);
449
+ return -1;
450
+ }
451
+ }
452
+ }