@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.
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/android/build.gradle +28 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/appspacer/AppSpacerCrashHandler.java +128 -0
- package/android/src/main/java/com/appspacer/AppSpacerModule.java +731 -0
- package/android/src/main/java/com/appspacer/AppSpacerPackage.java +29 -0
- package/dist/AppSpacer.d.ts +116 -0
- package/dist/AppSpacer.js +546 -0
- package/dist/CrashReporter.d.ts +1 -0
- package/dist/CrashReporter.js +41 -0
- package/dist/NativeAppSpacer.d.ts +34 -0
- package/dist/NativeAppSpacer.js +2 -0
- package/dist/assetResolver.d.ts +24 -0
- package/dist/assetResolver.js +130 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +13 -0
- package/dist/native.d.ts +88 -0
- package/dist/native.js +25 -0
- package/dist/types.d.ts +166 -0
- package/dist/types.js +50 -0
- package/dist/useAppSpacerUpdate.d.ts +31 -0
- package/dist/useAppSpacerUpdate.js +81 -0
- package/dist/withAppSpacer.d.ts +4 -0
- package/dist/withAppSpacer.js +487 -0
- package/ios/AppSpacerCrashHandler.h +12 -0
- package/ios/AppSpacerCrashHandler.m +90 -0
- package/ios/AppSpacerModule.h +12 -0
- package/ios/AppSpacerModule.mm +545 -0
- package/package.json +51 -0
- package/react-native-appspacer.podspec +17 -0
|
@@ -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
|
+
}
|