@aguacerowx/react-native 0.0.50 → 0.0.52

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 (56) hide show
  1. package/android/src/main/java/com/aguacerowx/reactnative/SatelliteLayerView.java +11 -3
  2. package/android/src/main/java/com/aguacerowx/reactnative/WeatherFrameProcessorModule.java +315 -275
  3. package/ios/SatelliteLayerView.swift +11 -4
  4. package/ios/WeatherFrameProcessorModule.swift +222 -188
  5. package/lib/commonjs/WeatherLayerManager.js +112 -48
  6. package/lib/commonjs/WeatherLayerManager.js.map +1 -1
  7. package/lib/commonjs/aguaceroCoreDebugHooks.js +144 -0
  8. package/lib/commonjs/aguaceroCoreDebugHooks.js.map +1 -0
  9. package/lib/commonjs/aguaceroRnDebug.js +358 -0
  10. package/lib/commonjs/aguaceroRnDebug.js.map +1 -0
  11. package/lib/commonjs/gridCdnAuth.js +64 -0
  12. package/lib/commonjs/gridCdnAuth.js.map +1 -0
  13. package/lib/commonjs/index.js +50 -0
  14. package/lib/commonjs/index.js.map +1 -1
  15. package/lib/commonjs/nexrad/nexradAndroidController.js +38 -25
  16. package/lib/commonjs/nexrad/nexradAndroidController.js.map +1 -1
  17. package/lib/commonjs/nexrad/nexradDiag.js +31 -25
  18. package/lib/commonjs/nexrad/nexradDiag.js.map +1 -1
  19. package/lib/commonjs/satellite/satelliteAndroidController.js +24 -15
  20. package/lib/commonjs/satellite/satelliteAndroidController.js.map +1 -1
  21. package/lib/module/WeatherLayerManager.js +112 -48
  22. package/lib/module/WeatherLayerManager.js.map +1 -1
  23. package/lib/module/aguaceroCoreDebugHooks.js +136 -0
  24. package/lib/module/aguaceroCoreDebugHooks.js.map +1 -0
  25. package/lib/module/aguaceroRnDebug.js +341 -0
  26. package/lib/module/aguaceroRnDebug.js.map +1 -0
  27. package/lib/module/gridCdnAuth.js +56 -0
  28. package/lib/module/gridCdnAuth.js.map +1 -0
  29. package/lib/module/index.js +2 -0
  30. package/lib/module/index.js.map +1 -1
  31. package/lib/module/nexrad/nexradAndroidController.js +38 -25
  32. package/lib/module/nexrad/nexradAndroidController.js.map +1 -1
  33. package/lib/module/nexrad/nexradDiag.js +31 -25
  34. package/lib/module/nexrad/nexradDiag.js.map +1 -1
  35. package/lib/module/satellite/satelliteAndroidController.js +24 -15
  36. package/lib/module/satellite/satelliteAndroidController.js.map +1 -1
  37. package/lib/typescript/WeatherLayerManager.d.ts.map +1 -1
  38. package/lib/typescript/aguaceroCoreDebugHooks.d.ts +10 -0
  39. package/lib/typescript/aguaceroCoreDebugHooks.d.ts.map +1 -0
  40. package/lib/typescript/aguaceroRnDebug.d.ts +97 -0
  41. package/lib/typescript/aguaceroRnDebug.d.ts.map +1 -0
  42. package/lib/typescript/gridCdnAuth.d.ts +24 -0
  43. package/lib/typescript/gridCdnAuth.d.ts.map +1 -0
  44. package/lib/typescript/index.d.ts +2 -0
  45. package/lib/typescript/nexrad/nexradAndroidController.d.ts.map +1 -1
  46. package/lib/typescript/nexrad/nexradDiag.d.ts.map +1 -1
  47. package/lib/typescript/satellite/satelliteAndroidController.d.ts.map +1 -1
  48. package/package.json +1 -1
  49. package/src/WeatherLayerManager.js +2024 -1947
  50. package/src/aguaceroCoreDebugHooks.js +142 -0
  51. package/src/aguaceroRnDebug.js +335 -0
  52. package/src/gridCdnAuth.js +56 -0
  53. package/src/index.js +19 -7
  54. package/src/nexrad/nexradAndroidController.js +1078 -1068
  55. package/src/nexrad/nexradDiag.js +150 -144
  56. package/src/satellite/satelliteAndroidController.js +245 -236
@@ -299,9 +299,17 @@ public class SatelliteLayerView extends FrameLayout {
299
299
  reqBuilder.header("x-app-identifier", bundleId);
300
300
  }
301
301
  // Match WeatherFrameProcessorModule + AguaceroCore grid fetch (CloudFront allowlists on RN).
302
- if (gridRequestSiteOrigin != null && !gridRequestSiteOrigin.isEmpty()) {
303
- reqBuilder.header("Origin", gridRequestSiteOrigin);
304
- reqBuilder.header("Referer", gridRequestSiteOrigin + "/");
302
+ String gridOrigin = gridRequestSiteOrigin;
303
+ if (gridOrigin == null || gridOrigin.trim().isEmpty()) {
304
+ gridOrigin = "https://localhost";
305
+ }
306
+ gridOrigin = gridOrigin.trim();
307
+ while (gridOrigin.endsWith("/")) {
308
+ gridOrigin = gridOrigin.substring(0, gridOrigin.length() - 1);
309
+ }
310
+ if (!gridOrigin.isEmpty()) {
311
+ reqBuilder.header("Origin", gridOrigin);
312
+ reqBuilder.header("Referer", gridOrigin + "/");
305
313
  }
306
314
  Request req = reqBuilder.build();
307
315
  Call call = HTTP.newCall(req);
@@ -1,276 +1,316 @@
1
- package com.aguacerowx.reactnative;
2
-
3
- import androidx.annotation.NonNull;
4
- import android.util.Base64;
5
- import android.util.Log;
6
-
7
- import com.facebook.react.bridge.Arguments;
8
- import com.facebook.react.bridge.Promise;
9
- import com.facebook.react.bridge.ReactApplicationContext;
10
- import com.facebook.react.bridge.ReactContextBaseJavaModule;
11
- import com.facebook.react.bridge.ReactMethod;
12
- import com.facebook.react.bridge.ReadableMap;
13
- import com.facebook.react.bridge.WritableMap;
14
-
15
- import org.json.JSONObject;
16
-
17
- import java.io.File;
18
- import java.io.FileOutputStream;
19
- import java.io.IOException;
20
- import java.util.Collections;
21
- import java.util.Set;
22
- import java.util.concurrent.ConcurrentHashMap;
23
- import java.util.concurrent.ExecutorService;
24
- import java.util.concurrent.Executors;
25
- import java.util.concurrent.Semaphore;
26
- import java.util.concurrent.TimeUnit;
27
- import java.util.concurrent.atomic.AtomicInteger;
28
-
29
- import okhttp3.Call;
30
- import okhttp3.Callback;
31
- import okhttp3.ConnectionPool;
32
- import okhttp3.Dispatcher;
33
- import okhttp3.OkHttpClient;
34
- import okhttp3.Request;
35
- import okhttp3.Response;
36
- import okhttp3.ResponseBody;
37
-
38
- public class WeatherFrameProcessorModule extends ReactContextBaseJavaModule {
39
-
40
- private static final String TAG = "AguaceroWX_Perf";
41
- private final AtomicInteger currentRunToken = new AtomicInteger(0);
42
- private final ReactApplicationContext reactContext;
43
- private final OkHttpClient httpClient;
44
-
45
- // FIX 1: Use an ExecutorService instead of "new Thread()".
46
- // CachedThreadPool reuses threads and scales down when idle.
47
- private final ExecutorService taskExecutor = Executors.newCachedThreadPool();
48
-
49
- // FIX 2: Reduce concurrency to 4.
50
- // 6 concurrent JSON parsings + Base64 decodings causes GC thrashing on mobile.
51
- // 4 is the "sweet spot" for throughput vs memory.
52
- private static final int MAX_CONCURRENT = 4;
53
- private final Semaphore semaphore = new Semaphore(MAX_CONCURRENT, true);
54
-
55
- private final Set<Call> activeCalls = Collections.newSetFromMap(new ConcurrentHashMap<>());
56
-
57
- WeatherFrameProcessorModule(ReactApplicationContext context) {
58
- super(context);
59
- this.reactContext = context;
60
-
61
- Dispatcher dispatcher = new Dispatcher();
62
- dispatcher.setMaxRequests(64);
63
- dispatcher.setMaxRequestsPerHost(32);
64
-
65
- this.httpClient = new OkHttpClient.Builder()
66
- .dispatcher(dispatcher)
67
- .connectionPool(new ConnectionPool(MAX_CONCURRENT, 30, TimeUnit.SECONDS))
68
- .connectTimeout(15, TimeUnit.SECONDS)
69
- .readTimeout(30, TimeUnit.SECONDS)
70
- .writeTimeout(15, TimeUnit.SECONDS)
71
- .build();
72
- }
73
-
74
- @ReactMethod
75
- public void cancelAllFrames() {
76
- currentRunToken.incrementAndGet();
77
-
78
- for (Call call : activeCalls) {
79
- if (!call.isCanceled()) {
80
- call.cancel();
81
- }
82
- }
83
- activeCalls.clear();
84
-
85
- // Release all semaphore permits to unblock the Executor queue
86
- semaphore.drainPermits();
87
- semaphore.release(MAX_CONCURRENT);
88
- }
89
-
90
- @NonNull
91
- @Override
92
- public String getName() {
93
- return "WeatherFrameProcessorModule";
94
- }
95
-
96
- @ReactMethod
97
- public void processFrame(ReadableMap options, Promise promise) {
98
- final int taskToken = currentRunToken.get();
99
-
100
- final String urlString = options.getString("url");
101
- final String apiKey = options.getString("apiKey");
102
- final String bundleId = options.hasKey("bundleId") ? options.getString("bundleId") : null;
103
- final String gridRequestSiteOrigin =
104
- options.hasKey("gridRequestSiteOrigin") && !options.isNull("gridRequestSiteOrigin")
105
- ? options.getString("gridRequestSiteOrigin")
106
- : null;
107
-
108
- // FIX 3: Submit to Executor instead of spinning up a raw OS thread
109
- taskExecutor.execute(() -> {
110
- try {
111
- if (currentRunToken.get() != taskToken) {
112
- promise.reject("E_CANCELLED", "Weather frame fetch superseded");
113
- return;
114
- }
115
-
116
- // This blocks the Executor thread (which is fine, it's a pool)
117
- // until a slot is available
118
- semaphore.acquire();
119
-
120
- if (currentRunToken.get() != taskToken) {
121
- semaphore.release();
122
- promise.reject("E_CANCELLED", "Weather frame fetch superseded");
123
- return;
124
- }
125
-
126
- Request.Builder requestBuilder = new Request.Builder()
127
- .url(urlString)
128
- .header("x-api-key", apiKey);
129
- if (bundleId != null && !bundleId.isEmpty()) {
130
- requestBuilder.header("x-app-identifier", bundleId);
131
- }
132
- if (gridRequestSiteOrigin != null) {
133
- String origin = gridRequestSiteOrigin.trim();
134
- while (origin.endsWith("/")) {
135
- origin = origin.substring(0, origin.length() - 1);
136
- }
137
- if (!origin.isEmpty()) {
138
- requestBuilder.header("Origin", origin);
139
- requestBuilder.header("Referer", origin + "/");
140
- }
141
- }
142
- Request request = requestBuilder.build();
143
-
144
- Call call = httpClient.newCall(request);
145
- activeCalls.add(call);
146
-
147
- call.enqueue(new Callback() {
148
- @Override
149
- public void onFailure(@NonNull Call call, @NonNull IOException e) {
150
- activeCalls.remove(call);
151
- semaphore.release(); // IMPORTANT: Release immediately on fail
152
-
153
- if (currentRunToken.get() != taskToken) {
154
- promise.reject("E_CANCELLED", "Weather frame fetch superseded");
155
- return;
156
- }
157
- if (!call.isCanceled()) {
158
- Log.e(TAG, "[FrameProcessor] Network failure: " + e.getMessage());
159
- promise.reject("NETWORK_ERROR", e.getMessage(), e);
160
- } else {
161
- promise.reject("E_CANCELLED", "Weather frame fetch superseded");
162
- }
163
- }
164
-
165
- @Override
166
- public void onResponse(@NonNull Call call, @NonNull Response response) {
167
- activeCalls.remove(call);
168
- // NOTE: We hold the semaphore until processing is done to prevent
169
- // CPU saturation from JSON parsing.
170
-
171
- // Check token immediately
172
- if (currentRunToken.get() != taskToken) {
173
- response.close();
174
- semaphore.release();
175
- promise.reject("E_CANCELLED", "Weather frame fetch superseded");
176
- return;
177
- }
178
-
179
- FileOutputStream fos = null;
180
- try {
181
- if (!response.isSuccessful()) {
182
- promise.reject("HTTP_ERROR", "HTTP " + response.code());
183
- return;
184
- }
185
-
186
- ResponseBody body = response.body();
187
- if (body == null) {
188
- promise.reject("NO_DATA", "Response body was null");
189
- return;
190
- }
191
-
192
- byte[] bodyBytes = body.bytes();
193
-
194
- if (currentRunToken.get() != taskToken) {
195
- promise.reject("E_CANCELLED", "Weather frame fetch superseded");
196
- return;
197
- }
198
-
199
- // JSON parse
200
- String jsonString = new String(bodyBytes);
201
- bodyBytes = null; // FIX 4: Nullify immediately to help GC
202
-
203
- JSONObject jsonResponse = new JSONObject(jsonString);
204
- jsonString = null; // FIX 4: Nullify immediately
205
-
206
- if (!jsonResponse.has("data") || !jsonResponse.has("encoding")) {
207
- promise.reject("INVALID_RESPONSE", "Invalid API response structure");
208
- return;
209
- }
210
-
211
- String b64CompressedData = jsonResponse.getString("data");
212
- JSONObject encoding = jsonResponse.getJSONObject("encoding");
213
-
214
- // Don't need the huge JSON object anymore
215
- jsonResponse = null;
216
-
217
- // Base64 decode
218
- byte[] compressedData = Base64.decode(b64CompressedData, Base64.DEFAULT);
219
- b64CompressedData = null; // FIX 4: Nullify immediately
220
-
221
- if (currentRunToken.get() != taskToken) {
222
- promise.reject("E_CANCELLED", "Weather frame fetch superseded");
223
- return;
224
- }
225
-
226
- // Disk write
227
- File cacheDir = reactContext.getCacheDir();
228
- // Use a unique name based on hash
229
- String fileName = "frame_" + urlString.hashCode() + ".zst";
230
- File dataFile = new File(cacheDir, fileName);
231
-
232
- fos = new FileOutputStream(dataFile);
233
- fos.write(compressedData);
234
- fos.flush();
235
-
236
- WritableMap responseMap = Arguments.createMap();
237
- responseMap.putString("filePath", dataFile.getAbsolutePath());
238
- responseMap.putDouble("scale", encoding.getDouble("scale"));
239
- responseMap.putDouble("offset", encoding.getDouble("offset"));
240
- responseMap.putDouble("missing", encoding.getDouble("missing_quantized"));
241
- if (encoding.has("scale_type")) {
242
- responseMap.putString("scaleType", encoding.getString("scale_type"));
243
- }
244
-
245
- if (currentRunToken.get() != taskToken) {
246
- promise.reject("E_CANCELLED", "Weather frame fetch superseded");
247
- return;
248
- }
249
-
250
- promise.resolve(responseMap);
251
-
252
- } catch (Exception e) {
253
- if (currentRunToken.get() == taskToken) {
254
- Log.e(TAG, "[FrameProcessor] Error: " + e.getMessage(), e);
255
- promise.reject("PROCESSING_ERROR", e.getMessage(), e);
256
- }
257
- } finally {
258
- // FIX 5: Ensure resources are closed and semaphore is released
259
- if (fos != null) {
260
- try { fos.close(); } catch (IOException ex) { /* ignore */ }
261
- }
262
- response.close();
263
- semaphore.release();
264
- }
265
- }
266
- });
267
-
268
- } catch (InterruptedException e) {
269
- Thread.currentThread().interrupt();
270
- // If interrupted while waiting for semaphore, just exit
271
- } catch (Exception e) {
272
- promise.reject("EXECUTION_ERROR", e.getMessage());
273
- }
274
- });
275
- }
1
+ package com.aguacerowx.reactnative;
2
+
3
+ import androidx.annotation.NonNull;
4
+ import android.util.Base64;
5
+ import android.util.Log;
6
+
7
+ import com.facebook.react.bridge.Arguments;
8
+ import com.facebook.react.bridge.Promise;
9
+ import com.facebook.react.bridge.ReactApplicationContext;
10
+ import com.facebook.react.bridge.ReactContextBaseJavaModule;
11
+ import com.facebook.react.bridge.ReactMethod;
12
+ import com.facebook.react.bridge.ReadableMap;
13
+ import com.facebook.react.bridge.WritableMap;
14
+
15
+ import org.json.JSONObject;
16
+
17
+ import java.io.File;
18
+ import java.io.FileOutputStream;
19
+ import java.io.IOException;
20
+ import java.util.Collections;
21
+ import java.util.Set;
22
+ import java.util.concurrent.ConcurrentHashMap;
23
+ import java.util.concurrent.ExecutorService;
24
+ import java.util.concurrent.Executors;
25
+ import java.util.concurrent.Semaphore;
26
+ import java.util.concurrent.TimeUnit;
27
+ import java.util.concurrent.atomic.AtomicInteger;
28
+
29
+ import okhttp3.Call;
30
+ import okhttp3.Callback;
31
+ import okhttp3.ConnectionPool;
32
+ import okhttp3.Dispatcher;
33
+ import okhttp3.OkHttpClient;
34
+ import okhttp3.Request;
35
+ import okhttp3.Response;
36
+ import okhttp3.ResponseBody;
37
+
38
+ public class WeatherFrameProcessorModule extends ReactContextBaseJavaModule {
39
+
40
+ private static final String TAG = "AguaceroWX_Perf";
41
+ /** CloudFront returns 403 without Origin; RN has no browser default. */
42
+ private static final String FALLBACK_GRID_REQUEST_SITE_ORIGIN = "https://localhost";
43
+ private final AtomicInteger currentRunToken = new AtomicInteger(0);
44
+ private final ReactApplicationContext reactContext;
45
+ private final OkHttpClient httpClient;
46
+
47
+ // FIX 1: Use an ExecutorService instead of "new Thread()".
48
+ // CachedThreadPool reuses threads and scales down when idle.
49
+ private final ExecutorService taskExecutor = Executors.newCachedThreadPool();
50
+
51
+ // FIX 2: Reduce concurrency to 4.
52
+ // 6 concurrent JSON parsings + Base64 decodings causes GC thrashing on mobile.
53
+ // 4 is the "sweet spot" for throughput vs memory.
54
+ private static final int MAX_CONCURRENT = 4;
55
+ private final Semaphore semaphore = new Semaphore(MAX_CONCURRENT, true);
56
+
57
+ private final Set<Call> activeCalls = Collections.newSetFromMap(new ConcurrentHashMap<>());
58
+
59
+ WeatherFrameProcessorModule(ReactApplicationContext context) {
60
+ super(context);
61
+ this.reactContext = context;
62
+
63
+ Dispatcher dispatcher = new Dispatcher();
64
+ dispatcher.setMaxRequests(64);
65
+ dispatcher.setMaxRequestsPerHost(32);
66
+
67
+ this.httpClient = new OkHttpClient.Builder()
68
+ .dispatcher(dispatcher)
69
+ .connectionPool(new ConnectionPool(MAX_CONCURRENT, 30, TimeUnit.SECONDS))
70
+ .connectTimeout(15, TimeUnit.SECONDS)
71
+ .readTimeout(30, TimeUnit.SECONDS)
72
+ .writeTimeout(15, TimeUnit.SECONDS)
73
+ .build();
74
+ }
75
+
76
+ @ReactMethod
77
+ public void cancelAllFrames() {
78
+ currentRunToken.incrementAndGet();
79
+
80
+ for (Call call : activeCalls) {
81
+ if (!call.isCanceled()) {
82
+ call.cancel();
83
+ }
84
+ }
85
+ activeCalls.clear();
86
+
87
+ // Release all semaphore permits to unblock the Executor queue
88
+ semaphore.drainPermits();
89
+ semaphore.release(MAX_CONCURRENT);
90
+ }
91
+
92
+ @NonNull
93
+ @Override
94
+ public String getName() {
95
+ return "WeatherFrameProcessorModule";
96
+ }
97
+
98
+ @ReactMethod
99
+ public void processFrame(ReadableMap options, Promise promise) {
100
+ final int taskToken = currentRunToken.get();
101
+
102
+ final String urlString = options.getString("url");
103
+ final String apiKey = options.getString("apiKey");
104
+ final String bundleId = options.hasKey("bundleId") ? options.getString("bundleId") : null;
105
+ final String gridRequestSiteOrigin =
106
+ options.hasKey("gridRequestSiteOrigin") && !options.isNull("gridRequestSiteOrigin")
107
+ ? options.getString("gridRequestSiteOrigin")
108
+ : null;
109
+ final boolean debug =
110
+ options.hasKey("debug") && !options.isNull("debug") && options.getBoolean("debug");
111
+
112
+ // FIX 3: Submit to Executor instead of spinning up a raw OS thread
113
+ taskExecutor.execute(() -> {
114
+ try {
115
+ if (currentRunToken.get() != taskToken) {
116
+ promise.reject("E_CANCELLED", "Weather frame fetch superseded");
117
+ return;
118
+ }
119
+
120
+ // This blocks the Executor thread (which is fine, it's a pool)
121
+ // until a slot is available
122
+ semaphore.acquire();
123
+
124
+ if (currentRunToken.get() != taskToken) {
125
+ semaphore.release();
126
+ promise.reject("E_CANCELLED", "Weather frame fetch superseded");
127
+ return;
128
+ }
129
+
130
+ Request.Builder requestBuilder = new Request.Builder()
131
+ .url(urlString)
132
+ .header("x-api-key", apiKey);
133
+ if (bundleId != null && !bundleId.isEmpty()) {
134
+ requestBuilder.header("x-app-identifier", bundleId);
135
+ }
136
+ String originSource = gridRequestSiteOrigin;
137
+ if (originSource == null || originSource.trim().isEmpty()) {
138
+ originSource = FALLBACK_GRID_REQUEST_SITE_ORIGIN;
139
+ }
140
+ String origin = originSource.trim();
141
+ while (origin.endsWith("/")) {
142
+ origin = origin.substring(0, origin.length() - 1);
143
+ }
144
+ if (!origin.isEmpty()) {
145
+ requestBuilder.header("Origin", origin);
146
+ requestBuilder.header("Referer", origin + "/");
147
+ }
148
+ Request request = requestBuilder.build();
149
+
150
+ if (debug) {
151
+ String redactedUrl = urlString.replaceAll("([?&])apiKey=[^&]*", "$1apiKey=(redacted)");
152
+ Log.w("AguaceroRN", "[debug][FrameProcessor] REQUEST "
153
+ + "method=GET url=" + redactedUrl
154
+ + " apiKey.len=" + (apiKey != null ? apiKey.length() : 0)
155
+ + " bundleId=" + (bundleId != null && !bundleId.isEmpty() ? bundleId : "(none)")
156
+ + " gridOrigin=" + (gridRequestSiteOrigin != null ? gridRequestSiteOrigin : "(none)")
157
+ + " headers=[x-api-key, "
158
+ + (bundleId != null && !bundleId.isEmpty() ? "x-app-identifier, " : "")
159
+ + (gridRequestSiteOrigin != null ? "Origin, Referer" : "no Origin/Referer")
160
+ + "]");
161
+ }
162
+
163
+ Call call = httpClient.newCall(request);
164
+ activeCalls.add(call);
165
+
166
+ call.enqueue(new Callback() {
167
+ @Override
168
+ public void onFailure(@NonNull Call call, @NonNull IOException e) {
169
+ activeCalls.remove(call);
170
+ semaphore.release(); // IMPORTANT: Release immediately on fail
171
+
172
+ if (currentRunToken.get() != taskToken) {
173
+ promise.reject("E_CANCELLED", "Weather frame fetch superseded");
174
+ return;
175
+ }
176
+ if (!call.isCanceled()) {
177
+ Log.e(TAG, "[FrameProcessor] Network failure: " + e.getMessage());
178
+ promise.reject("NETWORK_ERROR", e.getMessage(), e);
179
+ } else {
180
+ promise.reject("E_CANCELLED", "Weather frame fetch superseded");
181
+ }
182
+ }
183
+
184
+ @Override
185
+ public void onResponse(@NonNull Call call, @NonNull Response response) {
186
+ activeCalls.remove(call);
187
+ // NOTE: We hold the semaphore until processing is done to prevent
188
+ // CPU saturation from JSON parsing.
189
+
190
+ // Check token immediately
191
+ if (currentRunToken.get() != taskToken) {
192
+ response.close();
193
+ semaphore.release();
194
+ promise.reject("E_CANCELLED", "Weather frame fetch superseded");
195
+ return;
196
+ }
197
+
198
+ FileOutputStream fos = null;
199
+ try {
200
+ if (!response.isSuccessful()) {
201
+ if (debug) {
202
+ String bodyPeek = "";
203
+ try {
204
+ ResponseBody peekBody = response.peekBody(512);
205
+ if (peekBody != null) {
206
+ bodyPeek = peekBody.string();
207
+ }
208
+ } catch (Exception ignored) {
209
+ bodyPeek = "(unreadable)";
210
+ }
211
+ String redactedUrl = urlString.replaceAll("([?&])apiKey=[^&]*", "$1apiKey=(redacted)");
212
+ Log.e("AguaceroRN", "[debug][FrameProcessor] HTTP_ERROR code=" + response.code()
213
+ + " url=" + redactedUrl
214
+ + " apiKey.len=" + (apiKey != null ? apiKey.length() : 0)
215
+ + " bundleId=" + (bundleId != null && !bundleId.isEmpty() ? bundleId : "(none)")
216
+ + " gridOrigin=" + (gridRequestSiteOrigin != null ? gridRequestSiteOrigin : "(none)")
217
+ + " body=" + bodyPeek);
218
+ if (response.code() == 403) {
219
+ Log.e("AguaceroRN", "[debug][FrameProcessor] 403 hint: verify API key, allowlisted bundleId (x-app-identifier), and gridRequestSiteOrigin (Origin/Referer) match your web app.");
220
+ }
221
+ }
222
+ promise.reject("HTTP_ERROR", "HTTP " + response.code());
223
+ return;
224
+ }
225
+
226
+ ResponseBody body = response.body();
227
+ if (body == null) {
228
+ promise.reject("NO_DATA", "Response body was null");
229
+ return;
230
+ }
231
+
232
+ byte[] bodyBytes = body.bytes();
233
+
234
+ if (currentRunToken.get() != taskToken) {
235
+ promise.reject("E_CANCELLED", "Weather frame fetch superseded");
236
+ return;
237
+ }
238
+
239
+ // JSON parse
240
+ String jsonString = new String(bodyBytes);
241
+ bodyBytes = null; // FIX 4: Nullify immediately to help GC
242
+
243
+ JSONObject jsonResponse = new JSONObject(jsonString);
244
+ jsonString = null; // FIX 4: Nullify immediately
245
+
246
+ if (!jsonResponse.has("data") || !jsonResponse.has("encoding")) {
247
+ promise.reject("INVALID_RESPONSE", "Invalid API response structure");
248
+ return;
249
+ }
250
+
251
+ String b64CompressedData = jsonResponse.getString("data");
252
+ JSONObject encoding = jsonResponse.getJSONObject("encoding");
253
+
254
+ // Don't need the huge JSON object anymore
255
+ jsonResponse = null;
256
+
257
+ // Base64 decode
258
+ byte[] compressedData = Base64.decode(b64CompressedData, Base64.DEFAULT);
259
+ b64CompressedData = null; // FIX 4: Nullify immediately
260
+
261
+ if (currentRunToken.get() != taskToken) {
262
+ promise.reject("E_CANCELLED", "Weather frame fetch superseded");
263
+ return;
264
+ }
265
+
266
+ // Disk write
267
+ File cacheDir = reactContext.getCacheDir();
268
+ // Use a unique name based on hash
269
+ String fileName = "frame_" + urlString.hashCode() + ".zst";
270
+ File dataFile = new File(cacheDir, fileName);
271
+
272
+ fos = new FileOutputStream(dataFile);
273
+ fos.write(compressedData);
274
+ fos.flush();
275
+
276
+ WritableMap responseMap = Arguments.createMap();
277
+ responseMap.putString("filePath", dataFile.getAbsolutePath());
278
+ responseMap.putDouble("scale", encoding.getDouble("scale"));
279
+ responseMap.putDouble("offset", encoding.getDouble("offset"));
280
+ responseMap.putDouble("missing", encoding.getDouble("missing_quantized"));
281
+ if (encoding.has("scale_type")) {
282
+ responseMap.putString("scaleType", encoding.getString("scale_type"));
283
+ }
284
+
285
+ if (currentRunToken.get() != taskToken) {
286
+ promise.reject("E_CANCELLED", "Weather frame fetch superseded");
287
+ return;
288
+ }
289
+
290
+ promise.resolve(responseMap);
291
+
292
+ } catch (Exception e) {
293
+ if (currentRunToken.get() == taskToken) {
294
+ Log.e(TAG, "[FrameProcessor] Error: " + e.getMessage(), e);
295
+ promise.reject("PROCESSING_ERROR", e.getMessage(), e);
296
+ }
297
+ } finally {
298
+ // FIX 5: Ensure resources are closed and semaphore is released
299
+ if (fos != null) {
300
+ try { fos.close(); } catch (IOException ex) { /* ignore */ }
301
+ }
302
+ response.close();
303
+ semaphore.release();
304
+ }
305
+ }
306
+ });
307
+
308
+ } catch (InterruptedException e) {
309
+ Thread.currentThread().interrupt();
310
+ // If interrupted while waiting for semaphore, just exit
311
+ } catch (Exception e) {
312
+ promise.reject("EXECUTION_ERROR", e.getMessage());
313
+ }
314
+ });
315
+ }
276
316
  }