@aguacerowx/react-native 0.0.51 → 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 (41) 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 -311
  3. package/ios/SatelliteLayerView.swift +11 -4
  4. package/ios/WeatherFrameProcessorModule.swift +222 -219
  5. package/lib/commonjs/WeatherLayerManager.js +61 -45
  6. package/lib/commonjs/WeatherLayerManager.js.map +1 -1
  7. package/lib/commonjs/aguaceroRnDebug.js +8 -1
  8. package/lib/commonjs/aguaceroRnDebug.js.map +1 -1
  9. package/lib/commonjs/gridCdnAuth.js +64 -0
  10. package/lib/commonjs/gridCdnAuth.js.map +1 -0
  11. package/lib/commonjs/index.js +13 -0
  12. package/lib/commonjs/index.js.map +1 -1
  13. package/lib/commonjs/nexrad/nexradAndroidController.js +25 -25
  14. package/lib/commonjs/nexrad/nexradAndroidController.js.map +1 -1
  15. package/lib/commonjs/nexrad/nexradDiag.js +24 -24
  16. package/lib/commonjs/satellite/satelliteAndroidController.js +15 -15
  17. package/lib/module/WeatherLayerManager.js +61 -45
  18. package/lib/module/WeatherLayerManager.js.map +1 -1
  19. package/lib/module/aguaceroRnDebug.js +8 -1
  20. package/lib/module/aguaceroRnDebug.js.map +1 -1
  21. package/lib/module/gridCdnAuth.js +56 -0
  22. package/lib/module/gridCdnAuth.js.map +1 -0
  23. package/lib/module/index.js +1 -0
  24. package/lib/module/index.js.map +1 -1
  25. package/lib/module/nexrad/nexradAndroidController.js +25 -25
  26. package/lib/module/nexrad/nexradAndroidController.js.map +1 -1
  27. package/lib/module/nexrad/nexradDiag.js +24 -24
  28. package/lib/module/satellite/satelliteAndroidController.js +15 -15
  29. package/lib/typescript/WeatherLayerManager.d.ts.map +1 -1
  30. package/lib/typescript/aguaceroRnDebug.d.ts.map +1 -1
  31. package/lib/typescript/gridCdnAuth.d.ts +24 -0
  32. package/lib/typescript/gridCdnAuth.d.ts.map +1 -0
  33. package/lib/typescript/index.d.ts +1 -0
  34. package/package.json +1 -1
  35. package/src/WeatherLayerManager.js +2024 -2004
  36. package/src/aguaceroRnDebug.js +8 -1
  37. package/src/gridCdnAuth.js +56 -0
  38. package/src/index.js +19 -15
  39. package/src/nexrad/nexradAndroidController.js +1078 -1078
  40. package/src/nexrad/nexradDiag.js +150 -150
  41. package/src/satellite/satelliteAndroidController.js +245 -245
@@ -1,312 +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
- final boolean debug =
108
- options.hasKey("debug") && !options.isNull("debug") && options.getBoolean("debug");
109
-
110
- // FIX 3: Submit to Executor instead of spinning up a raw OS thread
111
- taskExecutor.execute(() -> {
112
- try {
113
- if (currentRunToken.get() != taskToken) {
114
- promise.reject("E_CANCELLED", "Weather frame fetch superseded");
115
- return;
116
- }
117
-
118
- // This blocks the Executor thread (which is fine, it's a pool)
119
- // until a slot is available
120
- semaphore.acquire();
121
-
122
- if (currentRunToken.get() != taskToken) {
123
- semaphore.release();
124
- promise.reject("E_CANCELLED", "Weather frame fetch superseded");
125
- return;
126
- }
127
-
128
- Request.Builder requestBuilder = new Request.Builder()
129
- .url(urlString)
130
- .header("x-api-key", apiKey);
131
- if (bundleId != null && !bundleId.isEmpty()) {
132
- requestBuilder.header("x-app-identifier", bundleId);
133
- }
134
- if (gridRequestSiteOrigin != null) {
135
- String origin = gridRequestSiteOrigin.trim();
136
- while (origin.endsWith("/")) {
137
- origin = origin.substring(0, origin.length() - 1);
138
- }
139
- if (!origin.isEmpty()) {
140
- requestBuilder.header("Origin", origin);
141
- requestBuilder.header("Referer", origin + "/");
142
- }
143
- }
144
- Request request = requestBuilder.build();
145
-
146
- if (debug) {
147
- String redactedUrl = urlString.replaceAll("([?&])apiKey=[^&]*", "$1apiKey=(redacted)");
148
- Log.w("AguaceroRN", "[debug][FrameProcessor] REQUEST "
149
- + "method=GET url=" + redactedUrl
150
- + " apiKey.len=" + (apiKey != null ? apiKey.length() : 0)
151
- + " bundleId=" + (bundleId != null && !bundleId.isEmpty() ? bundleId : "(none)")
152
- + " gridOrigin=" + (gridRequestSiteOrigin != null ? gridRequestSiteOrigin : "(none)")
153
- + " headers=[x-api-key, "
154
- + (bundleId != null && !bundleId.isEmpty() ? "x-app-identifier, " : "")
155
- + (gridRequestSiteOrigin != null ? "Origin, Referer" : "no Origin/Referer")
156
- + "]");
157
- }
158
-
159
- Call call = httpClient.newCall(request);
160
- activeCalls.add(call);
161
-
162
- call.enqueue(new Callback() {
163
- @Override
164
- public void onFailure(@NonNull Call call, @NonNull IOException e) {
165
- activeCalls.remove(call);
166
- semaphore.release(); // IMPORTANT: Release immediately on fail
167
-
168
- if (currentRunToken.get() != taskToken) {
169
- promise.reject("E_CANCELLED", "Weather frame fetch superseded");
170
- return;
171
- }
172
- if (!call.isCanceled()) {
173
- Log.e(TAG, "[FrameProcessor] Network failure: " + e.getMessage());
174
- promise.reject("NETWORK_ERROR", e.getMessage(), e);
175
- } else {
176
- promise.reject("E_CANCELLED", "Weather frame fetch superseded");
177
- }
178
- }
179
-
180
- @Override
181
- public void onResponse(@NonNull Call call, @NonNull Response response) {
182
- activeCalls.remove(call);
183
- // NOTE: We hold the semaphore until processing is done to prevent
184
- // CPU saturation from JSON parsing.
185
-
186
- // Check token immediately
187
- if (currentRunToken.get() != taskToken) {
188
- response.close();
189
- semaphore.release();
190
- promise.reject("E_CANCELLED", "Weather frame fetch superseded");
191
- return;
192
- }
193
-
194
- FileOutputStream fos = null;
195
- try {
196
- if (!response.isSuccessful()) {
197
- if (debug) {
198
- String bodyPeek = "";
199
- try {
200
- ResponseBody peekBody = response.peekBody(512);
201
- if (peekBody != null) {
202
- bodyPeek = peekBody.string();
203
- }
204
- } catch (Exception ignored) {
205
- bodyPeek = "(unreadable)";
206
- }
207
- String redactedUrl = urlString.replaceAll("([?&])apiKey=[^&]*", "$1apiKey=(redacted)");
208
- Log.e("AguaceroRN", "[debug][FrameProcessor] HTTP_ERROR code=" + response.code()
209
- + " url=" + redactedUrl
210
- + " apiKey.len=" + (apiKey != null ? apiKey.length() : 0)
211
- + " bundleId=" + (bundleId != null && !bundleId.isEmpty() ? bundleId : "(none)")
212
- + " gridOrigin=" + (gridRequestSiteOrigin != null ? gridRequestSiteOrigin : "(none)")
213
- + " body=" + bodyPeek);
214
- if (response.code() == 403) {
215
- Log.e("AguaceroRN", "[debug][FrameProcessor] 403 hint: verify API key, allowlisted bundleId (x-app-identifier), and gridRequestSiteOrigin (Origin/Referer) match your web app.");
216
- }
217
- }
218
- promise.reject("HTTP_ERROR", "HTTP " + response.code());
219
- return;
220
- }
221
-
222
- ResponseBody body = response.body();
223
- if (body == null) {
224
- promise.reject("NO_DATA", "Response body was null");
225
- return;
226
- }
227
-
228
- byte[] bodyBytes = body.bytes();
229
-
230
- if (currentRunToken.get() != taskToken) {
231
- promise.reject("E_CANCELLED", "Weather frame fetch superseded");
232
- return;
233
- }
234
-
235
- // JSON parse
236
- String jsonString = new String(bodyBytes);
237
- bodyBytes = null; // FIX 4: Nullify immediately to help GC
238
-
239
- JSONObject jsonResponse = new JSONObject(jsonString);
240
- jsonString = null; // FIX 4: Nullify immediately
241
-
242
- if (!jsonResponse.has("data") || !jsonResponse.has("encoding")) {
243
- promise.reject("INVALID_RESPONSE", "Invalid API response structure");
244
- return;
245
- }
246
-
247
- String b64CompressedData = jsonResponse.getString("data");
248
- JSONObject encoding = jsonResponse.getJSONObject("encoding");
249
-
250
- // Don't need the huge JSON object anymore
251
- jsonResponse = null;
252
-
253
- // Base64 decode
254
- byte[] compressedData = Base64.decode(b64CompressedData, Base64.DEFAULT);
255
- b64CompressedData = null; // FIX 4: Nullify immediately
256
-
257
- if (currentRunToken.get() != taskToken) {
258
- promise.reject("E_CANCELLED", "Weather frame fetch superseded");
259
- return;
260
- }
261
-
262
- // Disk write
263
- File cacheDir = reactContext.getCacheDir();
264
- // Use a unique name based on hash
265
- String fileName = "frame_" + urlString.hashCode() + ".zst";
266
- File dataFile = new File(cacheDir, fileName);
267
-
268
- fos = new FileOutputStream(dataFile);
269
- fos.write(compressedData);
270
- fos.flush();
271
-
272
- WritableMap responseMap = Arguments.createMap();
273
- responseMap.putString("filePath", dataFile.getAbsolutePath());
274
- responseMap.putDouble("scale", encoding.getDouble("scale"));
275
- responseMap.putDouble("offset", encoding.getDouble("offset"));
276
- responseMap.putDouble("missing", encoding.getDouble("missing_quantized"));
277
- if (encoding.has("scale_type")) {
278
- responseMap.putString("scaleType", encoding.getString("scale_type"));
279
- }
280
-
281
- if (currentRunToken.get() != taskToken) {
282
- promise.reject("E_CANCELLED", "Weather frame fetch superseded");
283
- return;
284
- }
285
-
286
- promise.resolve(responseMap);
287
-
288
- } catch (Exception e) {
289
- if (currentRunToken.get() == taskToken) {
290
- Log.e(TAG, "[FrameProcessor] Error: " + e.getMessage(), e);
291
- promise.reject("PROCESSING_ERROR", e.getMessage(), e);
292
- }
293
- } finally {
294
- // FIX 5: Ensure resources are closed and semaphore is released
295
- if (fos != null) {
296
- try { fos.close(); } catch (IOException ex) { /* ignore */ }
297
- }
298
- response.close();
299
- semaphore.release();
300
- }
301
- }
302
- });
303
-
304
- } catch (InterruptedException e) {
305
- Thread.currentThread().interrupt();
306
- // If interrupted while waiting for semaphore, just exit
307
- } catch (Exception e) {
308
- promise.reject("EXECUTION_ERROR", e.getMessage());
309
- }
310
- });
311
- }
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
+ }
312
316
  }