@appspacer/react-native 1.0.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.
@@ -0,0 +1,731 @@
1
+ package com.appspacer;
2
+
3
+ import androidx.annotation.NonNull;
4
+ import androidx.annotation.Nullable;
5
+
6
+ import com.facebook.react.bridge.Arguments;
7
+ import com.facebook.react.bridge.Promise;
8
+ import com.facebook.react.bridge.ReactApplicationContext;
9
+ import com.facebook.react.bridge.ReactContextBaseJavaModule;
10
+ import com.facebook.react.bridge.ReactMethod;
11
+ import com.facebook.react.bridge.UiThreadUtil;
12
+ import com.facebook.react.bridge.WritableMap;
13
+ import com.facebook.react.modules.core.DeviceEventManagerModule;
14
+ import com.facebook.react.ReactApplication;
15
+ import com.facebook.react.ReactInstanceManager;
16
+
17
+ import java.io.File;
18
+ import java.io.FileInputStream;
19
+ import java.io.FileOutputStream;
20
+ import java.io.InputStream;
21
+ import java.net.HttpURLConnection;
22
+ import java.net.URL;
23
+ import java.security.MessageDigest;
24
+ import java.util.zip.ZipEntry;
25
+ import java.util.zip.ZipInputStream;
26
+ import java.util.UUID;
27
+ import org.json.JSONObject;
28
+ import org.json.JSONArray;
29
+ import java.nio.charset.StandardCharsets;
30
+
31
+ import android.content.SharedPreferences;
32
+ import android.content.Context;
33
+ import android.util.Log;
34
+
35
+ public class AppSpacerModule extends ReactContextBaseJavaModule {
36
+
37
+ private static final String MODULE_NAME = "AppSpacerModule";
38
+ private static final String TAG = "AppSpacerModule";
39
+ private static final String PREFS_NAME = "AppSpacerPrefs";
40
+ private static final String KEY_PACKAGE_INFO = "packageInfo";
41
+ private static final String KEY_PREVIOUS_PACKAGE_INFO = "appspacer_previous_package_info";
42
+ private static final String KEY_BOOT_STATUS = "bootStatus"; // "SUCCESS" or "PENDING"
43
+ private static final String KEY_BOOT_COUNT = "bootCount";
44
+
45
+ private static final String OTA_DIR = "AppSpacerOTA";
46
+
47
+ private final ReactApplicationContext reactContext;
48
+
49
+ public AppSpacerModule(ReactApplicationContext context) {
50
+ super(context);
51
+ this.reactContext = context;
52
+
53
+ // Initialize Native Crash Handler automatically
54
+ AppSpacerCrashHandler.init(context);
55
+
56
+ // Restore user identity if it exists
57
+ SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
58
+ String userId = prefs.getString("userId", null);
59
+ String userMetadata = prefs.getString("userMetadata", null);
60
+ if (userId != null) {
61
+ AppSpacerCrashHandler.setUserIdentity(userId, userMetadata);
62
+ }
63
+ }
64
+
65
+ @NonNull
66
+ @Override
67
+ public String getName() {
68
+ return MODULE_NAME;
69
+ }
70
+
71
+ // ─── Helpers ──────────────────────────────────────────────
72
+
73
+ private void sendDownloadProgress(double progress) {
74
+ WritableMap params = Arguments.createMap();
75
+ params.putDouble("progress", progress);
76
+ try {
77
+ reactContext
78
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
79
+ .emit("AppSpacerDownloadProgress", params);
80
+ } catch (Exception e) {
81
+ // JS not ready yet — ignore
82
+ }
83
+ }
84
+
85
+ private static File getOtaRootDir(Context context) {
86
+ return new File(context.getFilesDir(), OTA_DIR);
87
+ }
88
+
89
+ // ─── OTA Storage ─────────────────────────────────────────
90
+
91
+ @ReactMethod
92
+ public void getOtaStoragePath(Promise promise) {
93
+ try {
94
+ File otaDir = getOtaRootDir(reactContext);
95
+ if (!otaDir.exists()) {
96
+ otaDir.mkdirs();
97
+ }
98
+ promise.resolve(otaDir.getAbsolutePath());
99
+ } catch (Exception e) {
100
+ promise.reject("ERR_CREATE_DIR", "Failed to create OTA directory", e);
101
+ }
102
+ }
103
+
104
+ // ─── Download with Progress ──────────────────────────────
105
+
106
+ @ReactMethod
107
+ public void downloadPackage(String urlString, String destPath, Promise promise) {
108
+ new Thread(() -> {
109
+ HttpURLConnection connection = null;
110
+ try {
111
+ URL url = new URL(urlString);
112
+ connection = (HttpURLConnection) url.openConnection();
113
+ connection.setRequestMethod("GET");
114
+ connection.setConnectTimeout(15000);
115
+ connection.setReadTimeout(30000);
116
+ connection.connect();
117
+
118
+ int responseCode = connection.getResponseCode();
119
+ if (responseCode < 200 || responseCode >= 300) {
120
+ promise.reject("ERR_HTTP", "Server returned HTTP " + responseCode);
121
+ return;
122
+ }
123
+
124
+ long totalBytes = connection.getContentLength();
125
+ File destFile = new File(destPath, "update.zip");
126
+ if (destFile.exists()) {
127
+ destFile.delete();
128
+ }
129
+
130
+ try (InputStream inputStream = connection.getInputStream();
131
+ FileOutputStream outputStream = new FileOutputStream(destFile)) {
132
+ byte[] buffer = new byte[8192];
133
+ int bytesRead;
134
+ long downloadedBytes = 0;
135
+ long lastProgressTime = 0;
136
+
137
+ while ((bytesRead = inputStream.read(buffer)) != -1) {
138
+ outputStream.write(buffer, 0, bytesRead);
139
+ downloadedBytes += bytesRead;
140
+
141
+ // Emit progress at most every 250ms to avoid flooding the bridge
142
+ long now = System.currentTimeMillis();
143
+ if (totalBytes > 0 && (now - lastProgressTime) >= 250) {
144
+ double progress = (double) downloadedBytes / totalBytes;
145
+ sendDownloadProgress(progress);
146
+ lastProgressTime = now;
147
+ }
148
+ }
149
+ }
150
+
151
+ // Final 100% progress
152
+ sendDownloadProgress(1.0);
153
+ promise.resolve(destFile.getAbsolutePath());
154
+ } catch (Exception e) {
155
+ promise.reject("ERR_DOWNLOAD", "Package download failed", e);
156
+ } finally {
157
+ if (connection != null) {
158
+ connection.disconnect();
159
+ }
160
+ }
161
+ }).start();
162
+ }
163
+
164
+ // ─── Hash Verification ───────────────────────────────────
165
+
166
+ @ReactMethod
167
+ public void verifyHash(String filePath, String expectedHash, Promise promise) {
168
+ new Thread(() -> {
169
+ try {
170
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
171
+ try (InputStream fis = new FileInputStream(filePath)) {
172
+ byte[] buffer = new byte[8192];
173
+ int n;
174
+ while ((n = fis.read(buffer)) != -1) {
175
+ digest.update(buffer, 0, n);
176
+ }
177
+ }
178
+ byte[] hashBytes = digest.digest();
179
+
180
+ StringBuilder hexString = new StringBuilder();
181
+ for (byte b : hashBytes) {
182
+ String hex = Integer.toHexString(0xff & b);
183
+ if (hex.length() == 1) hexString.append('0');
184
+ hexString.append(hex);
185
+ }
186
+
187
+ boolean isValid = hexString.toString().equals(expectedHash);
188
+ promise.resolve(isValid);
189
+ } catch (Exception e) {
190
+ promise.reject("ERR_HASH", "Failed to compute hash", e);
191
+ }
192
+ }).start();
193
+ }
194
+
195
+ // ─── Unzip ───────────────────────────────────────────────
196
+
197
+ @ReactMethod
198
+ public void unzipPackage(String zipPath, String destDir, Promise promise) {
199
+ new Thread(() -> {
200
+ try {
201
+ File dir = new File(destDir);
202
+ if (!dir.exists()) dir.mkdirs();
203
+ String canonicalDest = dir.getCanonicalPath() + File.separator;
204
+
205
+ try (ZipInputStream zis = new ZipInputStream(new FileInputStream(zipPath))) {
206
+ ZipEntry entry;
207
+ byte[] buffer = new byte[8192];
208
+
209
+ while ((entry = zis.getNextEntry()) != null) {
210
+ File file = new File(dir, entry.getName());
211
+
212
+ // Zip Slip protection
213
+ if (!file.getCanonicalPath().startsWith(canonicalDest)) {
214
+ throw new SecurityException("Zip entry outside target dir: " + entry.getName());
215
+ }
216
+
217
+ if (entry.isDirectory()) {
218
+ file.mkdirs();
219
+ } else {
220
+ File parent = file.getParentFile();
221
+ if (parent != null && !parent.exists()) parent.mkdirs();
222
+ try (FileOutputStream fos = new FileOutputStream(file)) {
223
+ int len;
224
+ while ((len = zis.read(buffer)) > 0) {
225
+ fos.write(buffer, 0, len);
226
+ }
227
+ }
228
+ }
229
+ zis.closeEntry();
230
+ }
231
+ }
232
+
233
+ // Clean up zip
234
+ new File(zipPath).delete();
235
+ promise.resolve(null);
236
+ } catch (SecurityException e) {
237
+ promise.reject("ERR_SECURITY", e.getMessage(), e);
238
+ } catch (Exception e) {
239
+ promise.reject("ERR_UNZIP", "Failed to unzip package", e);
240
+ }
241
+ }).start();
242
+ }
243
+
244
+ // ─── Install Update ──────────────────────────────────────
245
+
246
+ @ReactMethod
247
+ public void installUpdate(String unzippedDir, Promise promise) {
248
+ new Thread(() -> {
249
+ try {
250
+ // Verify the unzipped directory actually exists
251
+ File updateDir = new File(unzippedDir);
252
+ if (!updateDir.exists() || !updateDir.isDirectory()) {
253
+ promise.reject("ERR_INSTALL", "Unzipped update directory does not exist");
254
+ return;
255
+ }
256
+
257
+ SharedPreferences prefs = reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
258
+ String currentInfo = prefs.getString(KEY_PACKAGE_INFO, null);
259
+
260
+ SharedPreferences.Editor editor = prefs.edit();
261
+ if (currentInfo != null) {
262
+ editor.putString(KEY_PREVIOUS_PACKAGE_INFO, currentInfo);
263
+ }
264
+
265
+ editor.putString(KEY_BOOT_STATUS, "PENDING")
266
+ .putInt(KEY_BOOT_COUNT, 0)
267
+ .apply();
268
+
269
+ promise.resolve(null);
270
+ } catch (Exception e) {
271
+ promise.reject("ERR_INSTALL", "Failed to install update directory", e);
272
+ }
273
+ }).start();
274
+ }
275
+
276
+ // ─── Reload ──────────────────────────────────────────────
277
+
278
+ @ReactMethod
279
+ public void reloadApp() {
280
+ UiThreadUtil.runOnUiThread(() -> {
281
+ try {
282
+ ReactApplication application = (ReactApplication) reactContext.getApplicationContext();
283
+
284
+ // 1. Try New Architecture (ReactHost)
285
+ try {
286
+ com.facebook.react.ReactHost reactHost = application.getReactHost();
287
+ if (reactHost != null) {
288
+ reactHost.reload("AppSpacer Update");
289
+ return;
290
+ }
291
+ } catch (Throwable ignore) {}
292
+
293
+ // 2. Fallback: Restart the Activity via Intent
294
+ // This forces Android to call getJSBundleFile() again, picking up the new OTA bundle.
295
+ // recreateReactContextInBackground() alone does NOT reliably swap bundle paths.
296
+ try {
297
+ android.app.Activity currentActivity = reactContext.getCurrentActivity();
298
+ if (currentActivity != null) {
299
+ android.content.Intent intent = currentActivity.getPackageManager()
300
+ .getLaunchIntentForPackage(currentActivity.getPackageName());
301
+ if (intent != null) {
302
+ intent.addFlags(android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP | android.content.Intent.FLAG_ACTIVITY_NEW_TASK);
303
+ currentActivity.startActivity(intent);
304
+ currentActivity.finish();
305
+ // Kill the current process so the new Activity starts fresh
306
+ android.os.Process.killProcess(android.os.Process.myPid());
307
+ return;
308
+ }
309
+ }
310
+ } catch (Throwable e) {
311
+ Log.w(MODULE_NAME, "Activity restart failed, falling back to recreateReactContextInBackground", e);
312
+ }
313
+
314
+ // 3. Last resort fallback to Classic Architecture (InstanceManager)
315
+ try {
316
+ ReactInstanceManager instanceManager = application.getReactNativeHost().getReactInstanceManager();
317
+ if (instanceManager != null) {
318
+ instanceManager.recreateReactContextInBackground();
319
+ }
320
+ } catch (Throwable ignore) {}
321
+ } catch (Exception e) {
322
+ Log.e(MODULE_NAME, "Failed to reload app", e);
323
+ }
324
+ });
325
+ }
326
+
327
+ // ─── Package Info ────────────────────────────────────────
328
+
329
+ @ReactMethod
330
+ public void getCurrentPackageHash(Promise promise) {
331
+ SharedPreferences prefs = reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
332
+ String infoStr = prefs.getString(KEY_PACKAGE_INFO, null);
333
+ if (infoStr != null) {
334
+ try {
335
+ JSONObject json = new JSONObject(infoStr);
336
+ promise.resolve(json.optString("hash", null));
337
+ return;
338
+ } catch (Exception e) {
339
+ // Ignore parsing errors
340
+ }
341
+ }
342
+ promise.resolve(null);
343
+ }
344
+
345
+ @ReactMethod
346
+ public void setCurrentPackageInfo(String info, Promise promise) {
347
+ SharedPreferences prefs = reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
348
+ prefs.edit().putString(KEY_PACKAGE_INFO, info).apply();
349
+ promise.resolve(null);
350
+ }
351
+
352
+ @ReactMethod
353
+ public void getCurrentPackageInfo(Promise promise) {
354
+ SharedPreferences prefs = reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
355
+ promise.resolve(prefs.getString(KEY_PACKAGE_INFO, null));
356
+ }
357
+
358
+ // ─── Boot Status & Rollback ──────────────────────────────
359
+
360
+ @ReactMethod
361
+ public void markSuccess(Promise promise) {
362
+ SharedPreferences prefs = reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
363
+ prefs.edit()
364
+ .putString(KEY_BOOT_STATUS, "SUCCESS")
365
+ .putInt(KEY_BOOT_COUNT, 0)
366
+ .apply();
367
+ promise.resolve(null);
368
+ }
369
+
370
+ @ReactMethod
371
+ public void rollback(Promise promise) {
372
+ performRollback(reactContext);
373
+ promise.resolve(null);
374
+ }
375
+
376
+ @ReactMethod
377
+ public void getInstallId(Promise promise) {
378
+ SharedPreferences prefs = reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
379
+ String installId = prefs.getString("installId", null);
380
+ if (installId == null) {
381
+ installId = UUID.randomUUID().toString();
382
+ prefs.edit().putString("installId", installId).apply();
383
+ }
384
+ promise.resolve(installId);
385
+ }
386
+
387
+ // ─── Asset Resolution ────────────────────────────────────
388
+
389
+ @ReactMethod
390
+ public void getAssetSourceDirectory(Promise promise) {
391
+ String assetPath = getCustomAssetPath(reactContext);
392
+ promise.resolve(assetPath);
393
+ }
394
+
395
+ @Nullable
396
+ public static String getCustomAssetPath(Context context) {
397
+ SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
398
+ String infoStr = prefs.getString(KEY_PACKAGE_INFO, null);
399
+ if (infoStr == null) return null;
400
+
401
+ try {
402
+ JSONObject json = new JSONObject(infoStr);
403
+ String hash = json.optString("hash", null);
404
+ if (hash == null || hash.isEmpty()) return null;
405
+
406
+ File otaDir = getOtaRootDir(context);
407
+ File updateDir = new File(otaDir, "update_" + hash);
408
+ if (updateDir.exists() && updateDir.isDirectory()) {
409
+ return updateDir.getAbsolutePath();
410
+ }
411
+ } catch (Exception e) {
412
+ Log.e("AppSpacer", "Error resolving asset path", e);
413
+ }
414
+
415
+ return null;
416
+ }
417
+
418
+ // ─── Clear Old Updates ───────────────────────────────────
419
+
420
+ @ReactMethod
421
+ public void clearOldUpdates(Promise promise) {
422
+ new Thread(() -> {
423
+ try {
424
+ File otaDir = getOtaRootDir(reactContext);
425
+ if (!otaDir.exists()) {
426
+ promise.resolve(0);
427
+ return;
428
+ }
429
+
430
+ SharedPreferences prefs = reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
431
+
432
+ // Get active hash
433
+ String activeHash = null;
434
+ String infoStr = prefs.getString(KEY_PACKAGE_INFO, null);
435
+ if (infoStr != null) {
436
+ try {
437
+ JSONObject json = new JSONObject(infoStr);
438
+ activeHash = json.optString("hash", null);
439
+ } catch (Exception e) {}
440
+ }
441
+
442
+ // Get previous hash
443
+ String previousHash = null;
444
+ String prevInfoStr = prefs.getString(KEY_PREVIOUS_PACKAGE_INFO, null);
445
+ if (prevInfoStr != null) {
446
+ try {
447
+ JSONObject json = new JSONObject(prevInfoStr);
448
+ previousHash = json.optString("hash", null);
449
+ } catch (Exception e) {}
450
+ }
451
+
452
+ File[] children = otaDir.listFiles();
453
+ int removedCount = 0;
454
+ if (children != null) {
455
+ for (File child : children) {
456
+ String name = child.getName();
457
+ if (!name.startsWith("update_")) continue;
458
+
459
+ String hash = name.substring(7); // strip "update_"
460
+ // Keep active and previous bundles
461
+ if (hash.equals(activeHash)) continue;
462
+ if (previousHash != null && hash.equals(previousHash)) continue;
463
+
464
+ deleteRecursive(child);
465
+ removedCount++;
466
+ }
467
+ }
468
+
469
+ promise.resolve(removedCount);
470
+ } catch (Exception e) {
471
+ promise.reject("ERR_CLEAR", "Failed to clear old updates", e);
472
+ }
473
+ }).start();
474
+ }
475
+
476
+ // ─── Get Update Metadata ─────────────────────────────────
477
+
478
+ @ReactMethod
479
+ public void getUpdateMetadata(Promise promise) {
480
+ SharedPreferences prefs = reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
481
+ promise.resolve(prefs.getString(KEY_PACKAGE_INFO, null));
482
+ }
483
+
484
+ // ─── Rollback Logic ──────────────────────────────────────
485
+
486
+ private static void performRollback(Context context) {
487
+ SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
488
+ String previousInfo = prefs.getString(KEY_PREVIOUS_PACKAGE_INFO, null);
489
+ String currentInfo = prefs.getString(KEY_PACKAGE_INFO, null);
490
+
491
+ SharedPreferences.Editor editor = prefs.edit();
492
+ if (previousInfo != null) {
493
+ editor.putString(KEY_PACKAGE_INFO, previousInfo);
494
+ editor.remove(KEY_PREVIOUS_PACKAGE_INFO);
495
+ } else {
496
+ editor.remove(KEY_PACKAGE_INFO);
497
+ }
498
+ editor.putString(KEY_BOOT_STATUS, "SUCCESS");
499
+ editor.apply();
500
+
501
+ // Remove the corrupt update directory
502
+ if (currentInfo != null) {
503
+ try {
504
+ JSONObject json = new JSONObject(currentInfo);
505
+ String hash = json.optString("hash", null);
506
+ if (hash != null && !hash.isEmpty()) {
507
+ File corruptDir = new File(getOtaRootDir(context), "update_" + hash);
508
+ deleteRecursive(corruptDir);
509
+ }
510
+ } catch (Exception e) {}
511
+ }
512
+ }
513
+
514
+ private static void deleteRecursive(File fileOrDirectory) {
515
+ if (fileOrDirectory.isDirectory()) {
516
+ File[] children = fileOrDirectory.listFiles();
517
+ if (children != null) {
518
+ for (File child : children) {
519
+ deleteRecursive(child);
520
+ }
521
+ }
522
+ }
523
+ fileOrDirectory.delete();
524
+ }
525
+
526
+ // ─── Bundle Path Resolution ──────────────────────────────
527
+
528
+ /**
529
+ * Call this from MainApplication to determine which bundle to load.
530
+ */
531
+ @Nullable
532
+ public static String getCustomBundlePath(Context context) {
533
+ SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
534
+ String status = prefs.getString(KEY_BOOT_STATUS, "SUCCESS");
535
+
536
+ if ("PENDING".equals(status)) {
537
+ int bootCount = prefs.getInt(KEY_BOOT_COUNT, 0);
538
+ bootCount++;
539
+ prefs.edit().putInt(KEY_BOOT_COUNT, bootCount).apply();
540
+
541
+ if (bootCount > 1) {
542
+ Log.e("AppSpacer", "Crash detected on boot. Rolling back to previous bundle...");
543
+ performRollback(context);
544
+ }
545
+ }
546
+
547
+ String infoStr = prefs.getString(KEY_PACKAGE_INFO, null);
548
+ if (infoStr == null) return null;
549
+
550
+ try {
551
+ JSONObject json = new JSONObject(infoStr);
552
+ String hash = json.optString("hash", null);
553
+ if (hash == null || hash.isEmpty()) return null;
554
+
555
+ File otaDir = getOtaRootDir(context);
556
+ File currentBundle = new File(otaDir, "update_" + hash);
557
+ File customBundle = findBundleRecursive(currentBundle, "index.android.bundle");
558
+
559
+ if (customBundle != null && customBundle.exists()) {
560
+ return customBundle.getAbsolutePath();
561
+ }
562
+ } catch (Exception e) {
563
+ Log.e("AppSpacer", "Error parsing package info", e);
564
+ }
565
+
566
+ return null;
567
+ }
568
+
569
+ private static File findBundleRecursive(File dir, String bundleName) {
570
+ if (dir != null && dir.isDirectory()) {
571
+ File[] files = dir.listFiles();
572
+ if (files != null) {
573
+ for (File file : files) {
574
+ if (file.isFile() && file.getName().equals(bundleName)) {
575
+ return file;
576
+ }
577
+ }
578
+ for (File file : files) {
579
+ if (file.isDirectory()) {
580
+ File result = findBundleRecursive(file, bundleName);
581
+ if (result != null) return result;
582
+ }
583
+ }
584
+ }
585
+ }
586
+ return null;
587
+ }
588
+
589
+ // ─── Crash Analytics ─────────────────────────────────────
590
+
591
+ private static File getCrashDir(Context context) {
592
+ if (context == null) {
593
+ Log.e(MODULE_NAME, "Cannot get crash directory: context is null");
594
+ return null;
595
+ }
596
+ File dir = new File(context.getFilesDir(), "appspacer_crashes");
597
+ if (!dir.exists()) {
598
+ boolean created = dir.mkdirs();
599
+ if (created) {
600
+ Log.d(TAG, "Created crash directory: " + dir.getAbsolutePath());
601
+ } else {
602
+ Log.e(TAG, "Failed to create crash directory: " + dir.getAbsolutePath());
603
+ }
604
+ }
605
+ return dir;
606
+ }
607
+
608
+ public static void saveCrashToDiskSync(Context context, String id, String jsonPayload) {
609
+ try {
610
+ File crashDir = getCrashDir(context);
611
+ if (crashDir == null) return;
612
+
613
+ File crashFile = new File(crashDir, id + ".json");
614
+ Log.d(MODULE_NAME, "Saving crash report to: " + crashFile.getAbsolutePath());
615
+
616
+ try (FileOutputStream fos = new FileOutputStream(crashFile)) {
617
+ fos.write(jsonPayload.getBytes(StandardCharsets.UTF_8));
618
+ fos.flush();
619
+ }
620
+ Log.i(MODULE_NAME, "Crash report saved successfully: " + id);
621
+ } catch (Exception e) {
622
+ Log.e(MODULE_NAME, "Failed to save crash to disk: " + id, e);
623
+ }
624
+ }
625
+
626
+ @ReactMethod
627
+ public void setUserIdentity(String userId, String metadata) {
628
+ SharedPreferences prefs = reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
629
+ prefs.edit()
630
+ .putString("userId", userId)
631
+ .putString("userMetadata", metadata)
632
+ .apply();
633
+
634
+ // Also update the static handler for native crashes
635
+ AppSpacerCrashHandler.setUserIdentity(userId, metadata);
636
+ }
637
+
638
+ @ReactMethod
639
+ public void addBreadcrumb(String message) {
640
+ AppSpacerCrashHandler.addBreadcrumb(message);
641
+ }
642
+
643
+ @ReactMethod
644
+ public void saveCrashReport(String reportJson, Promise promise) {
645
+ try {
646
+ JSONObject json = new JSONObject(reportJson);
647
+ String id = json.getString("id");
648
+
649
+ // Inject persisted user info if not already present
650
+ SharedPreferences prefs = reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
651
+ String userId = prefs.getString("userId", null);
652
+ String userMetadata = prefs.getString("userMetadata", null);
653
+
654
+ if (userId != null && !json.has("user_id")) {
655
+ json.put("user_id", userId);
656
+ }
657
+ if (userMetadata != null && !json.has("user_metadata")) {
658
+ try {
659
+ json.put("user_metadata", new JSONObject(userMetadata));
660
+ } catch (Exception ignore) {}
661
+ }
662
+
663
+ saveCrashToDiskSync(reactContext, id, json.toString());
664
+ promise.resolve(null);
665
+ } catch (Exception e) {
666
+ promise.reject("ERR_CRASH_SAVE", "Failed to save crash report", e);
667
+ }
668
+ }
669
+
670
+ @ReactMethod
671
+ public void getPendingCrashReports(Promise promise) {
672
+ new Thread(() -> {
673
+ try {
674
+ File crashDir = getCrashDir(reactContext);
675
+ File[] files = crashDir.listFiles((dir, name) -> name.endsWith(".json"));
676
+
677
+ JSONArray logs = new JSONArray();
678
+ if (files != null) {
679
+ for (File f : files) {
680
+ try (FileInputStream fis = new FileInputStream(f)) {
681
+ byte[] data = new byte[(int) f.length()];
682
+ fis.read(data);
683
+ String content = new String(data, StandardCharsets.UTF_8);
684
+ logs.put(new JSONObject(content));
685
+ } catch (Exception ignore) {}
686
+ }
687
+ }
688
+
689
+ if (logs.length() == 0) {
690
+ promise.resolve(null);
691
+ } else {
692
+ promise.resolve(logs.toString());
693
+ }
694
+ } catch (Exception e) {
695
+ promise.reject("ERR_CRASH_READ", "Failed to read crash reports", e);
696
+ }
697
+ }).start();
698
+ }
699
+
700
+ @ReactMethod
701
+ public void deleteCrashReport(String id, Promise promise) {
702
+ new Thread(() -> {
703
+ try {
704
+ File crashDir = getCrashDir(reactContext);
705
+ File crashFile = new File(crashDir, id + ".json");
706
+ if (crashFile.exists()) {
707
+ crashFile.delete();
708
+ }
709
+ promise.resolve(null);
710
+ } catch (Exception e) {
711
+ promise.reject("ERR_CRASH_DELETE", "Failed to delete crash report", e);
712
+ }
713
+ }).start();
714
+ }
715
+
716
+ @ReactMethod
717
+ public void testNativeCrash() {
718
+ // Trigger a runtime exception which will be caught by AppSpacerCrashHandler
719
+ throw new RuntimeException("Native crash test triggered from AppSpacer SDK");
720
+ }
721
+
722
+ @ReactMethod
723
+ public void addListener(String eventName) {
724
+ // Required for NativeEventEmitter
725
+ }
726
+
727
+ @ReactMethod
728
+ public void removeListeners(Integer count) {
729
+ // Required for NativeEventEmitter
730
+ }
731
+ }